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
1951 lines
73 KiB
Python
1951 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()
|