From 4f4664f804026c4117a32f5fb04d8d7aae96277f Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 00:42:48 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20cache=20qemu=20:=20soustraire=20l'invit?= =?UTF-8?q?=C3=A9=20qui=20n'a=20pas=20de=20magasin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le détournement est TRANSPARENT et vaut pour tout le pont : ne pas donner l'autorité à une VM ne la dispense pas d'être interceptée, elle échoue sur « self-signed certificate in certificate chain ». NixOS n'a pas d'ancre de confiance par fichier, et la poser par déclaration arriverait trop tard — la première reconstruction EST le premier téléchargement. La VM est donc exceptée par son adresse MAC, avant sa création, et cela se dit. L'image Proxmox n'est mise en place qu'une fois complète : « wget -O » écrivait dans la cible, et une coupure y figeait un fichier tronqué que le test de présence acceptait à chaque déploiement suivant. --- EN --- Interception is TRANSPARENT and covers the whole bridge: withholding the authority from a VM does not spare it, it fails on "self-signed certificate in certificate chain". NixOS has no per-file trust anchor, and declaring one would come too late — the first rebuild IS the first download. The VM is therefore exempted by MAC, before creation, and it is said. The Proxmox image is put in place only once complete: "wget -O" wrote into the target, and an interruption froze a truncated file there that the presence test accepted on every later deployment. Assisted-by: Claude Opus 5 --- long_test/descente.py | 35 +++++++++- long_test/qemu_cache.py | 3 +- script/proxmox/proxmox_deploy.py | 26 ++++++-- script/qemu/deploy_qemu.py | 38 +++++++++++ script/todo/proxmox_menu.py | 20 ++++-- script/todo/todo_i18n.py | 12 ++-- test/test_proxmox_form.py | 69 +++++++++++++++++++ test/test_qemu_cache_guest.py | 39 ++++++++++- test/test_qemu_nixos.py | 36 +++++++++- test/test_qemu_seed_locale_keyboard.py | 92 ++++++++++++++++++++++++-- test/test_todo_longtest.py | 58 ++++++++++++++-- 11 files changed, 399 insertions(+), 29 deletions(-) diff --git a/long_test/descente.py b/long_test/descente.py index eb59a90..7616f01 100644 --- a/long_test/descente.py +++ b/long_test/descente.py @@ -37,7 +37,39 @@ from script.proxmox import proxmox_deploy as pve # noqa: E402 # Les scripts qui lancent une descente. Le verrou les cherche TOUS : deux # descentes de piles différentes se disputeraient la RAM, le disque et # ~/.ssh/config aussi sûrement que deux de la même. -SCRIPTS = ("deep_proxmox.py", "deep_qemu.py", "install_nixos.py") +SCRIPTS = ( + "deep_proxmox.py", + "deep_qemu.py", + "install_nixos.py", + "qemu_cache.py", +) + +# L'autorité du cache de téléchargement, telle que le service l'écrit sur +# l'hôte. Une seule définition pour tout long_test/ : une seconde dériverait +# en silence, et un test qui pose la mauvaise autorité échoue comme s'il n'en +# posait aucune. +CACHE_CA = "/var/lib/erplibre_go_qemu_cache/ca.crt" + + +def drapeaux_cache(): + """Les drapeaux de cache à passer à la CLI QEMU pour une VM locale. + + Le détournement est TRANSPARENT et vaut pour tout le pont libvirt : une + VM créée ici est interceptée qu'elle le demande ou non. Ne rien passer ne + la laisse donc pas en direct — elle reçoit un certificat qu'elle ne + reconnaît pas, et chaque téléchargement HTTPS échoue sur « self-signed + certificate in certificate chain » : l'image, puis le gestionnaire de + paquets, puis tout le reste de l'étage. + + Deux issues, et elles se valent pour le test : approuver l'autorité quand + l'hôte en porte une, ou demander une exception par adresse MAC sinon. Sur + un hôte sans cache, l'exception ne fait rien et le dit — la CLI n'exige + pas d'installer un cache pour pouvoir s'en passer. + """ + if os.path.isfile(CACHE_CA): + return ["--cache-ca", CACHE_CA] + return ["--cache-bypass"] + # Une étape bloquée ne doit pas bloquer le test : chaque appel est borné, et le # journal dit lequel a expiré. Généreux, parce que chaque étage est plus lent @@ -458,6 +490,7 @@ class Descente: pub = cle_publique() if pub: argv += ["--ssh-key", pub] + argv += drapeaux_cache() if self.dry_run: print(" " + " ".join(shlex.quote(a) for a in argv)) return nom diff --git a/long_test/qemu_cache.py b/long_test/qemu_cache.py index 9e1edba..7a08791 100755 --- a/long_test/qemu_cache.py +++ b/long_test/qemu_cache.py @@ -53,6 +53,7 @@ sys.path.insert(0, os.path.join(RACINE, "long_test")) sys.path.insert(0, RACINE) from descente import ( # noqa: E402 + CACHE_CA, Descente, cle_publique, detruire_etage1, @@ -95,7 +96,7 @@ DISTRO = "arch" VERSION = "latest" CLI = os.path.join(RACINE, "script/qemu/deploy_qemu.py") -CA = "/var/lib/erplibre_go_qemu_cache/ca.crt" +CA = CACHE_CA CACHE_BIN = "/usr/local/bin/erplibre_go_qemu_cache" SERVICE = "erplibre-go-qemu-cache.service" CONF = "/etc/erplibre_go_qemu_cache/env" diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py index 7f23df8..2913ce0 100644 --- a/script/proxmox/proxmox_deploy.py +++ b/script/proxmox/proxmox_deploy.py @@ -958,17 +958,35 @@ def image_fetch_cmd( continuer reviendrait à installer un système que personne n'a regardé. """ cible = f"{repertoire}/{nom}" + partiel = f"{cible}.partiel" + somme = lambda f: ( # noqa: E731 - une expression, pas une fonction + f"echo {shlex.quote(f'{sha256} {f}')} | sha256sum -c -" + ) + # Le téléchargement va dans un nom PROVISOIRE, et n'est renommé qu'une + # fois complet. « wget -O » écrivait dans la cible : une coupure — + # réseau, disque plein, Ctrl-C — y figeait une image tronquée que le + # « [ -s ] » ci-dessous acceptait à chaque déploiement suivant. Avec une + # somme, elle échouait pour toujours sans dire quoi effacer ; sans somme, + # elle servait à créer une VM. + recuperation = f"wget -nv -O {shlex.quote(partiel)} {shlex.quote(url)}" + if sha256: + # Vérifiée AVANT d'être mise en place : une image fausse ne devient + # jamais celle que le prochain déploiement trouvera « déjà présente ». + recuperation += f" && {somme(partiel)}" + recuperation += f" && mv {shlex.quote(partiel)} {shlex.quote(cible)}" + cmd = ( f"mkdir -p {shlex.quote(repertoire)} && " f"if [ -s {shlex.quote(cible)} ]; then " f'echo "image déjà présente : {cible}"; else ' - f"wget -nv -O {shlex.quote(cible)} {shlex.quote(url)}; " + f"{recuperation}; " f"fi" ) if sha256: - cmd += ( - f" && echo {shlex.quote(f'{sha256} {cible}')}" " | sha256sum -c -" - ) + # Et la cible elle-même, fraîche ou déjà en cache : le cas visé est un + # fichier substitué entre deux déploiements, qu'aucun test de présence + # ne voit. + cmd += f" && {somme(cible)}" return cmd diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index ca4c7b0..16421d2 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -2799,6 +2799,32 @@ OFFLINE_BOOTCMD = [ CACHE_CERT_NAME = "erplibre-cache.crt" +# Les familles à qui AUCUN des trois gestes de CACHE_TRUST ne s'applique. +# +# « nix » en est une : NixOS n'a pas de dossier d'ancres inscriptible — /usr +# n'y existe qu'en lecture seule et /etc est produit par le système —, aucune +# commande de mise à jour du magasin, et son faisceau est un lien du store que +# seule une reconstruction change. Y recopier une ligne calquée sur apt +# rendrait le contrôle vert sans rien poser : la VM resterait interceptée et +# sans autorité, c'est-à-dire muette sur chaque téléchargement HTTPS. +# +# Le détournement, lui, porte sur tout le pont. Une VM de ces familles doit +# donc en être SOUSTRAITE, faute de quoi elle ne télécharge plus rien — et le +# message qu'elle rendrait, « self-signed certificate in certificate chain », +# ne dit rien d'une famille sans magasin. +CACHE_SANS_AUTORITE = frozenset({"nix"}) + + +def cache_sans_autorite(distro: str) -> bool: + """La VM de `distro` peut-elle recevoir l'autorité du cache ? + + Non quand sa famille n'a pas de magasin de certificats où l'écrire. Le + déploiement l'exempte alors du détournement plutôt que de la laisser + échouer sur un certificat qu'elle ne peut pas apprendre. + """ + famille = cache_family(distro) + return bool(famille) and famille in CACHE_SANS_AUTORITE + def cache_family(distro: str) -> str: """Famille de gestionnaire de paquets d'un système du catalogue. @@ -5237,6 +5263,18 @@ def main() -> None: print(f"\n== 5/5 virt-install (--osinfo {resolved_osinfo}) ==") ensure_network(network_name(args.network), runner) # Avant la création, et non après : la VM télécharge dès cloud-init. + # Une famille sans magasin de certificats ne peut pas apprendre + # l'autorité du cache : on l'en SOUSTRAIT plutôt que de la laisser buter + # sur un certificat inconnu à chaque téléchargement. Décidé ici et non + # demandé à l'opérateur — c'est le catalogue qui sait. + if cache_sans_autorite(getattr(args, "distro", "")) and not getattr( + args, "cache_bypass", False + ): + args.cache_bypass = True + print( + f"\n {args.distro} n'a pas de magasin de certificats :" + " la VM est soustraite au cache et téléchargera en direct." + ) cache_bypass_apply(args, runner) virt_install(args, disk, seed, resolved_osinfo, runner, installer) if installer: diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 24846f0..91f3c37 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -1760,10 +1760,22 @@ class ProxmoxMenuMixin: ) fichiers = mod.cache_files(args) if not fichiers: - # Distribution hors table, ou autorité illisible : la VM - # télécharge en direct, ce qui marche tant qu'aucune règle ne la - # vise. Poser le fichier au mauvais endroit ne marcherait pas et - # ne dirait rien. + # Une distribution dont le magasin de confiance n'a pas de forme + # par fichier — un système déclaratif n'a pas d'ancre où écrire — + # ou une autorité illisible. Poser le fichier au mauvais endroit + # ne marcherait pas. + # + # Mais le silence était faux : on n'arrive ici que lorsque l'hôte + # Proxmox est lui-même une VM de CE pont, donc ses invités sont + # détournés. Celui-ci n'aura rien pour reconnaître le cache, et + # chaque téléchargement HTTPS échouera sur « self-signed + # certificate in certificate chain » — plus tard, dans la VM, + # loin d'ici. C'est pourquoi il est nommé. + cle = ( + "no trust store for this distribution, its downloads " + "will fail" + ) + print(f" ⚠ {t(cle)} : {vm.get('distro') or '?'}") return False morceaux = [] for chemin, mode, contenu, _proprio in fichiers: diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 0816e96..6eb4ef9 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4530,10 +4530,6 @@ TRANSLATIONS = { "fr": "Partir d'une machine NixOS qu'on possède déjà", "en": "Use a NixOS machine you already have", }, - "Choice (1-2, default 1): ": { - "fr": "Choix (1-2, défaut 1) : ", - "en": "Choice (1-2, default 1): ", - }, "Nested QEMU depth: run it": { "fr": "Profondeur QEMU imbriqué : le lancer", "en": "Nested QEMU depth: run it", @@ -7272,6 +7268,14 @@ TRANSLATIONS = { "fr": "autorité du cache de téléchargement non posée", "en": "download cache authority not installed", }, + "no trust store for this distribution, its downloads " + "will fail": { + "fr": ( + "pas de magasin de confiance pour cette distribution, ses " + "téléchargements échoueront" + ), + "en": "no trust store for this distribution, its downloads will fail", + }, "Timezone": { "fr": "Fuseau horaire", "en": "Timezone", diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index edc87c0..cadc2cd 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -2552,5 +2552,74 @@ class TestLAutoriteDUneVmProxmoxHorsLigne(unittest.TestCase): self.assertNotIn("NPM_CONFIG_AUDIT", rendus[False]) +class TestUnInviteQueLAutoriteNAtteintPas(unittest.TestCase): + """Une distribution dont le magasin de confiance n'a pas de forme par + fichier ne reçoit rien à poser. + + On n'arrive ici que lorsque l'hôte Proxmox est lui-même une VM de ce pont + — c'est la condition de _pve_cache_ca. Son invité est donc détourné par + le cache, et sans autorité chaque téléchargement HTTPS échoue sur + « self-signed certificate in certificate chain ». Le taire déplace la + panne dans la VM, des minutes plus tard et sans sa cause. + """ + + def _todo(self): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + return TODO.__new__(TODO) + + class _Sans: + @staticmethod + def cache_files(args): + return [] + + class _Avec: + @staticmethod + def cache_files(args): + return [ + ("/usr/local/share/ca-certificates/x.crt", "0644", "PEM", "") + ] + + @staticmethod + def cache_commands(args): + return ["update-ca-certificates"] + + def _poser(self, mod, distro): + import contextlib + import io + + todo = self._todo() + vu = {"ssh": []} + todo._qemu_import_module = lambda: mod + todo._pve_ssh = lambda cible, cmd, timeout=120: ( + vu["ssh"].append(cmd) or (0, "") + ) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + vu["rendu"] = todo._pve_set_cache_ca( + "pve+vm-a", {"distro": distro}, "/var/lib/cache/ca.crt" + ) + vu["ecrit"] = sortie.getvalue() + return vu + + def test_la_distribution_sans_magasin_est_nommee(self): + vu = self._poser(self._Sans, "nixos") + self.assertFalse(vu["rendu"]) + self.assertEqual([], vu["ssh"], "ssh lancé pour rien") + self.assertIn("⚠", vu["ecrit"]) + self.assertIn("nixos", vu["ecrit"]) + + def test_celle_qui_en_a_un_le_pose_sans_avertir(self): + """L'avertissement ne doit pas se banaliser : le cas ordinaire sort + un « ✓ » et rien d'autre.""" + vu = self._poser(self._Avec, "debian") + self.assertTrue(vu["rendu"]) + self.assertEqual(1, len(vu["ssh"])) + self.assertIn("update-ca-certificates", vu["ssh"][0]) + self.assertNotIn("⚠", vu["ecrit"]) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/test/test_qemu_cache_guest.py b/test/test_qemu_cache_guest.py index 257099d..4133911 100644 --- a/test/test_qemu_cache_guest.py +++ b/test/test_qemu_cache_guest.py @@ -464,17 +464,50 @@ class TestAucunSystemeNestOublie(unittest.TestCase): ) def test_chaque_famille_sait_poser_lautorite(self): - from script.qemu.deploy_qemu import CACHE_TRUST, DISTROS, cache_family + """Chaque famille du catalogue sait poser l'autorité, OU est + explicitement soustraite au détournement. - manquantes = sorted( - {cache_family(d) for d in DISTROS} - set(CACHE_TRUST) + Le détournement porte sur tout le pont : une famille qui ne sait pas + apprendre l'autorité et qu'on laisse passer n'échoue pas à + l'installation du certificat — elle échoue sur CHAQUE téléchargement, + avec un message qui ne dit rien de la cause. + """ + from script.qemu.deploy_qemu import ( + CACHE_SANS_AUTORITE, + CACHE_TRUST, + DISTROS, + cache_family, ) + + familles = {cache_family(d) for d in DISTROS} - {""} + manquantes = sorted(familles - set(CACHE_TRUST) - CACHE_SANS_AUTORITE) self.assertEqual( manquantes, [], f"familles sans commande de confiance : {manquantes}", ) + def test_une_famille_exemptee_nest_pas_aussi_dans_la_table(self): + """Les deux listes s'excluent : une famille qui sait poser l'autorité + n'a rien à faire parmi les exemptées, et l'y laisser soustrairait au + cache une VM qui pouvait parfaitement en vivre.""" + from script.qemu.deploy_qemu import CACHE_SANS_AUTORITE, CACHE_TRUST + + self.assertEqual(set(), set(CACHE_TRUST) & CACHE_SANS_AUTORITE) + + def test_une_exemptee_est_bien_soustraite_au_detournement(self): + """L'exemption ne vaut que si le déploiement la POSE : la déclarer et + laisser la VM sur le pont ne change rien à son sort.""" + from script.qemu.deploy_qemu import cache_sans_autorite + + self.assertTrue(cache_sans_autorite("nixos")) + for connue in ("debian", "ubuntu", "fedora", "arch", "opensuse"): + with self.subTest(distro=connue): + self.assertFalse(cache_sans_autorite(connue)) + # Un système hors catalogue n'est pas « exempté » : il n'a pas de + # famille, et c'est une autre question que celle-ci. + self.assertFalse(cache_sans_autorite("inconnue")) + def test_la_famille_vient_du_catalogue_et_nest_pas_recopiee(self): """Deux tables qui disent la même chose dérivent : c'est ce qui a laissé Proxmox de côté.""" diff --git a/test/test_qemu_nixos.py b/test/test_qemu_nixos.py index 4662979..927ec51 100644 --- a/test/test_qemu_nixos.py +++ b/test/test_qemu_nixos.py @@ -270,6 +270,36 @@ class LesDeuxCheminsLisentLaMemeSomme(unittest.TestCase): # et non se contenter d'un avertissement dans le journal. self.assertIn("&& echo", cmd) + def test_the_download_lands_on_its_final_name_only_when_complete(self): + """« wget -O » écrivait dans la CIBLE : une coupure — réseau, disque + plein, Ctrl-C — y figeait une image tronquée que le test de présence + acceptait à chaque déploiement suivant. Avec une somme, elle échouait + pour toujours sans dire quoi effacer ; sans somme, elle servait à + créer une VM. + + Mesuré : un « .partiel » laissé par une coupure est repris et la + cible finit identique à l'amont.""" + from script.proxmox import proxmox_deploy as pve + + cmd = pve.image_fetch_cmd("http://x/i.qcow2", "i.qcow2") + self.assertIn("-O", cmd) + self.assertIn(".partiel", cmd) + # Le « mv » suit le téléchargement, et par « && » : un wget en échec + # ne doit RIEN mettre en place. + self.assertLess(cmd.index("wget"), cmd.index("mv ")) + self.assertIn("&& mv ", cmd) + + def test_a_fresh_download_is_checked_before_it_is_installed(self): + """Une image fausse ne doit jamais devenir celle que le prochain + déploiement trouvera « déjà présente ».""" + from script.proxmox import proxmox_deploy as pve + + cmd = pve.image_fetch_cmd( + "http://x/i.qcow2", "i.qcow2", sha256="c" * 64 + ) + i_somme = cmd.index("sha256sum") + self.assertLess(i_somme, cmd.index("mv ")) + def test_a_cached_image_is_verified_too(self): """Le cas qu'on veut prendre est un fichier substitué ou tronqué entre deux déploiements : le test de présence ne regarde que la @@ -279,7 +309,11 @@ class LesDeuxCheminsLisentLaMemeSomme(unittest.TestCase): cmd = pve.image_fetch_cmd( "http://x/i.qcow2", "i.qcow2", sha256="b" * 64 ) - self.assertLess(cmd.index("fi"), cmd.index("sha256sum")) + # La DERNIÈRE vérification, celle qui porte sur le fichier en + # place : la première garde le téléchargement frais avant de + # l'installer, et se trouve donc AVANT le « fi ». + self.assertLess(cmd.index("fi"), cmd.rindex("sha256sum")) + self.assertEqual(2, cmd.count("sha256sum")) def test_nothing_is_appended_without_a_sum(self): from script.proxmox import proxmox_deploy as pve diff --git a/test/test_qemu_seed_locale_keyboard.py b/test/test_qemu_seed_locale_keyboard.py index e5f901d..63da544 100644 --- a/test/test_qemu_seed_locale_keyboard.py +++ b/test/test_qemu_seed_locale_keyboard.py @@ -22,7 +22,10 @@ configuration ENTIÈRE, sans message : la VM démarre sans compte ni clé. """ import importlib.util +import os +import shutil import sys +import tempfile import unittest from pathlib import Path @@ -47,10 +50,11 @@ def _deploy_qemu(): DQ = _deploy_qemu() -def _cloud_config(distro): - args = DQ.build_parser().parse_args( - ["--distro", distro, "--hostname", "x"] - ) +def _cloud_config(distro, cache_ca=""): + argv = ["--distro", distro, "--hostname", "x"] + if cache_ca: + argv += ["--cache-ca", cache_ca] + args = DQ.build_parser().parse_args(argv) return DQ.build_cloud_config(args, None, ["ssh-ed25519 AAAA essai"]) @@ -149,5 +153,85 @@ class LeSeedEntier(unittest.TestCase): self.assertNotIn("/etc/default/keyboard", chemins) +class LAutoriteDuCacheDansLeSeedAssemble(unittest.TestCase): + """Le clavier et l'autorité du cache arrivent par la MÊME clé. + + Trois écritures se disputent « write_files: » — le guide, l'autorité du + cache, le clavier — et cloud-init n'en lit qu'UNE : un document qui porte + la clé deux fois garde la dernière, sans erreur ni message. Ce qui est + perdu ne se voit alors qu'à l'usage, chez l'invité, et seulement pour ce + qui en dépendait. + + Les tests plus haut regardent les fonctions une à une ; ceux-ci regardent + le document ASSEMBLÉ, seul endroit où cette collision existe. + """ + + def setUp(self): + if yaml is None: + self.skipTest("PyYAML absent") + dossier = tempfile.mkdtemp(prefix="cache-ca-") + self.addCleanup(shutil.rmtree, dossier, ignore_errors=True) + # Une autorité INVENTÉE : cache_files exige « BEGIN CERTIFICATE » et + # ne lit rien d'autre du fichier. Reprendre celle d'un hôte réel + # figerait dans le dépôt le certificat d'une machine. + self.ca = os.path.join(dossier, "ca.crt") + with open(self.ca, "w", encoding="utf-8") as fh: + fh.write( + "-----BEGIN CERTIFICATE-----\nZXNzYWk=\n" + "-----END CERTIFICATE-----\n" + ) + + def _chemins(self, distro): + doc = yaml.safe_load(_cloud_config(distro, self.ca)) + return [f["path"] for f in doc.get("write_files", [])] + + def test_the_key_appears_exactly_once(self): + """Deux « write_files: » dans le même document : PyYAML garde le + second et jette le premier, sans rien dire.""" + for distro in ("debian", "ubuntu", "fedora", "arch", "nixos"): + with self.subTest(distro=distro): + texte = _cloud_config(distro, self.ca) + lignes = texte.splitlines() + self.assertEqual( + 1, lignes.count("write_files:"), "\n".join(lignes[:5]) + ) + + def test_the_authority_reaches_the_assembled_document(self): + """Ce qu'aucun test ne gardait : l'autorité peut disparaître du + document tout en restant correcte dans cache_files.""" + for distro in ("debian", "ubuntu", "fedora", "arch", "opensuse"): + with self.subTest(distro=distro): + chemins = self._chemins(distro) + self.assertTrue( + any(c.endswith(DQ.CACHE_CERT_NAME) for c in chemins), + chemins, + ) + + def test_debian_keeps_both_the_keyboard_and_the_authority(self): + """La seule distribution qui demande les deux par write_files : si + une écriture en écrase une autre, c'est ici que cela se voit.""" + chemins = self._chemins("debian") + self.assertIn("/etc/default/keyboard", chemins) + self.assertTrue(any(c.endswith(DQ.CACHE_CERT_NAME) for c in chemins)) + + def test_the_trust_command_reaches_runcmd(self): + """Le fichier posé sans la commande qui relit le magasin ne sert à + rien : les deux moitiés voyagent séparément.""" + doc = yaml.safe_load(_cloud_config("debian", self.ca)) + self.assertTrue( + any("update-ca-certificates" in str(c) for c in doc["runcmd"]), + doc["runcmd"], + ) + + def test_a_system_with_no_anchor_gets_nothing_and_keeps_the_rest(self): + """Un système déclaratif n'a pas d'ancre où écrire. Il ne reçoit donc + pas l'autorité — et le reste du document lui parvient quand même.""" + doc = yaml.safe_load(_cloud_config("nixos", self.ca)) + chemins = [f["path"] for f in doc.get("write_files", [])] + self.assertFalse([c for c in chemins if DQ.CACHE_CERT_NAME in c]) + self.assertIn("users", doc) + self.assertEqual("ca", doc["keyboard"]["layout"]) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index b80adc4..7cbc0f3 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -1176,20 +1176,19 @@ class TestLeMenuDesDeuxTests(unittest.TestCase): src.index("--detruire --dry-run"), src.index('"--detruire"') ) - def test_everything_the_lock_knows_can_be_undone(self): + def test_the_two_copies_of_the_list_agree(self): """Le verrou (long_test/descente.py) et le défaire (le menu) portent chacun la liste des tests longs, faute de pouvoir la partager : le menu lance ces scripts en sous-processus et n'importe jamais long_test/, qui traîne avec lui le module Proxmox. - L'inclusion, et non l'égalité : un test long que le verrou connaît - crée des machines, donc il DOIT être défaisable. L'inverse n'est pas - vrai — le menu défait aussi le test du cache, que le verrou ne - surveille pas. Exiger l'égalité ferait échouer ce test sur un écart - qui est un choix, pas une dérive.""" + L'égalité, dans les deux sens, et chacun de ses sens couvre une + panne : un test long absent du VERROU laisse une autre descente + détruire ses machines pendant qu'il tourne ; absent du DÉFAIRE, il + laisse ses VM derrière lui, sans rien pour les reprendre.""" from script.todo.longtest_menu import SCRIPTS_DEFAISABLES - self.assertTrue(set(moteur.SCRIPTS) <= set(SCRIPTS_DEFAISABLES)) + self.assertEqual(set(moteur.SCRIPTS), set(SCRIPTS_DEFAISABLES)) self.assertIn("install_nixos.py", moteur.SCRIPTS) def test_the_host_options_are_built_from_the_host_dict(self): @@ -1767,5 +1766,50 @@ class TestLeMenu(unittest.TestCase): self.assertIn("Long tests", src) +class LeCacheDeLEtage1(unittest.TestCase): + """L'étage 1 est une VM du pont libvirt local, et le cache détourne tout + ce pont. Ne rien lui passer ne la laisse pas en direct : elle est + interceptée sans autorité, et chaque téléchargement HTTPS de l'étage — + l'image, puis le gestionnaire de paquets — échoue sur « self-signed + certificate in certificate chain ».""" + + def setUp(self): + self.dossier = tempfile.mkdtemp(prefix="descente-ca-") + self.addCleanup(shutil.rmtree, self.dossier, ignore_errors=True) + self.addCleanup(setattr, moteur, "CACHE_CA", moteur.CACHE_CA) + + def test_the_authority_is_trusted_when_the_host_has_one(self): + ca = os.path.join(self.dossier, "ca.crt") + with open(ca, "w", encoding="utf-8") as fh: + fh.write("-----BEGIN CERTIFICATE-----\n") + moteur.CACHE_CA = ca + self.assertEqual(["--cache-ca", ca], moteur.drapeaux_cache()) + + def test_a_host_without_one_asks_for_an_exception(self): + """L'exception par MAC, et non le silence : sur un hôte sans cache + elle ne fait rien et le dit, ce qui ne coûte qu'une ligne ; sur un + hôte qui en porte un, le silence coûte l'étage.""" + moteur.CACHE_CA = os.path.join(self.dossier, "absent.crt") + self.assertEqual(["--cache-bypass"], moteur.drapeaux_cache()) + + def test_the_first_floor_passes_them(self): + """Les deux piles héritent de creer_etage1 : le drapeau posé là les + couvre toutes les deux.""" + import inspect + + src = inspect.getsource(moteur.Descente.creer_etage1) + self.assertIn("argv += drapeaux_cache()", src) + + def test_the_long_tests_share_one_authority(self): + """Une seconde définition du chemin dériverait en silence, et poser + la mauvaise autorité échoue comme n'en poser aucune.""" + import sys as _sys + + _sys.path.insert(0, os.path.join(RACINE, "long_test")) + import qemu_cache + + self.assertEqual(moteur.CACHE_CA, qemu_cache.CA) + + if __name__ == "__main__": unittest.main(verbosity=2)