erplibre/test/test_qemu_cache_offline.py
Mathieu Benoit a7706adf3d [ADD] cache qemu : miroir des téléchargements des VM, hors ligne compris
VMs of one host pulled the same packages again and again, and an install
could not be proven to work without the network. A Go service intercepts the
bridge transparently: a package file is served from disk, an index is always
revalidated upstream and replayed only when upstream is mute, git is mirrored.
Deployment poses the cache authority in QEMU and Proxmox VE guests, and can cut
the network for a whole install. The TODO menu installs, diagnoses, fills,
cleans and copies the store; long_test/qemu_cache.py measures that a second VM
pulls no package upstream. Covered by Go tests and test/test_qemu_cache_*.py.

--- FR ---

Les VM d'un hôte tiraient sans cesse les mêmes paquets, et rien ne prouvait
qu'une installation marche sans réseau. Un service Go intercepte le pont de
façon transparente : un fichier de paquet est servi du disque, un index est
toujours revalidé à l'amont et rejoué seulement quand l'amont est muet, git
est mis en miroir. Le déploiement pose l'autorité du cache dans les invités
QEMU et Proxmox VE, et peut couper le réseau pour toute une installation. Le
menu TODO installe, diagnostique, remplit, nettoie et emporte le magasin ;
long_test/qemu_cache.py mesure qu'une seconde VM ne tire aucun paquet de
l'amont. Couvert par les tests Go et test/test_qemu_cache_*.py.

Assisted-by: Claude Opus 5
2026-09-14 16:46:15 -04:00

