diff --git a/long_test/qemu_cache.py b/long_test/qemu_cache.py index 119b7ff..25dfbb7 100755 --- a/long_test/qemu_cache.py +++ b/long_test/qemu_cache.py @@ -691,6 +691,27 @@ def dans_la_vm(adresse, commande, delai, journal, montrer=False): return executer(ssh, delai, journal, montrer=montrer) +def eteindre(nom, journal, dry_run=False): + """Éteint une VM dont la mesure est faite, sans l'effacer. + + Une VM de la charge « erplibre » tient 8 Gio de mémoire. Laissée allumée + pendant que la suivante s'installe, puis la troisième, elles s'additionnent, + et l'hôte manque de mémoire en pleine série. Éteinte, elle garde son disque + pour qui voudrait l'inspecter, et « --detruire » la retrouve par son nom. + Un échec d'extinction est dit et n'arrête rien : la mesure est déjà faite. + """ + cmd = f"sudo -n virsh -c qemu:///system destroy {shlex.quote(nom)}" + if dry_run: + dire(f" [à blanc] {cmd}", journal) + return + code, _ = executer(cmd, 120, journal) + if code: + dire( + f" ⚠ {nom} : extinction impossible ({code}), elle reste allumée", + journal, + ) + + def attendre_ssh(adresse, journal): for _essai in range(40): code, _ = dans_la_vm(adresse, "true", 30, None) @@ -1310,6 +1331,9 @@ def _boucle(args, rapport, journal, acces, decalage): } ecrire_rapport(rapport) dire(f" VM {rang} : paquets posés en {duree:.0f} s", journal) + # Sa mesure est dans le rapport : allumée, elle ne sert plus à rien et + # tient sa mémoire pendant que la suivante s'installe. + eteindre(nom, journal, args.dry_run) ok = True if args.dry_run: diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index a70cd54..a482f64 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -2507,10 +2507,14 @@ CACHE_TRUST = { "update-ca-trust", "/etc/pki/tls/certs/ca-bundle.crt", ), + # openSUSE n'écrit aucun « /etc/ssl/certs/ca-certificates.crt » : son + # faisceau est « ca-bundle.pem ». Une variable qui vise un fichier absent + # fait échouer pip sur « Could not find a suitable TLS CA certificate + # bundle », en ligne comme hors ligne. "zypper": ( "/etc/pki/trust/anchors", "update-ca-certificates", - "/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/ca-bundle.pem", ), } @@ -2767,15 +2771,49 @@ def cache_commands(args: argparse.Namespace) -> list[str]: f"sh -c 'grep -q ^{var}= /etc/environment" f" || echo {var}={faisceau} >> /etc/environment'" ) + gardees = list(CACHE_ENV_VARS) if getattr(args, "offline", False): for var, valeur in OFFLINE_ENV_VARS: commandes.append( f"sh -c 'grep -q ^{var}= /etc/environment" f" || echo {var}={valeur} >> /etc/environment'" ) + gardees.append(var) + commandes.append(commande_sudoers(gardees)) return commandes +CACHE_SUDOERS = "/etc/sudoers.d/erplibre-cache" + + +def commande_sudoers(variables, fichier: str = CACHE_SUDOERS) -> str: + """La commande qui fait traverser « sudo » aux variables du cache. + + sudo remet l'environnement à zéro. Seul un module PAM qui relit + /etc/environment pour sudo y ramène les variables, et toutes les + distributions ne le configurent pas : sans lui, « sudo npm install -g » + rejette l'autorité du cache sur « self-signed certificate in certificate + chain », quand le même npm sans sudo l'accepte. « env_keep » les garde + partout, pourvu que la session appelante les porte — ce que + cache_env_reload lui assure. + + Le fichier est écrit sous un nom à point, que sudo ignore, vérifié par + « visudo -c », puis renommé : un fichier invalide dans sudoers.d rendrait + sudo inutilisable sur toute la machine. Un échec — visudo absent, pas de + répertoire sudoers.d — retire le temporaire et ne fait pas échouer la + commande. Une variable par ligne, sans guillemets : la commande passe + telle quelle dans un « runcmd » YAML comme dans un « sh -c » par ssh. + """ + dossier, nom = fichier.rsplit("/", 1) + tmp = f"{dossier}/.{nom}" + return ( + f"sh -c 'for v in {' '.join(variables)};" + f" do echo Defaults env_keep += $v; done > {tmp}" + f" && chmod 0440 {tmp} && visudo -cf {tmp} && mv {tmp} {fichier}" + f" || rm -f {tmp}'" + ) + + def cache_runcmd(args: argparse.Namespace) -> list[str]: """Les mêmes gestes, emballés en éléments de « runcmd » pour cloud-init.""" return [f" - {c}" for c in cache_commands(args)] @@ -4022,16 +4060,12 @@ def virt_install( "--network", args.network, "--console", - # Journal de console pour la voie installateur. Une console « pty » - # seule ne gardE rien : quand d-i échoue, il l'écrit à l'écran d'une - # VM que personne ne regarde, et il ne reste RIEN à lire ensuite — - # exactement « l'installation a échoué, pas de sortie pertinente ». - # Le fichier, lui, survit à l'arrêt du domaine. - ( - f"pty,target_type={console_target},log.file={console_log}" - if installer - else f"pty,target_type={console_target}" - ), + # Journal de console, pour toutes les voies. Une console « pty » seule + # ne garde rien : quand d-i échoue, ou qu'une image cloud reste bloquée + # avant son serveur ssh, la VM l'écrit à un écran que personne ne + # regarde, et il ne reste RIEN à lire ensuite. Le fichier, lui, survit + # à l'arrêt du domaine. + f"pty,target_type={console_target},log.file={console_log}", # Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à # virsh de piloter la VM SANS réseau (ex. étendre le FS invité après # un redimensionnement de disque). Inoffensif si l'agent est absent. diff --git a/script/qemu_cache/rules.go b/script/qemu_cache/rules.go index c4d3c03..f4ac7a4 100644 --- a/script/qemu_cache/rules.go +++ b/script/qemu_cache/rules.go @@ -151,9 +151,10 @@ func GuestTrustCommand(family string) (dir, cmd, bundle string, ok bool) { "/etc/pki/tls/certs/ca-bundle.crt", true case "zypper": // openSUSE range ses ancres ailleurs que la famille RHEL, tout en - // employant la même commande que Debian. + // employant la même commande que Debian ; son faisceau n'est pas celui + // de Debian, et une variable qui vise un fichier absent casse pip. return "/etc/pki/trust/anchors", "update-ca-certificates", - "/etc/ssl/certs/ca-certificates.crt", true + "/etc/ssl/ca-bundle.pem", true } return "", "", "", false } diff --git a/script/qemu_cache/rules_test.go b/script/qemu_cache/rules_test.go index 24a9eaa..73c1a9f 100644 --- a/script/qemu_cache/rules_test.go +++ b/script/qemu_cache/rules_test.go @@ -118,7 +118,7 @@ func TestCommandeDeConfiance(t *testing.T) { }, "zypper": { "/etc/pki/trust/anchors", "update-ca-certificates", - "/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/ca-bundle.pem", }, } for famille, attendu := range cas { diff --git a/test/test_qemu_cache_charge.py b/test/test_qemu_cache_charge.py index a68f18b..609d919 100644 --- a/test/test_qemu_cache_charge.py +++ b/test/test_qemu_cache_charge.py @@ -366,5 +366,67 @@ class TestPlusieursSystemes(unittest.TestCase): self.assertEqual(appels, [("ubuntu", DISTROS["ubuntu"][1])]) +class TestUneVmMesureeEstEteinte(unittest.TestCase): + """Deux VM de 8 Gio allumées ensemble, puis trois, épuisent la mémoire de + l'hôte en pleine série : chacune s'éteint dès que sa mesure est faite.""" + + def test_chaque_vm_s_eteint_apres_sa_propre_mesure(self): + import argparse + import tempfile + from unittest import mock + + ordre = [] + args = argparse.Namespace( + dry_run=False, + sans_cache=False, + hors_ligne=False, + distro="debian", + version="12", + charge="erplibre", + ) + with tempfile.TemporaryDirectory() as rep: + rapport = {"_fichier": str(Path(rep) / "r.json"), "vms": []} + with mock.patch.object(QC, "dire"), mock.patch.object( + QC, "noter_uuid" + ), mock.patch.object( + QC, "deployer", return_value="10.0.0.1" + ), mock.patch.object( + QC, "attendre_ssh", return_value=True + ), mock.patch.object( + QC, + "poser_les_paquets", + side_effect=lambda *a, **k: ordre.append("paquets") or True, + ), mock.patch.object( + QC, + "eteindre", + side_effect=lambda nom, *a, **k: ordre.append(nom), + ), mock.patch.object( + QC, "verdict", return_value=True + ): + QC._boucle(args, rapport, None, "", 0) + base = QC.base_des_noms(args) + self.assertEqual( + ordre, ["paquets", f"{base}-1", "paquets", f"{base}-2"] + ) + + def test_eteindre_n_efface_rien(self): + from unittest import mock + + with mock.patch.object(QC, "dire") as dit: + QC.eteindre("vm-essai", None, dry_run=True) + annonce = " ".join(str(a) for c in dit.call_args_list for a in c.args) + self.assertIn("destroy vm-essai", annonce) + self.assertNotIn("undefine", annonce) + + def test_a_blanc_rien_n_est_lance(self): + from unittest import mock + + with mock.patch.object(QC, "dire"), mock.patch.object( + QC, "executer" + ) as ex: + QC.eteindre("vm-essai", None, dry_run=True) + ex.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/test/test_qemu_cache_guest.py b/test/test_qemu_cache_guest.py index e1f44e8..a313016 100644 --- a/test/test_qemu_cache_guest.py +++ b/test/test_qemu_cache_guest.py @@ -32,13 +32,15 @@ sys.path.insert(0, str(RACINE)) from script.qemu.deploy_qemu import ( # noqa: E402 CACHE_CERT_NAME, CACHE_ENV_VARS, + CACHE_SUDOERS, CACHE_TRUST, OFFLINE_ENV_VARS, + cache_commands, cache_env_reload, cache_family, cache_files, - cache_commands, cache_runcmd, + commande_sudoers, langue_des_messages, ) @@ -134,6 +136,10 @@ class TestRuncmd(unittest.TestCase): self.tmp = tempfile.TemporaryDirectory() self.lignes = cache_runcmd(faux_args(Path(self.tmp.name), "arch")) + # Les écritures de variables, sans la confiance ni le sudoers. + self.variables = [ + l for l in self.lignes[1:] if "/etc/environment" in l + ] def tearDown(self): self.tmp.cleanup() @@ -159,13 +165,14 @@ class TestRuncmd(unittest.TestCase): def test_les_variables_vont_dans_etc_environment(self): """PAM lit /etc/environment pour TOUTE session ssh, non interactive comprise : c'est la seule voie qui atteint une commande distante.""" - for ligne in self.lignes[1:]: - self.assertIn("/etc/environment", ligne) + self.assertEqual(len(self.variables), len(CACHE_ENV_VARS)) + for var in CACHE_ENV_VARS: + self.assertTrue(any(f"{var}=" in l for l in self.variables), var) def test_ecriture_idempotente(self): """runcmd ne tourne qu'une fois par instance, mais un opérateur peut rejouer la commande : elle ne doit pas empiler les doublons.""" - for ligne in self.lignes[1:]: + for ligne in self.variables: self.assertIn("grep -q", ligne) def test_aucune_commande_ne_peut_faire_echouer_le_boot(self): @@ -258,6 +265,96 @@ class TestLeHorsLigneCoupeLAuditNpm(unittest.TestCase): self.assertIn(var, cache_env_reload()) +class TestLesVariablesTraversentSudo(unittest.TestCase): + """sudo remet l'environnement à zéro : sans « env_keep », une installation + lancée par « sudo npm » rejette l'autorité du cache là où PAM ne relit pas + /etc/environment pour sudo.""" + + def setUp(self): + import tempfile + + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def commande(self, offline=False, distro="debian"): + args = faux_args(Path(self.tmp.name), distro) + args.offline = offline + lignes = [c for c in cache_commands(args) if "env_keep" in c] + self.assertEqual(len(lignes), 1, "une seule écriture du sudoers") + return lignes[0] + + def test_chaque_variable_du_cache_est_gardee(self): + commande = self.commande() + for var in CACHE_ENV_VARS: + self.assertIn(var, commande) + self.assertNotIn("NPM_CONFIG_AUDIT", commande) + + def test_hors_ligne_l_audit_coupe_traverse_aussi(self): + for var, _ in OFFLINE_ENV_VARS: + self.assertIn(var, self.commande(offline=True)) + + def test_vient_apres_les_variables(self): + """La confiance reste la première commande ; le sudoers suit.""" + args = faux_args(Path(self.tmp.name), "debian") + commandes = cache_commands(args) + self.assertIn("env_keep", commandes[-1]) + + def test_le_fichier_ecrit_est_celui_que_sudo_lit(self): + """sudo ignore un nom de sudoers.d qui porte un point : c'est ce qui + rend le temporaire inoffensif, et interdit ce point au nom final.""" + nom_final = CACHE_SUDOERS.rsplit("/", 1)[1] + self.assertNotIn(".", nom_final) + self.assertTrue(CACHE_SUDOERS.startswith("/etc/sudoers.d/")) + + def test_le_fichier_est_verifie_avant_d_etre_pose(self): + commande = commande_sudoers(["A"], "/etc/sudoers.d/essai") + self.assertLess( + commande.index("visudo -cf"), + commande.index("mv /etc/sudoers.d/.essai /etc/sudoers.d/essai"), + ) + self.assertIn("chmod 0440", commande) + self.assertTrue( + commande.rstrip("'").endswith("|| rm -f /etc/sudoers.d/.essai") + ) + + def test_le_contenu_ecrit_est_une_ligne_par_variable(self): + """Joué dans un vrai shell, visudo remplacé : c'est le fichier produit + qui compte, et son absence quand la vérification échoue.""" + import subprocess + import tempfile + + with tempfile.TemporaryDirectory() as rep: + bin_ = Path(rep) / "bin" + bin_.mkdir() + dossier = Path(rep) / "sudoers.d" + dossier.mkdir() + for verdict, attendu in (("0", True), ("1", False)): + faux = bin_ / "visudo" + faux.write_text(f"#!/bin/sh\nexit {verdict}\n") + faux.chmod(0o755) + fichier = dossier / "erplibre-cache" + commande = commande_sudoers( + ["PIP_CERT", "NODE_EXTRA_CA_CERTS"], str(fichier) + ) + res = subprocess.run( + ["sh", "-c", commande], + capture_output=True, + text=True, + env={"PATH": f"{bin_}:/usr/bin:/bin"}, + ) + with self.subTest(visudo=verdict): + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(fichier.exists(), attendu) + self.assertFalse((dossier / ".erplibre-cache").exists()) + if attendu: + self.assertEqual( + fichier.read_text(), + "Defaults env_keep += PIP_CERT\n" + "Defaults env_keep += NODE_EXTRA_CA_CERTS\n", + ) + fichier.unlink() + + class TestLaLangueDesMessagesDuCache(unittest.TestCase): def test_l_option_du_deploiement_l_emporte(self): self.assertEqual(