1950 lines
73 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Déployer avec l'amont du cache coupé.
Trois sorties tombent : l'amont du service du cache, la sortie directe des
VM que l'hôte relaie, et la résolution des noms par l'internet — l'hôte
répond lui-même à tout nom une adresse que le cache intercepte. Tout ce qui
arrive encore dans une VM vient donc du disque du cache, et un pas qui
prendrait un autre chemin échoue.
Les règles sont VÉRIFIÉES au caractère près et jamais appliquées : la machine
qui exécute les tests garde son pare-feu intact.
"""
import os
import re
import subprocess
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 cache_offline # noqa: E402
# Gardée avant que setUpModule ne la remplace pour tout le module.
_VRAI_DNSMASQ = cache_offline.dnsmasq
class TestLeTemoinDesAmontsMuets(unittest.TestCase):
"""La levée d'une coupure doit rendre leur chance aux amonts.
Le service retient les amonts dont l'établissement vient d'échouer, pour
ne pas repayer le délai à chaque requête d'une installation hors ligne.
Rien ne le prévient qu'une coupure est levée : sans témoin, ces amonts
restent muets jusqu'à la fin de leur fenêtre, et les premières requêtes
d'après la levée se rabattent sur le magasin alors que le réseau est
revenu. Aucun canal n'existe vers le service en marche.
"""
def test_le_nom_du_temoin_est_le_meme_des_deux_cotes(self):
"""Deux noms qui divergent font toucher un fichier que personne ne
lit : la levée paraît faite, et rien ne change."""
src = (RACINE / "script" / "qemu_cache" / "joignable.go").read_text(
encoding="utf-8"
)
trouve = re.search(r'SentinelleAmonts = "([^"]+)"', src)
self.assertIsNotNone(trouve, "le Go ne déclare plus le témoin")
self.assertEqual(trouve.group(1), cache_offline.SENTINELLE_AMONTS)
def test_la_levee_touche_le_temoin(self):
cmd = cache_offline.restore_cmd()
self.assertIn("touch ", cmd)
self.assertIn(cache_offline.SENTINELLE_AMONTS, cmd)
def test_le_temoin_vit_sous_le_magasin_regle(self):
"""Un magasin déplacé emporte son témoin : sinon la levée écrirait
hors du répertoire que le service lit."""
self.assertEqual(
cache_offline.sentinelle_amonts("/ailleurs/magasin"),
os.path.join("/ailleurs/magasin", cache_offline.SENTINELLE_AMONTS),
)
class TestLesRegles(unittest.TestCase):
def test_la_coupure_vise_le_compte_et_non_le_port(self):
"""Une règle générale sur le 443 de l'orchestrateur emporterait la
session ssh depuis laquelle le déploiement est lancé."""
regles = cache_offline.nft_rules()
self.assertIn(f"meta skuid {cache_offline.SERVICE_USER}", regles)
self.assertIn("hook output", regles)
self.assertNotIn("hook input", regles)
def test_les_deux_ports_tombent(self):
"""Ne couper que le 443 laisserait passer tout un miroir en clair."""
self.assertIn("tcp dport { 80, 443 }", cache_offline.nft_rules())
def test_la_politique_reste_permissive(self):
"""« policy accept » : seule la ligne ciblée jette. Une politique
« drop » couperait tout ce que l'hôte émet."""
self.assertIn("policy accept", cache_offline.nft_rules())
def test_la_table_est_distincte_de_celle_du_detournement(self):
"""Les mêler ferait tomber la redirection de tout le pont en
rebranchant l'amont."""
self.assertNotEqual(cache_offline.TABLE, "erplibre_qemu_cache")
def test_la_sortie_directe_des_vm_tombe(self):
"""Ping, autres ports, UDP, IPv6 : tout ce que le pont relaie vers
l'extérieur. Sans cette chaîne, un pas qui contourne le cache réussit
en ligne pendant une coupure annoncée, et le test ment."""
regles = cache_offline.nft_rules(pont="virbr9")
self.assertIn("hook forward", regles)
self.assertIn('iifname "virbr9" oifname != "virbr9" reject', regles)
def test_la_coupure_refuse_au_lieu_de_jeter(self):
"""Un paquet jeté fait pendre l'établissement jusqu'au délai du
cache, à chaque adresse qu'il ne détient pas : des centaines pendant
un « apt-get update ». Refusé, il échoue sur-le-champ."""
regles = cache_offline.nft_rules()
self.assertIn("tcp dport { 80, 443 } reject with tcp reset", regles)
self.assertNotIn(" drop\n", regles)
def test_ce_qui_vise_lhote_reste_joignable(self):
"""Le détournement vers le cache se fait avant le routage, le
résolveur et la session ssh passent par « input » : aucune de ces
routes ne doit être visée, sans quoi la VM ne joindrait plus le cache
et échouerait pour une raison qui n'est pas le hors-ligne."""
regles = cache_offline.nft_rules()
for accroche in ("hook input", "hook postrouting"):
self.assertNotIn(accroche, regles)
# En tête de routage, cette table ne détourne que le port 53 : le 80
# et le 443 restent à la table du cache, qui les mène au cache.
self.assertNotIn("dport 80", regles)
self.assertNotIn("dport 443 redirect", regles)
# Le trafic entre VM du même pont n'est pas relayé vers l'extérieur.
self.assertIn('oifname != "', regles)
def test_le_dns_des_vm_va_au_resolveur_fictif(self):
"""UDP et TCP : une réponse DNS trop grande repasse en TCP, et une
requête qui y échapperait joindrait l'internet par le résolveur de
libvirt."""
regles = cache_offline.nft_rules(pont="virbr9")
self.assertIn("hook prerouting", regles)
for proto in ("udp", "tcp"):
self.assertIn(
f'iifname "virbr9" {proto} dport 53 redirect to'
f" :{cache_offline.PORT_DNS}",
regles,
)
def test_le_pont_vient_des_reglages_du_service(self):
with mock.patch.object(
cache_offline, "reglage", lambda nom, conf="": "virbr7"
):
self.assertIn('iifname "virbr7"', cache_offline.cut_cmd())
with mock.patch.object(
cache_offline, "reglage", lambda nom, conf="": ""
):
self.assertIn(
f'iifname "{cache_offline.PONT_PAR_DEFAUT}"',
cache_offline.cut_cmd(),
)
def test_un_nom_de_pont_douteux_est_refuse(self):
"""Il entre tel quel dans les règles : un guillemet casserait le jeu,
un nom faux donnerait une règle qui ne vise rien."""
for douteux in ('vir"br0', "virbr0 drop", "", "x" * 16):
with self.assertRaises(ValueError, msg=douteux):
cache_offline.nft_rules(pont=douteux)
def test_le_retrait_est_muet_sur_une_table_absente(self):
"""Il se fait dans un « finally » : une erreur y masquerait celle
d'origine."""
self.assertIn("|| true", cache_offline.restore_cmd())
self.assertIn(cache_offline.TABLE, cache_offline.restore_cmd())
class TestLeResolveurFictif(unittest.TestCase):
"""Pendant la coupure, l'hôte répond LUI-MÊME à tout nom : sans
résolution, la VM ne se connecterait à rien et le cache ne verrait jamais
la requête ; avec la vraie, les noms sortiraient par l'internet."""
def test_un_dnsmasq_sans_amont_qui_repond_tout_nom(self):
cmd = cache_offline.dns_cmd(pont="virbr9", binaire="/usr/bin/dnsmasq")
for attendu in (
f"--unit={cache_offline.UNITE_DNS}",
"--collect",
"RuntimeMaxSec=",
"--conf-file=/dev/null",
"--no-resolv",
"--no-hosts",
"--interface=virbr9",
"--except-interface=lo",
"--bind-interfaces",
f"--port={cache_offline.PORT_DNS}",
f"--address=/#/{cache_offline.ADRESSE_FICTIVE_V4}",
f"--address=/#/{cache_offline.ADRESSE_FICTIVE_V6}",
"--local-ttl=0",
):
self.assertIn(attendu, cmd)
def test_sans_dnsmasq_la_coupure_est_refusee(self):
"""Une coupure qui laisserait les noms sortir mentirait sur ce
qu'elle prouve."""
with mock.patch.object(cache_offline, "dnsmasq", lambda: ""):
self.assertEqual(cache_offline.dns_cmd(pont="virbr9"), "")
cmd = cache_offline.cut_cmd(pont="virbr9")
self.assertNotEqual(subprocess.run(["sh", "-c", cmd]).returncode, 0)
def test_dnsmasq_se_cherche_dans_le_path(self):
"""Le module le remplace pour tous les tests : c'est la VRAIE
fonction qu'on éprouve ici."""
with mock.patch.object(cache_offline.shutil, "which", lambda n: None):
self.assertEqual(_VRAI_DNSMASQ(), "")
with mock.patch.object(
cache_offline.shutil, "which", lambda n: f"/opt/bin/{n}"
):
self.assertEqual(_VRAI_DNSMASQ(), "/opt/bin/dnsmasq")
def test_le_retrait_arrete_aussi_le_resolveur(self):
for cmd in (cache_offline._retrait(), cache_offline.restore_cmd()):
self.assertIn(f"systemctl stop {cache_offline.UNITE_DNS}", cmd)
self.assertIn(f"nft delete table inet {cache_offline.TABLE}", cmd)
self.assertTrue(cache_offline.restore_cmd().startswith("sudo sh -c "))
# Le guet lève par le même retrait : il arrête donc le résolveur.
guet = cache_offline.guet_cmd(["/srv/run/vm.log"], "__FIN__")
self.assertIn(cache_offline.UNITE_DNS, guet)
def _poser(self, systemd_run_rc, nft_refuse_le_refus=False):
"""Exécute la VRAIE commande de pose avec de faux sudo, nft,
systemd-run et systemctl EN TÊTE du PATH : aucun vrai outil n'est
atteint, et le journal dit qui a été appelé, dans quel ordre."""
import tempfile
d = tempfile.mkdtemp()
self.addCleanup(lambda: __import__("shutil").rmtree(d, True))
journal = os.path.join(d, "appels")
faux = {
"sudo": 'exec "$@"',
# Le journal dit quel jeu la pose a reçu : « refus » ou « jet ».
"nft": (
'case "$1" in -f) jeu=$(cat);; esac; '
'case "$jeu" in *reject*) v=refus;; *drop*) v=jet;; esac; '
f'echo "nft $* $v" >> {journal}; '
f'[ "$v" = refus ] && exit {1 if nft_refuse_le_refus else 0}; '
"exit 0"
),
"systemctl": f'echo "systemctl $*" >> {journal}',
"systemd-run": f'echo "systemd-run" >> {journal}; exit {systemd_run_rc}',
}
for nom, corps in faux.items():
chemin = os.path.join(d, nom)
with open(chemin, "w", encoding="utf-8") as fh:
fh.write(f"#!/bin/sh\n{corps}\n")
os.chmod(chemin, 0o755)
dns = cache_offline.dns_cmd(
pont="virbr9", binaire="/inexistant/dnsmasq"
)
cmd = cache_offline.cut_cmd(pont="virbr9", dns=dns)
env = {"PATH": f"{d}:/usr/bin:/bin"}
rc = subprocess.run(["/bin/sh", "-c", cmd], env=env).returncode
with open(journal, encoding="utf-8") as fh:
return rc, fh.read().splitlines()
def test_un_resolveur_qui_ne_part_pas_retire_la_table(self):
rc, appels = self._poser(systemd_run_rc=1)
self.assertNotEqual(rc, 0, "la pose a réussi sans résolveur")
self.assertEqual(appels[0], "nft -f - refus")
self.assertEqual(appels[1], "systemd-run")
self.assertIn(
f"nft delete table inet {cache_offline.TABLE}", appels[2]
)
self.assertIn(f"systemctl stop {cache_offline.UNITE_DNS}", appels[3])
def test_une_pose_complete_ne_retire_rien(self):
rc, appels = self._poser(systemd_run_rc=0)
self.assertEqual(rc, 0)
self.assertEqual(appels, ["nft -f - refus", "systemd-run"])
def test_un_noyau_sans_module_de_refus_recoit_le_jeu_qui_jette(self):
"""Un noyau mis à jour sans redémarrage ne charge plus le module du
refus : la pose retombe sur le jeu qui jette, sans demi-coupure."""
rc, appels = self._poser(systemd_run_rc=0, nft_refuse_le_refus=True)
self.assertEqual(rc, 0)
self.assertEqual(
appels, ["nft -f - refus", "nft -f - jet", "systemd-run"]
)
def test_le_repli_jette_partout(self):
repli = cache_offline.nft_rules(refus=False)
self.assertNotIn("reject", repli)
self.assertIn("tcp dport { 80, 443 } drop", repli)
self.assertIn('oifname != "virbr0" drop', repli)
class TestLeCompteDuService(unittest.TestCase):
"""Le script d'installation est en shell et ne peut pas lire la valeur
d'ici : les deux copies doivent dire la même chose, sans quoi la coupure
viserait un compte qui n'émet rien et le test passerait pour hors ligne
en étant en ligne."""
def test_le_meme_compte_que_le_script_dinstallation(self):
src = (
RACINE / "script" / "install" / "install_qemu_cache.sh"
).read_text(encoding="utf-8")
trouve = re.search(r'^SERVICE_USER="([^"]+)"', src, re.M)
self.assertIsNotNone(trouve, "SERVICE_USER absent du script")
self.assertEqual(trouve.group(1), cache_offline.SERVICE_USER)
class TestLeTestLongEtLeFormulaireCoupentPareil(unittest.TestCase):
"""Une seule source : ce que la case « Sans connexion internet » fait est
exactement ce que la contre-épreuve du test long mesure."""
def test_le_test_long_ne_recopie_pas_les_regles(self):
src = (RACINE / "long_test" / "qemu_cache.py").read_text(
encoding="utf-8"
)
self.assertIn("cache_offline.cut_cmd()", src)
self.assertNotIn("meta skuid", src)
class TestLeCablageDuDeploiement(unittest.TestCase):
"""La coupure couvre la spec ENTIÈRE, installation comprise, et se retire
quoi qu'il arrive."""
def _lance(self, offline, echec_coupure=False, plante=False):
sys.argv = ["todo.py"]
from script.todo.qemu_deploy import _SansInternetImpossible # noqa
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
vu = {"shell": [], "ordre": []}
def shell(cmd, timeout=60):
vu["shell"].append(cmd)
# Les relevés ne comptent pas dans l'ordre : aucun guet ne
# tourne, aucune table n'est posée avant ni après.
if "systemctl is-active" in cmd or "nft list tables" in cmd:
return 1
if "nft -f -" in cmd:
vu["ordre"].append("coupure")
return 1 if echec_coupure else 0
vu["ordre"].append("rebranchement")
return 0
todo._qemu_shell = shell
def deploie(spec, **kw):
vu["ordre"].append("déploiement")
if plante:
raise RuntimeError("le déploiement a échoué")
return "fait"
todo._qemu_deploie_spec = deploie
import contextlib
import io
with contextlib.redirect_stdout(io.StringIO()) as sortie:
if plante:
with self.assertRaises(RuntimeError):
todo._qemu_run_spec({"offline": offline})
else:
vu["rendu"] = todo._qemu_run_spec({"offline": offline})
vu["ecrit"] = sortie.getvalue()
return vu
def test_sans_la_case_rien_nest_touche(self):
"""Seul l'état du guet est relu, sans sudo : une coupure tenue par un
autre déploiement ferait tourner celui-ci hors ligne."""
vu = self._lance(offline=False)
self.assertEqual(vu["ordre"], ["déploiement"])
self.assertEqual(vu["shell"], [cache_offline.guet_actif_cmd()])
def test_la_coupure_precede_le_deploiement(self):
"""Après lui, elle ne mesurerait plus rien : c'est l'installation qui
télécharge."""
vu = self._lance(offline=True)
self.assertEqual(
vu["ordre"], ["coupure", "déploiement", "rebranchement"]
)
def test_le_rebranchement_a_lieu_meme_si_le_deploiement_plante(self):
"""Une coupure laissée en place prive le cache de réseau bien après,
et la panne se découvre ailleurs."""
vu = self._lance(offline=True, plante=True)
self.assertEqual(
vu["ordre"], ["coupure", "déploiement", "rebranchement"]
)
def test_une_coupure_impossible_ne_deploie_rien(self):
"""Une VM bâtie avec l'amont debout se bâtit toujours : son succès se
lirait comme une preuve hors ligne qu'elle n'est pas."""
vu = self._lance(offline=True, echec_coupure=True)
self.assertEqual(vu["ordre"], ["coupure"])
self.assertIn("✗", vu["ecrit"])
def test_la_spec_porte_bien_la_cle(self):
from script.todo.deploy_form_lib import build_spec
spec = build_spec(
[],
set(),
{
"res_label": "",
"ssh_key": "",
"install": None,
"add_ssh_config": True,
"parallelism": 1,
"offline": True,
},
)
self.assertIs(spec["offline"], True)
class TestLeDiagnosticVoitLaCoupure(unittest.TestCase):
"""Une coupure restée en place est muette là où on la cherche.
Le rebranchement est dans un « finally », mais un « finally » ne court
pas sur un processus tué net. Le cache rend alors « 504 » à chaque VM et
l'installation échoue sur « failed retrieving file … 504 » depuis TOUS
les miroirs — un message qui accuse les miroirs, jamais une règle de
pare-feu posée sur l'hôte.
Le journal du cache écrivait bien « offline-miss », et le diagnostic le
montrait ; encore fallait-il savoir le lire. Il le dit maintenant.
"""
TABLE_POSEE = (
f"table inet {cache_offline.TABLE} {{\n"
" chain sortie {\n"
" type filter hook output priority filter; policy accept;\n"
" meta skuid 959 tcp dport { 80, 443 } drop\n"
" }\n"
"}\n"
)
TABLE_ABSENTE = "Error: No such file or directory"
def _menu(self, listing):
sys.argv = ["todo.py"]
from script.todo.qemu_cache_menu import QemuCacheMenuMixin
class Faux(QemuCacheMenuMixin):
@staticmethod
def _cache_lire(cmd, delai=15):
return listing if cache_offline.TABLE in cmd else ""
return Faux
def test_elle_est_vue_quand_elle_est_posee(self):
self.assertTrue(self._menu(self.TABLE_POSEE)._cache_amont_coupe())
def test_rien_nest_annonce_quand_elle_ne_lest_pas(self):
self.assertFalse(self._menu(self.TABLE_ABSENTE)._cache_amont_coupe())
def test_une_lecture_impossible_ne_crie_pas_au_loup(self):
"""Sans sudo, « nft list » ne rend rien : annoncer une coupure
enverrait chercher une règle qui n'existe pas."""
self.assertFalse(self._menu("")._cache_amont_coupe())
def test_le_diagnostic_la_nomme_et_donne_le_geste(self):
import contextlib
import io as _io
menu = self._menu(self.TABLE_POSEE)
faux = menu.__new__(menu)
faux._cache_actif = lambda: True
with mock.patch("os.path.isfile", return_value=True), 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(
_io.StringIO()
) as sortie:
faux._cache_diagnostic()
ecrit = sortie.getvalue()
self.assertIn(
"504", ecrit, "le symptôme n'est pas rattaché à sa cause"
)
self.assertIn(
cache_offline.restore_cmd(),
ecrit,
"le diagnostic constate sans donner le geste qui lève la coupure",
)
class TestCeQueLeCacheDetient(unittest.TestCase):
"""Savoir AVANT de couper si une VM hors ligne a une chance.
Une VM déployée hors ligne sur une suite que le cache n'a jamais servie
échoue une heure plus tard, sur « Impossible de trouver le paquet » — un
message qui ne parle ni du cache ni du hors ligne.
"""
def _journal(self, lignes):
import json
import tempfile
fh = tempfile.NamedTemporaryFile(
"w", suffix=".jsonl", delete=False, encoding="utf-8"
)
for l in lignes:
fh.write(json.dumps(l) + "\n")
fh.close()
return fh.name
def test_le_nom_de_code_vient_du_catalogue(self):
"""Le recopier ici le ferait dériver du déploiement, qui l'a déjà."""
self.assertEqual(
cache_offline.jeton_de_suite("ubuntu", "26.04"),
"/dists/resolute/",
)
self.assertEqual(
cache_offline.jeton_de_suite("debian", "13"), "/dists/trixie/"
)
def test_aucun_verdict_hors_des_familles_apt(self):
"""« /repodata/ » ne dit pas quelle VERSION il sert : un cache rempli
pour l'une passerait pour rempli pour toutes."""
for d, v in (("arch", "latest"), ("fedora", "42"), ("rocky", "9")):
self.assertEqual(cache_offline.jeton_de_suite(d, v), "", d)
def test_une_suite_servie_est_vue_en_reserve(self):
chemin = self._journal(
[
{
"url": "http://m/ubuntu/dists/resolute/InRelease",
"outcome": "stored",
}
]
)
self.assertTrue(
cache_offline.detient_la_suite("ubuntu", "26.04", chemin)
)
def test_une_suite_jamais_servie_est_signalee(self):
chemin = self._journal(
[
{
"url": "http://m/ubuntu/dists/noble/InRelease",
"outcome": "stored",
}
]
)
self.assertFalse(
cache_offline.detient_la_suite("ubuntu", "26.04", chemin)
)
def test_un_304_ne_compte_pas_pour_une_reserve(self):
"""« fetched » couvre aussi la revalidation, qui n'a pas de corps :
c'est exactement le cas qui laissait le cache vide."""
chemin = self._journal(
[
{
"url": "http://m/ubuntu/dists/resolute/InRelease",
"outcome": "fetched",
"status": 304,
}
]
)
self.assertFalse(
cache_offline.detient_la_suite("ubuntu", "26.04", chemin)
)
def test_un_journal_illisible_ne_crie_pas_au_loup(self):
"""Un avertissement qui se déclenche sans savoir apprend à passer
outre, et c'est alors celui qui compte qu'on ne lit plus."""
self.assertTrue(
cache_offline.detient_la_suite("ubuntu", "26.04", "/pas/la.jsonl")
)
def test_les_manquantes_sont_nommees_sans_doublon(self):
chemin = self._journal(
[
{
"url": "http://m/ubuntu/dists/noble/InRelease",
"outcome": "hit",
}
]
)
with mock.patch.object(
cache_offline,
"detient_la_suite",
lambda d, v, c="": (d, v) == ("ubuntu", "24.04"),
):
manquantes = cache_offline.suites_absentes(
[
{"distro": "ubuntu", "version": "24.04"},
{"distro": "ubuntu", "version": "26.04"},
{"distro": "ubuntu", "version": "26.04"},
{"distro": "", "version": "x"},
]
)
self.assertEqual(manquantes, [("ubuntu", "26.04")])
self.assertEqual(chemin, chemin)
class _SansSysteme:
"""Garde de CLASSE : aucune commande ne part vers l'hôte.
Le code sous test pose et lève des règles de pare-feu et des unités
systemd. Une régression qui contournerait un faux `_qemu_shell` les
poserait pour de bon sur la machine de test ; l'espion les intercepte
toutes et rend un échec.
"""
def setUp(self):
self.lancees = []
def espion(cmd, *a, **kw):
texte = cmd if isinstance(cmd, str) else " ".join(map(str, cmd))
self.lancees.append(texte)
return subprocess.CompletedProcess(cmd, 1, "", "")
for cible in ("subprocess.run", "subprocess.Popen"):
patch = mock.patch(cible, espion)
patch.start()
self.addCleanup(patch.stop)
class _FauxHote:
"""L'hôte vu par `_qemu_shell` : la table et le guet sont deux booléens.
Chaque commande rendue par cache_offline y est reconnue à sa forme, et
son effet simulé ; rien n'est exécuté.
"""
def __init__(self, table=False, guet=False, retrait=True, lecture=True):
self.table, self.guet = table, guet
self.retrait, self.lecture = retrait, lecture
self.ordre = []
def shell(self, cmd, timeout=60):
if "systemctl is-active" in cmd:
return 0 if self.guet else 1
if "nft list tables" in cmd:
if not self.lecture:
return 2
return 0 if self.table else 1
# La pose passe AVANT le guet : elle lance aussi une unité (le
# résolveur fictif), mais c'est la table qu'elle pose.
if "nft -f -" in cmd:
self.ordre.append("coupure")
self.table = True
return 0
if "systemd-run" in cmd:
self.ordre.append("guet")
self.guet = True
return 0
if "nft delete" in cmd:
self.ordre.append("rebranchement")
if self.retrait:
self.table = False
return 0
self.ordre.append(f"? {cmd}")
return 0
def _todo():
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return TODO.__new__(TODO)
def _joue_la_spec(
hote, guet_pendant=False, reponse=None, hors_ligne=True, pendant=None
):
"""`_qemu_run_spec`, le déploiement remplacé par un faux.
`guet_pendant` : le faux déploiement pose le guet, comme la voie suivie
le fait après le lancement. `reponse` : ce que l'utilisateur tape si on
lui pose une question ; None la rend interdite. `hors_ligne` : la case
de la spec. `pendant(vu)` : appelé DANS le bloc, par le faux
déploiement. Chaque commande passée à `_qemu_shell` est ajoutée à
vu["lances"] ; le code d'un SystemExit sorti du bloc va dans
vu["sortie"].
"""
import contextlib
import io
todo = _todo()
lances = []
def shell(cmd, timeout=60):
lances.append(cmd)
return hote.shell(cmd, timeout)
todo._qemu_shell = shell
vu = {"lances": lances, "todo": todo}
def deploie(spec, **kw):
hote.ordre.append("déploiement")
vu["kw"] = kw
if guet_pendant:
hote.shell(cache_offline.guet_cmd(["/srv/essai/vm-a.log"], "X"))
if pendant:
pendant(vu)
todo._qemu_deploie_spec = deploie
question = (
mock.patch("builtins.input", return_value=reponse)
if reponse is not None
else mock.patch(
"builtins.input",
side_effect=AssertionError("question posée sans motif"),
)
)
try:
with question, contextlib.redirect_stdout(io.StringIO()) as sortie:
todo._qemu_run_spec({"offline": hors_ligne})
except SystemExit as fin:
vu["sortie"] = fin.code
vu["ecrit"] = sortie.getvalue()
return vu
class TestLeGuetRendu(unittest.TestCase):
"""La levée confiée à root est une commande RENDUE : lue ici au
caractère près, jamais lancée."""
JOURNAUX = [
"/srv/essai/journaux d'installation/vm-a.log",
'/srv/essai/un "autre" dossier/vm b.log',
]
MARQUEUR = "__FIN_ESSAI__"
def _argv(self):
import shlex
return shlex.split(
cache_offline.guet_cmd(self.JOURNAUX, self.MARQUEUR)
)
def _attente(self, argv):
"""Le script d'attente : l'argument qui suit « /bin/sh -c »."""
debut = argv.index("/bin/sh")
self.assertEqual(argv[debut + 1], "-c")
return debut + 2
def test_une_unite_nommee_posee_par_root(self):
argv = self._argv()
self.assertEqual(argv[:2], ["sudo", "systemd-run"])
self.assertIn(f"--unit={cache_offline.UNITE_GUET}", argv)
self.assertIn(
"--collect", argv, "une unité en échec garderait son nom pris"
)
def test_elle_ne_vit_pas_plus_de_douze_heures(self):
"""Un marqueur qui ne vient jamais ne prive pas le cache d'amont
indéfiniment."""
argv = self._argv()
self.assertEqual(cache_offline.DUREE_MAX_GUET, 12 * 3600)
rang = argv.index("RuntimeMaxSec=43200")
self.assertEqual(argv[rang - 1], "-p")
def test_sa_fin_leve_la_table_de_la_coupure(self):
"""ExecStopPost court à la fin, au dépassement et à l'arrêt."""
argv = self._argv()
post = [a for a in argv if a.startswith("ExecStopPost=")]
self.assertEqual(len(post), 1)
self.assertEqual(argv[argv.index(post[0]) - 1], "-p")
self.assertIn(f"nft delete table inet {cache_offline.TABLE} ", post[0])
def test_lattente_couvre_chaque_journal_tel_quel(self):
"""Espaces, apostrophes et guillemets : les chemins voyagent en
arguments et arrivent intacts, un par VM."""
argv = self._argv()
rang = self._attente(argv)
self.assertEqual(argv[rang + 1], "sh")
self.assertEqual(argv[rang + 2 :], self.JOURNAUX)
script = argv[rang]
self.assertIn('for f in "$@"', script)
self.assertIn(self.MARQUEUR, script)
self.assertNotIn("vm-a.log", script, "un chemin est dans le script")
def test_rien_que_systemd_remplacerait(self):
"""systemd remplace « ${VAR} » et « $$ » dans ce qu'il exécute, et
« % » dans ce qu'il lit d'une unité : le script tomberait en
morceaux sans que rien ne le dise."""
argv = self._argv()
script = argv[self._attente(argv)]
post = next(a for a in argv if a.startswith("ExecStopPost="))
for texte in (script, post):
for motif in ("${", "$$", "%"):
self.assertNotIn(motif, texte)
def test_la_commande_est_du_shell_valide(self):
"""« bash -n » lit sans exécuter."""
argv = self._argv()
for texte in (
cache_offline.guet_cmd(self.JOURNAUX, self.MARQUEUR),
argv[self._attente(argv)],
cache_offline.table_posee_cmd(),
):
res = subprocess.run(
["bash", "-n"], input=texte, text=True, capture_output=True
)
self.assertEqual(res.returncode, 0, res.stderr)
def test_les_releves_et_la_levee_immediate(self):
actif = cache_offline.guet_actif_cmd()
self.assertIn("systemctl is-active --quiet", actif)
self.assertIn(cache_offline.UNITE_GUET, actif)
self.assertNotIn("sudo", actif, "lire un état n'a pas besoin de root")
self.assertEqual(
cache_offline.lever_maintenant_cmd(),
f"sudo systemctl stop {cache_offline.UNITE_GUET}",
)
def test_une_lecture_ratee_nest_pas_une_absence(self):
"""« ! nft list table » conclurait à l'absence dès que sudo refuse,
et un rebranchement raté se lirait comme réussi."""
cmd = cache_offline.table_posee_cmd()
self.assertIn("$(sudo nft list tables) || exit 2", cmd)
self.assertIn(f"grep -qxF 'table inet {cache_offline.TABLE}'", cmd)
self.assertNotIn("!", cmd)
class TestLaLeveeConfieeAuGuet(_SansSysteme, unittest.TestCase):
"""Sur la voie suivie, la levée part chez root APRÈS le lancement — les
journaux existent alors — et AVANT le tableau de bord, qui prend le
terminal : une invite de mot de passe n'y aurait nulle part où
s'afficher."""
def _suivi(self, guet, rc_guet=0):
import contextlib
import io
import json
import shlex
import tempfile
from pathlib import Path
import script.todo.qemu_install_monitor as mon
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
journal = str(Path(tmp.name) / "journal d'essai" / "vm-a.log")
vu = {"ordre": [], "guet": []}
def lance(vms, branche, remote):
vu["ordre"].append("lancement")
Path(journal).parent.mkdir()
Path(journal).write_text("", encoding="utf-8")
manifeste = Path(tmp.name) / "session.json"
manifeste.write_text(
json.dumps({"vms": [{"name": "vm-a", "log": journal}]}),
encoding="utf-8",
)
return str(manifeste)
def shell(cmd, timeout=60):
if "systemd-run" in cmd:
vu["ordre"].append("guet")
vu["guet"].append(shlex.split(cmd))
return rc_guet
vu["ordre"].append(f"? {cmd}")
return 1
todo = _todo()
todo._qemu_shell = shell
todo._qemu_import_module = lambda: None
todo._qemu_erplibre_remote_cmd = lambda *a, **kw: "true"
with mock.patch.object(
mon, "launch_installs", lance
), mock.patch.object(
mon, "run_monitor", lambda m: vu["ordre"].append("tableau")
), contextlib.redirect_stdout(
io.StringIO()
) as sortie:
todo._qemu_install_erplibre_monitored(
["vm-a"],
"develop",
{"vm-a": "192.0.2.10"},
guet_hors_ligne=guet,
)
vu["ecrit"] = sortie.getvalue()
vu["journal"] = journal
return vu
def test_le_guet_part_entre_le_lancement_et_le_tableau(self):
vu = self._suivi(guet=True)
self.assertEqual(vu["ordre"], ["lancement", "guet", "tableau"])
self.assertEqual(
vu["guet"][0][-1],
vu["journal"],
"le guet n'attend pas le journal du manifeste",
)
self.assertEqual(self.lancees, [])
def test_sans_coupure_aucun_guet(self):
vu = self._suivi(guet=False)
self.assertEqual(vu["ordre"], ["lancement", "tableau"])
def test_un_guet_refuse_le_dit_et_le_suivi_souvre_quand_meme(self):
"""La coupure reste alors au « finally » : fermer le tableau de bord
avant la fin fait finir les installations en ligne, et on le dit."""
from script.todo.todo_i18n import t
vu = self._suivi(guet=True, rc_guet=1)
self.assertEqual(vu["ordre"], ["lancement", "guet", "tableau"])
self.assertIn(
t("closes; closing it early finishes the installs online."),
vu["ecrit"],
)
def test_la_spec_coupee_arme_le_guet_du_suivi(self):
"""Le drapeau vient du bloc de la coupure, pas d'une relecture de la
spec : sans coupure posée, aucun guet."""
import contextlib
import io
for hors_ligne in (True, False):
with self.subTest(hors_ligne=hors_ligne):
hote = _FauxHote()
todo = _todo()
todo._qemu_shell = hote.shell
vus = []
todo._qemu_install_erplibre_monitored = (
lambda *a, **kw: vus.append(kw)
)
todo._qemu_resolve_ips = lambda names, labels=None: {}
with contextlib.redirect_stdout(io.StringIO()):
todo._qemu_run_spec(
{
"vms": [],
"existing": ["vm-a"],
"install": None,
"add_ssh_config": False,
"parallelism": 1,
"monitor": True,
"offline": hors_ligne,
}
)
self.assertIs(vus[0]["guet_hors_ligne"], hors_ligne)
self.assertIs(vus[0].get("hors_ligne"), hors_ligne)
class TestLeFinallyDuGuet(_SansSysteme, unittest.TestCase):
"""À la sortie du bloc : ne pas lever sous un guet actif, et ne dire
« rebranché » que constaté."""
def test_guet_actif_la_coupure_reste_et_le_geste_est_donne(self):
"""Les installations tournent encore, détachées : lever ici les ferait
finir en ligne."""
hote = _FauxHote()
vu = _joue_la_spec(hote, guet_pendant=True)
self.assertEqual(hote.ordre, ["coupure", "déploiement", "guet"])
self.assertTrue(hote.table)
self.assertIn(cache_offline.lever_maintenant_cmd(), vu["ecrit"])
self.assertIn("12 h", vu["ecrit"])
self.assertIs(vu["kw"]["coupee"], True)
def test_sans_guet_le_finally_leve_et_constate(self):
from script.todo.todo_i18n import t
hote = _FauxHote()
vu = _joue_la_spec(hote)
self.assertEqual(
hote.ordre, ["coupure", "déploiement", "rebranchement"]
)
self.assertFalse(hote.table)
self.assertIn(t("Cache upstream restored."), vu["ecrit"])
def test_un_retrait_refuse_ne_se_dit_pas_rebranche(self):
"""`restore_cmd` rend 0 même quand sudo refuse."""
from script.todo.todo_i18n import t
hote = _FauxHote(retrait=False)
vu = _joue_la_spec(hote)
self.assertNotIn(t("Cache upstream restored."), vu["ecrit"])
self.assertIn(cache_offline.restore_cmd(), vu["ecrit"])
def test_une_table_illisible_ne_se_dit_pas_rebranchee(self):
from script.todo.todo_i18n import t
hote = _FauxHote(lecture=False)
vu = _joue_la_spec(hote)
self.assertEqual(
hote.ordre, ["coupure", "déploiement", "rebranchement"]
)
self.assertNotIn(t("Cache upstream restored."), vu["ecrit"])
class TestUneSecondeCoupure(_SansSysteme, unittest.TestCase):
"""La table est unique : deux coupures n'en font qu'une, et la première
levée ôte les deux."""
def test_un_guet_actif_refuse_tout(self):
"""Sa fin lèverait la coupure de ce déploiement en cours de route."""
from script.todo.todo_i18n import t
hote = _FauxHote(table=True, guet=True)
vu = _joue_la_spec(hote)
self.assertEqual(hote.ordre, [], "une seconde coupure a été posée")
self.assertIn(
t("An offline deployment is still running: the cut"), vu["ecrit"]
)
def test_un_guet_actif_refuse_meme_table_levee_a_la_main(self):
hote = _FauxHote(table=False, guet=True)
_joue_la_spec(hote)
self.assertEqual(hote.ordre, [])
def test_un_reste_sans_guet_se_demande_et_non_ne_deploie_rien(self):
from script.todo.todo_i18n import t
hote = _FauxHote(table=True)
vu = _joue_la_spec(hote, reponse="n")
self.assertEqual(hote.ordre, [])
self.assertTrue(hote.table, "le reste a été levé sans accord")
self.assertIn(t("Nothing deployed."), vu["ecrit"])
def test_un_reste_leve_sur_accord_puis_la_coupure_suit(self):
hote = _FauxHote(table=True)
_joue_la_spec(hote, reponse="o")
self.assertEqual(
hote.ordre,
["rebranchement", "coupure", "déploiement", "rebranchement"],
)
def test_rien_nest_demande_quand_la_voie_est_libre(self):
# `_joue_la_spec` sans réponse rend toute question fatale.
hote = _FauxHote()
_joue_la_spec(hote)
self.assertEqual(hote.ordre[0], "coupure")
class TestLaLigneDeCommit(unittest.TestCase):
"""Le journal nomme le commit que la VM exécute : hors ligne, le clone
vient du miroir du cache, qui peut retarder sur le distant."""
def _script(self, prod=False):
return _todo()._qemu_erplibre_remote_cmd("develop", None, prod)
def test_la_ligne_suit_le_clone_et_precede_le_make(self):
for prod, depot in (
(False, "~/git/erplibre"),
(True, "/opt/erplibre"),
):
with self.subTest(prod=prod):
script = self._script(prod)
ligne = script.index(f"git -C {depot} log -1")
self.assertLess(script.index("git clone --branch"), ligne)
self.assertLess(ligne, script.index("make install_os"))
self.assertIn("Commit : %h %s", script)
def test_un_depot_garde_se_dit_garde(self):
from script.todo.todo_i18n import t
script = self._script()
clone = script.index("git clone --branch")
garde = script.index(t("Existing checkout kept, not updated:"))
self.assertLess(clone, script.index("else echo", clone))
self.assertLess(script.index("else echo", clone), garde)
self.assertLess(garde, script.index("fi;", clone))
def test_la_ligne_ne_fait_jamais_echouer_linstallation(self):
"""Sous « set -e », un « git log » qui échoue — dépôt absent, git
refusant un dépôt d'un autre compte — arrêterait tout. Lu sur un
dépôt qui n'existe pas : git ne fait que lire."""
import tempfile
from script.todo.qemu_deploy import QemuDeployMixin
with tempfile.TemporaryDirectory() as tmp:
absent = f"{tmp}/pas_de_depot"
res = subprocess.run(
[
"bash",
"-c",
"set -e; "
+ QemuDeployMixin._qemu_commit_line(absent)
+ "echo SUITE",
],
text=True,
capture_output=True,
timeout=30,
)
self.assertIn("SUITE", res.stdout, res.stderr)
def test_le_script_reste_du_shell_valide(self):
for prod in (False, True):
res = subprocess.run(
["bash", "-n"],
input=self._script(prod),
text=True,
capture_output=True,
)
self.assertEqual(res.returncode, 0, res.stderr)
class TestLecartHorsLigne(_SansSysteme, unittest.TestCase):
"""Hors ligne, la VM clone le miroir du cache TEL QUEL : « git push »
seul n'y change rien, et c'est son sha qui compte."""
MIROIR = "/srv/essai/miroir/erplibre.git"
SHA_MIROIR = "a" * 40
SHA_ICI = "b" * 40
def _git(
self,
sha_miroir=SHA_MIROIR,
sha_ici=SHA_ICI,
compte="3",
refs=None,
rc_ls=None,
sortie_ls=None,
):
"""Un faux git. `refs` : {branche: sha du miroir}, "" pour une
branche que le miroir n'a pas — « ls-remote --exit-code » rend alors
2 ; par défaut la seule « dev ». `rc_ls` : une requête qui échoue
avec ce code. `sortie_ls` : ce que rend un ls-remote qui réussit."""
self.appels = []
refs = {"dev": sha_miroir} if refs is None else refs
def run(argv, *a, **kw):
self.appels.append(list(argv))
fait = subprocess.CompletedProcess
if argv[:2] == ["git", "ls-remote"]:
if rc_ls is not None:
return fait(argv, rc_ls, "", "fatal: illisible\n")
if sortie_ls is not None:
return fait(argv, 0, sortie_ls, "")
sha = refs.get(argv[-1][len("refs/heads/") :])
if not sha:
return fait(argv, 2, "", "")
return fait(argv, 0, f"{sha}\t{argv[-1]}\n", "")
if argv[:2] == ["git", "rev-parse"]:
return fait(argv, 0 if sha_ici else 1, sha_ici, "")
if argv[:2] == ["git", "rev-list"]:
return fait(argv, 0, f"{compte}\n", "")
if argv[:2] == ["git", "log"]:
return fait(argv, 0, "c1 [FIX] un correctif\n", "")
raise AssertionError(f"commande inattendue : {argv}")
return run
def _lignes(self, hors_ligne, miroir=MIROIR, **git):
todo = _todo()
todo._qemu_miroir_erplibre = lambda: miroir
with mock.patch("subprocess.run", self._git(**git)):
return todo._qemu_branch_gap_lines("dev", hors_ligne=hors_ligne)
def test_hors_ligne_le_sha_du_miroir_est_dit(self):
from script.todo.todo_i18n import t
texte = "\n".join(self._lignes(True))
self.assertIn(self.SHA_MIROIR[:12], texte)
self.assertIn(self.SHA_ICI[:12], texte)
self.assertIn(f"3 {t('commit(s) missing from the mirror')}", texte)
self.assertIn(t("A push alone changes nothing: the mirror is"), texte)
self.assertNotIn(t("to deploy your own work."), texte)
self.assertIn(
["git", "ls-remote", "--exit-code", self.MIROIR, "refs/heads/dev"],
self.appels,
)
def test_en_ligne_le_conseil_reste_git_push(self):
from script.todo.todo_i18n import t
texte = "\n".join(self._lignes(False))
self.assertIn(f"git push {t('to deploy your own work.')}", texte)
self.assertFalse(
[a for a in self.appels if a[:2] == ["git", "ls-remote"]]
)
def test_sans_miroir_on_se_tait_et_retombe_sur_origin(self):
from script.todo.todo_i18n import t
texte = "\n".join(self._lignes(True, miroir=""))
self.assertIn(f"git push {t('to deploy your own work.')}", texte)
self.assertFalse(
[a for a in self.appels if a[:2] == ["git", "ls-remote"]]
)
def test_un_miroir_a_jour_na_rien_a_dire(self):
self.assertEqual(self._lignes(True, sha_ici=self.SHA_MIROIR), [])
def test_une_branche_absente_du_miroir_fera_echouer_le_clone(self):
from script.todo.todo_i18n import t
texte = "\n".join(self._lignes(True, sha_miroir=""))
self.assertIn(t("an offline clone will fail."), texte)
def test_une_requete_ratee_ne_dit_pas_la_branche_absente(self):
"""Seul le code 2 de « --exit-code » vaut absence : un miroir
illisible ne sait rien, et l'écart retombe sur origin."""
from script.todo.todo_i18n import t
texte = "\n".join(self._lignes(True, rc_ls=128))
self.assertNotIn(t("an offline clone will fail."), texte)
self.assertIn(f"git push {t('to deploy your own work.')}", texte)
def test_une_reference_qui_finit_pareil_nest_pas_la_branche(self):
"""Le motif de ls-remote se compare à la FIN des références."""
from script.todo.todo_i18n import t
sortie = f"{self.SHA_MIROIR}\trefs/heads/x/refs/heads/dev\n"
texte = "\n".join(self._lignes(True, sortie_ls=sortie))
self.assertIn(f"{t('The cache mirror has no branch')} dev", texte)
VM_RECAP = {
"distro": "debian",
"version": "13",
"arch": "amd64",
"vcpus": 2,
"ram": 2048,
"disk": "20G",
}
def _recap(self, hors_ligne=True, **git):
"""Le récapitulatif de deux VM sur deux branches : « dev » par
défaut, « stable » pour la seconde."""
import contextlib
import io
todo = _todo()
todo._qemu_miroir_erplibre = lambda: self.MIROIR
todo._qemu_sudo_lines = lambda: []
spec = {
"vms": [
dict(self.VM_RECAP, name="vm-a"),
dict(self.VM_RECAP, name="vm-b", branch="stable"),
],
"install": {
"branch": "dev",
"prod": False,
"label": "x",
"cmd": "make x",
},
"add_ssh_config": False,
"parallelism": 1,
"offline": hors_ligne,
}
with mock.patch(
"subprocess.run", self._git(**git)
), contextlib.redirect_stdout(io.StringIO()) as sortie:
todo._qemu_print_recap(spec, [])
return sortie.getvalue()
def _refs_lues(self):
return [a[-1] for a in self.appels if a[:2] == ["git", "ls-remote"]]
def test_deux_branches_chacune_face_au_miroir(self):
"""« varie, voir chaque ligne » est un libellé : le miroir n'a aucune
branche de ce nom, et l'annoncer absente serait faux."""
from script.todo.todo_i18n import t
texte = self._recap(refs={"dev": self.SHA_ICI, "stable": ""})
self.assertEqual(
self._refs_lues(), ["refs/heads/dev", "refs/heads/stable"]
)
self.assertIn(
f"{t('The cache mirror has no branch')} stable: "
f"{t('an offline clone will fail.')}",
texte,
)
self.assertNotIn(f"{t('The cache mirror has no branch')} dev", texte)
self.assertNotIn(
f"{t('The cache mirror has no branch')} "
f"{t('varies, see each line')}",
texte,
)
def test_deux_branches_a_jour_rien_a_dire(self):
from script.todo.todo_i18n import t
texte = self._recap(refs={"dev": self.SHA_ICI, "stable": self.SHA_ICI})
self.assertNotIn(t("The cache mirror has no branch"), texte)
self.assertNotIn(
t("Offline, the VM clones the cache mirror of"), texte
)
def test_deux_branches_le_retard_nomme_la_sienne(self):
from script.todo.todo_i18n import t
texte = self._recap(
refs={"dev": self.SHA_MIROIR, "stable": self.SHA_ICI}
)
self.assertIn(
f"{t('Offline, the VM clones the cache mirror of')} dev: "
f"{self.SHA_MIROIR[:12]}",
texte,
)
self.assertNotIn(
f"{t('Offline, the VM clones the cache mirror of')} stable",
texte,
)
self.assertEqual(
1, texte.count(t("A push alone changes nothing: the mirror is"))
)
def test_deux_branches_une_requete_ratee_se_tait(self):
from script.todo.todo_i18n import t
texte = self._recap(rc_ls=128)
self.assertNotIn(t("The cache mirror has no branch"), texte)
self.assertNotIn(t("to deploy your own work."), texte)
self.assertFalse([a for a in self.appels if a[:2] == ["git", "log"]])
def test_deux_branches_en_ligne_rien_nest_demande(self):
"""En ligne, l'écart se mesure contre HEAD, une seule branche : il
attribuerait à l'autre des commits qui ne la concernent pas."""
texte = self._recap(hors_ligne=False)
self.assertEqual(self.appels, [])
self.assertNotIn("⚠", texte)
def test_le_recapitulatif_passe_le_hors_ligne(self):
"""Le récapitulatif est le dernier écran avant de déployer : c'est là
que l'écart doit parler du miroir."""
import contextlib
import io
for hors_ligne in (True, False):
with self.subTest(hors_ligne=hors_ligne):
todo = _todo()
vus = []
todo._qemu_branch_gap_lines = lambda br, **kw: (
vus.append(kw) or []
)
todo._qemu_sudo_lines = lambda: []
spec = {
"vms": [
{
"name": "vm-a",
"distro": "debian",
"version": "13",
"arch": "amd64",
"vcpus": 2,
"ram": 2048,
"disk": "20G",
}
],
"install": {
"branch": "dev",
"prod": False,
"label": "x",
"cmd": "make x",
},
"add_ssh_config": False,
"parallelism": 1,
"offline": hors_ligne,
}
with contextlib.redirect_stdout(io.StringIO()):
todo._qemu_print_recap(spec, [])
self.assertIs(vus[0].get("hors_ligne"), hors_ligne)
def test_le_chemin_est_celui_que_le_cache_calcule(self):
from script.todo.qemu_cache_menu import CACHE_MIROIR_GIT
attendu = f"{CACHE_MIROIR_GIT}/github.com/erplibre/erplibre.git"
todo = _todo()
with mock.patch("os.path.isdir", lambda p: p == attendu):
self.assertEqual(todo._qemu_miroir_erplibre(), attendu)
with mock.patch("os.path.isdir", return_value=False):
self.assertEqual(todo._qemu_miroir_erplibre(), "")
class TestUnCheminQueSystemdReecrirait(unittest.TestCase):
"""Le guet reçoit les chemins de journaux en arguments, que systemd
réécrit quand ils portent « $ » ou « % » : le journal ne serait jamais
trouvé et la coupure tiendrait douze heures. Un tel chemin ne part donc
pas au guet, et la levée revient au « finally »."""
def _confier(self, chemin_journal):
import contextlib
import io as _io
import json
import tempfile
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
lances = []
todo._qemu_shell = lambda cmd, timeout=60: lances.append(cmd) or 0
with tempfile.NamedTemporaryFile(
"w", suffix=".json", delete=False, encoding="utf-8"
) as fh:
json.dump({"vms": [{"log": chemin_journal}]}, fh)
manifeste = fh.name
with contextlib.redirect_stdout(_io.StringIO()):
rendu = todo._qemu_confier_la_levee(manifeste)
return rendu, lances
def test_les_caracteres_reecrits_sont_refuses(self):
self.assertTrue(cache_offline.chemins_surs(["/srv/run/vm-a.log"]))
for piege in ("/srv/$HOME/vm.log", "/srv/run/%h.log", "/a/${X}/b"):
self.assertFalse(cache_offline.chemins_surs([piege]), piege)
def test_un_chemin_sur_part_au_guet(self):
rendu, lances = self._confier("/srv/run/vm-a.log")
self.assertTrue(rendu)
self.assertTrue(any("systemd-run" in c for c in lances), lances)
def test_un_chemin_reecrit_ne_part_pas_au_guet(self):
rendu, lances = self._confier("/srv/run/$HOME/vm-a.log")
self.assertFalse(rendu, "le guet a été posé sur un chemin réécrit")
self.assertFalse(
any("systemd-run" in c for c in lances),
f"systemd-run a été lancé : {lances}",
)
class TestLeGuetAttendLesJournauxQuiNExistentPasEncore(unittest.TestCase):
"""Le script du guet est EXÉCUTÉ ici, par un « sh » local : ni root, ni
systemd, ni pare-feu. Le guet part juste après le lancement des
installations ; un journal qui n'existe pas encore compté pour fini
lèverait la coupure sur-le-champ, et l'installation finirait en ligne."""
MARQUEUR = "__FIN_DE_TEST__"
def _lancer(self, *journaux):
import subprocess as sp
script = cache_offline.script_attente(self.MARQUEUR, pas=1)
proc = sp.Popen(["sh", "-c", script, "sh", *journaux])
self.addCleanup(lambda: proc.poll() is None and proc.kill())
return proc
def _tmp(self):
import tempfile
d = tempfile.mkdtemp()
self.addCleanup(lambda: __import__("shutil").rmtree(d, True))
return d
def test_un_journal_absent_nest_pas_fini(self):
import os
import time
proc = self._lancer(os.path.join(self._tmp(), "pas-encore.log"))
time.sleep(1.5)
self.assertIsNone(
proc.poll(), "le guet a rendu la main sur un journal absent"
)
def test_un_journal_fini_et_un_absent_attendent_encore(self):
import os
import time
d = self._tmp()
fini = os.path.join(d, "a.log")
with open(fini, "w", encoding="utf-8") as fh:
fh.write(f"installation\n{self.MARQUEUR} 0\n")
proc = self._lancer(fini, os.path.join(d, "b.log"))
time.sleep(1.5)
self.assertIsNone(proc.poll())
def test_le_guet_rend_la_main_quand_tous_sont_finis(self):
import os
import time
d = self._tmp()
a, b = os.path.join(d, "a.log"), os.path.join(d, "b.log")
proc = self._lancer(a, b)
time.sleep(0.3)
for chemin in (a, b):
with open(chemin, "w", encoding="utf-8") as fh:
fh.write(f"installation\n{self.MARQUEUR} 2\n")
self.assertEqual(proc.wait(timeout=5), 0)
def setUpModule():
"""Les sessions d'installation, et le verrou des déploiements hors ligne
qui vit parmi elles, vont dans un répertoire temporaire : aucun test ne
crée de fichier sous le répertoire personnel, et un déploiement réel en
cours sur la machine de test ne tient pas le verrou des tests."""
import tempfile
import script.todo.qemu_install_monitor as mon
sessions = tempfile.TemporaryDirectory()
unittest.addModuleCleanup(sessions.cleanup)
patch = mock.patch.object(mon, "session_dir", lambda: Path(sessions.name))
patch.start()
unittest.addModuleCleanup(patch.stop)
# Les commandes rendues ne dépendent pas de l'hôte de test : sans ce
# remplacement, un hôte sans dnsmasq rendrait « false » pour la pose.
dns = mock.patch.object(
cache_offline, "dnsmasq", lambda: "/usr/sbin/dnsmasq"
)
dns.start()
unittest.addModuleCleanup(dns.stop)
def _chemin_du_verrou():
return _todo()._qemu_verrou_hors_ligne_chemin()
def _verrou_pris_ailleurs(test):
"""Prend le verrou comme le ferait un autre terminal : un second
descripteur ouvert sur le même fichier, `flock` exclusif."""
import fcntl
import os
fd = os.open(_chemin_du_verrou(), os.O_RDWR | os.O_CREAT, 0o600)
test.addCleanup(os.close, fd)
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
def _verrou_libre():
"""True si le verrou peut être pris maintenant ; il est aussitôt rendu."""
import fcntl
import os
fd = os.open(_chemin_du_verrou(), os.O_RDWR | os.O_CREAT, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return False
finally:
os.close(fd)
return True
class TestUnDeploiementEnLigneSousLaCoupure(_SansSysteme, unittest.TestCase):
"""La table est partagée par tout le cache : un déploiement EN LIGNE
lancé pendant qu'un autre la tient tournerait hors ligne. Il ne part
pas en silence, et cette voie n'appelle jamais sudo."""
def _sans_sudo(self, vu):
for cmd in vu["lances"]:
self.assertNotIn("sudo", cmd, "la voie en ligne a appelé sudo")
def test_un_guet_actif_se_dit_et_non_ne_deploie_rien(self):
from script.todo.todo_i18n import t
hote = _FauxHote(table=True, guet=True)
vu = _joue_la_spec(hote, reponse="n", hors_ligne=False)
self.assertEqual(hote.ordre, [], "déployé sans accord")
self.assertIn(
t("This deployment would therefore run offline."), vu["ecrit"]
)
self.assertIn(cache_offline.lever_maintenant_cmd(), vu["ecrit"])
self.assertIn("12 h", vu["ecrit"])
self.assertIn(t("Nothing deployed."), vu["ecrit"])
self._sans_sudo(vu)
def test_sur_accord_il_part_sans_rien_couper_ni_lever(self):
hote = _FauxHote(table=True, guet=True)
vu = _joue_la_spec(hote, reponse="o", hors_ligne=False)
self.assertEqual(hote.ordre, ["déploiement"])
self.assertIs(vu["kw"]["coupee"], False)
self.assertTrue(hote.table, "la coupure de l'autre a été levée")
self._sans_sudo(vu)
def test_sans_guet_ni_verrou_rien_nest_demande(self):
# `_joue_la_spec` sans réponse rend toute question fatale.
hote = _FauxHote()
vu = _joue_la_spec(hote, hors_ligne=False)
self.assertEqual(hote.ordre, ["déploiement"])
self.assertEqual(vu["lances"], [cache_offline.guet_actif_cmd()])
def test_un_deploiement_hors_ligne_qui_se_lance_se_dit_aussi(self):
"""Entre la coupure et le guet, seul le verrou témoigne : le guet ne
part qu'au lancement des installations."""
from script.todo.todo_i18n import t
_verrou_pris_ailleurs(self)
hote = _FauxHote(table=True)
vu = _joue_la_spec(hote, reponse="n", hors_ligne=False)
self.assertEqual(hote.ordre, [])
self.assertIn(
t("terminal: the cache upstream is cut until it ends."),
vu["ecrit"],
)
self._sans_sudo(vu)
def test_le_sondage_ne_cree_pas_le_fichier_du_verrou(self):
# Le répertoire des sessions est commun au module : un test précédent
# a pu y laisser le fichier.
_chemin_du_verrou().unlink(missing_ok=True)
hote = _FauxHote()
_joue_la_spec(hote, hors_ligne=False)
self.assertFalse(_chemin_du_verrou().exists())
class TestLeVerrouDesDeploiementsHorsLigne(_SansSysteme, unittest.TestCase):
"""Deux déploiements hors ligne lancés ensemble passeraient tous deux
les relevés avant que l'un ait posé son guet : un verrou tient le bloc
entier, relevés compris."""
def test_un_verrou_tenu_refuse_avant_tout_releve(self):
from script.todo.todo_i18n import t
_verrou_pris_ailleurs(self)
hote = _FauxHote()
vu = _joue_la_spec(hote)
self.assertEqual(vu["lances"], [], "un relevé a précédé le verrou")
self.assertEqual(hote.ordre, [])
self.assertIn(t("running in another terminal."), vu["ecrit"])
self.assertIn(t("Nothing deployed."), vu["ecrit"])
def test_tenu_pendant_le_bloc_et_rendu_apres(self):
hote = _FauxHote()
vu = _joue_la_spec(
hote, pendant=lambda vu: vu.update(libre=_verrou_libre())
)
self.assertIs(vu["libre"], False, "le verrou n'est pas tenu")
self.assertTrue(_verrou_libre(), "le verrou n'est pas rendu")
def test_rendu_quand_la_coupure_est_refusee(self):
hote = _FauxHote(guet=True)
_joue_la_spec(hote)
self.assertEqual(hote.ordre, [])
self.assertTrue(_verrou_libre())
class TestLesSignauxPendantLaCoupure(_SansSysteme, unittest.TestCase):
"""SIGHUP et SIGTERM tuent sur place par défaut : un terminal fermé
laisserait la coupure posée sans fin. Tant qu'elle est tenue, ils
déroulent le « finally ». Aucun signal n'est envoyé au processus de
test : le gestionnaire est relu et appelé directement."""
SIGNAUX = ("SIGHUP", "SIGTERM")
def setUp(self):
import signal
super().setUp()
for nom in self.SIGNAUX:
num = getattr(signal, nom)
self.addCleanup(signal.signal, num, signal.getsignal(num))
signal.signal(num, signal.SIG_DFL)
@staticmethod
def _releve(vu):
import signal
vu["pendant"] = {
nom: signal.getsignal(getattr(signal, nom))
for nom in ("SIGHUP", "SIGTERM")
}
def _rendus(self):
import signal
for nom in self.SIGNAUX:
self.assertEqual(
signal.getsignal(getattr(signal, nom)),
signal.SIG_DFL,
f"{nom} n'est pas rendu",
)
def test_le_premier_signal_sort_le_suivant_ne_coupe_pas_la_levee(self):
import signal
def pendant(vu):
self._releve(vu)
try:
vu["pendant"]["SIGTERM"](signal.SIGTERM, None)
except SystemExit as fin:
vu["code"] = fin.code
vu["second"] = vu["pendant"]["SIGHUP"](signal.SIGHUP, None)
vu = _joue_la_spec(_FauxHote(), pendant=pendant)
for nom in self.SIGNAUX:
self.assertTrue(
callable(vu["pendant"][nom]), f"{nom} non intercepté"
)
self.assertEqual(vu["code"], 128 + signal.SIGTERM)
self.assertIsNone(vu["second"])
self._rendus()
def test_un_signal_dans_le_bloc_leve_la_coupure(self):
import signal
def pendant(vu):
signal.getsignal(signal.SIGHUP)(signal.SIGHUP, None)
hote = _FauxHote()
vu = _joue_la_spec(hote, pendant=pendant)
self.assertEqual(vu["sortie"], 128 + signal.SIGHUP)
self.assertEqual(
hote.ordre, ["coupure", "déploiement", "rebranchement"]
)
self.assertFalse(hote.table)
self._rendus()
self.assertTrue(_verrou_libre())
def test_un_signal_pendant_la_commande_de_coupure_leve_quand_meme(self):
"""La table est posée, la commande n'a pas encore rendu la main :
le signal reçu là doit trouver la levée sur son chemin."""
import signal
hote = _FauxHote()
dessous = hote.shell
def shell(cmd, timeout=60):
rc = dessous(cmd, timeout)
if cmd == cache_offline.cut_cmd():
signal.getsignal(signal.SIGTERM)(signal.SIGTERM, None)
return rc
hote.shell = shell
vu = _joue_la_spec(hote)
self.assertEqual(vu["sortie"], 128 + signal.SIGTERM)
self.assertEqual(hote.ordre, ["coupure", "rebranchement"])
self.assertFalse(hote.table, "la coupure est restée posée")
self._rendus()
self.assertTrue(_verrou_libre())
def test_une_coupure_refusee_nest_pas_levee(self):
"""Le sudo qui a refusé la coupure refuserait le retrait, et
redemanderait un mot de passe pour rien."""
from script.todo.todo_i18n import t
hote = _FauxHote()
dessous = hote.shell
def shell(cmd, timeout=60):
rc = dessous(cmd, timeout)
return 1 if cmd == cache_offline.cut_cmd() else rc
hote.shell = shell
vu = _joue_la_spec(hote)
self.assertEqual(hote.ordre, ["coupure"])
self.assertIn(t("Upstream not cut: nothing deployed."), vu["ecrit"])
self._rendus()
self.assertTrue(_verrou_libre())
def test_sans_dnsmasq_rien_nest_touche_et_on_dit_pourquoi(self):
"""La pose échouerait sans motif lisible : le refus vient avant, et
nomme ce qui manque."""
from script.todo.todo_i18n import t
hote = _FauxHote()
with mock.patch.object(cache_offline, "dnsmasq", lambda: ""):
vu = _joue_la_spec(hote)
self.assertEqual(hote.ordre, [], "une commande est partie")
self.assertEqual(vu["lances"], [])
self.assertIn(
t("dnsmasq is missing on the host: names cannot"), vu["ecrit"]
)
self._rendus()
self.assertTrue(_verrou_libre())
def test_un_signal_ignore_le_reste(self):
"""Lancé sous « nohup », le déploiement survit à son terminal."""
import signal
signal.signal(signal.SIGHUP, signal.SIG_IGN)
vu = _joue_la_spec(_FauxHote(), pendant=self._releve)
self.assertEqual(vu["pendant"]["SIGHUP"], signal.SIG_IGN)
self.assertTrue(callable(vu["pendant"]["SIGTERM"]))
self.assertEqual(signal.getsignal(signal.SIGHUP), signal.SIG_IGN)
def test_en_ligne_aucun_gestionnaire_nest_pose(self):
import signal
vu = _joue_la_spec(_FauxHote(), hors_ligne=False, pendant=self._releve)
self.assertEqual(
vu["pendant"], {nom: signal.SIG_DFL for nom in self.SIGNAUX}
)
def test_hors_du_fil_principal_le_deploiement_passe(self):
"""`signal.signal` lève ValueError hors du fil principal."""
import threading
hote = _FauxHote()
erreurs = []
def fil():
try:
_joue_la_spec(hote)
except BaseException as exc: # noqa: B902
erreurs.append(exc)
fil_de_test = threading.Thread(target=fil)
fil_de_test.start()
fil_de_test.join(timeout=30)
self.assertEqual(erreurs, [])
self.assertEqual(
hote.ordre, ["coupure", "déploiement", "rebranchement"]
)
self._rendus()
class TestLeDebutDuDeploiementDansLaSession(_SansSysteme, unittest.TestCase):
"""« deploy_started » ouvre la fenêtre du bilan hors ligne AVANT la
création des VM : « started » n'est posé qu'après leur premier
démarrage, quand cloud-init et l'agent invité ont déjà demandé."""
VM = {
"name": "vm-a",
"ip": "192.0.2.10",
"distro": "ubuntu",
"version": "24.04",
"arch": "amd64",
}
def _session(self, **kw):
import json
import tempfile
import script.todo.qemu_install_monitor as mon
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
with mock.patch.object(
mon, "session_dir", lambda: Path(tmp.name)
), mock.patch.object(
mon, "_launch_one", lambda *a, **k: None
), mock.patch.object(
mon, "local_uuid", lambda nom: ""
):
chemin = mon.launch_installs(
[dict(self.VM)], "develop", "true", **kw
)
with open(chemin, encoding="utf-8") as fh:
return json.load(fh)
def test_ecrit_quand_il_est_donne(self):
data = self._session(deploy_started=1234)
self.assertEqual(data["deploy_started"], 1234.0)
self.assertIsInstance(data["deploy_started"], float)
self.assertIn("started", data)
def test_absent_sinon(self):
data = self._session()
self.assertNotIn("deploy_started", data)
self.assertNotIn("offline", data)
self.assertIn("started", data)
def test_le_hors_ligne_est_ecrit_quand_il_est_donne(self):
"""Seul un « offline » vrai permet au bilan de clore la lecture :
les lignes d'amont muet naissent aussi en ligne."""
for valeur in (True, False):
with self.subTest(hors_ligne=valeur):
data = self._session(hors_ligne=valeur)
self.assertIs(data["offline"], valeur)
def _suivi(self, **kw):
import contextlib
import io
import json
import tempfile
import script.todo.qemu_install_monitor as mon
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
recus = []
def lance(vms, branche, remote, **options):
recus.append(options)
manifeste = Path(tmp.name) / "session.json"
manifeste.write_text(json.dumps({"vms": []}), encoding="utf-8")
return str(manifeste)
todo = _todo()
todo._qemu_shell = lambda cmd, timeout=60: 1
todo._qemu_import_module = lambda: None
todo._qemu_erplibre_remote_cmd = lambda *a, **k: "true"
with mock.patch.object(
mon, "launch_installs", lance
), mock.patch.object(
mon, "run_monitor", lambda m: None
), contextlib.redirect_stdout(
io.StringIO()
):
todo._qemu_install_erplibre_monitored(
["vm-a"], "develop", {"vm-a": "192.0.2.10"}, **kw
)
return recus
def test_le_suivi_le_transmet_au_lanceur(self):
self.assertEqual(
self._suivi(deploy_started=1234.5), [{"deploy_started": 1234.5}]
)
def test_sans_valeur_le_lanceur_ne_recoit_rien_de_plus(self):
self.assertEqual(self._suivi(), [{}])
def test_le_suivi_transmet_le_hors_ligne(self):
for valeur in (True, False):
with self.subTest(hors_ligne=valeur):
self.assertEqual(
self._suivi(hors_ligne=valeur), [{"hors_ligne": valeur}]
)
def test_la_spec_le_fournit(self):
import contextlib
import io
import time
todo = _todo()
vus = []
todo._qemu_install_erplibre_monitored = lambda *a, **k: vus.append(k)
avant = time.time()
with contextlib.redirect_stdout(io.StringIO()):
todo._qemu_deploie_spec(
{
"vms": [],
"existing": ["vm-a"],
"install": None,
"add_ssh_config": False,
"parallelism": 1,
"monitor": True,
}
)
apres = time.time()
self.assertLessEqual(avant, vus[0]["deploy_started"])
self.assertLessEqual(vus[0]["deploy_started"], apres)
self.assertIs(vus[0].get("hors_ligne"), False)
class TestLeSuiviDOfficeHorsLigne(_SansSysteme, unittest.TestCase):
"""Hors ligne, seule la voie suivie confie la levée au guet : sans elle,
la voie synchrone n'a aucun guet, et une spec sans rien à installer
lèverait la coupure dès les IP connues."""
def _joue(self, spec):
import contextlib
import io
todo = _todo()
appels = []
todo._qemu_install_erplibre_monitored = lambda *a, **k: appels.append(
"suivi"
)
todo._qemu_install_erplibre_vm = lambda *a, **k: appels.append("muet")
todo._qemu_resolve_ips = lambda names, labels=None: {}
base = {
"vms": [],
"existing": ["vm-a"],
"install": None,
"add_ssh_config": False,
"parallelism": 1,
}
base.update(spec)
with contextlib.redirect_stdout(io.StringIO()):
todo._qemu_deploie_spec(base)
return appels
INSTALL_MUETTE = {
"branch": "develop",
"prod": False,
"cmd": "make x",
"monitor": False,
}
def test_rien_a_installer_le_suivi_souvre_quand_meme(self):
self.assertEqual(
self._joue({"monitor": False, "offline": True}), ["suivi"]
)
self.assertEqual(self._joue({"monitor": False}), [])
def test_une_install_sans_suivi_prend_la_voie_suivie(self):
spec = {"monitor": False, "install": self.INSTALL_MUETTE}
self.assertEqual(self._joue(dict(spec, offline=True)), ["suivi"])
self.assertEqual(self._joue(spec), ["muet"])
if __name__ == "__main__":
unittest.main()