[FIX] déploiement et mesure : openSUSE, sudo, console, mémoire

Quatre défauts qu'une campagne complète sur les sept systèmes du catalogue a
mis au jour. La famille zypper visait un faisceau de certificats qu'openSUSE
n'écrit pas, et pip s'arrêtait là, en ligne comme hors ligne. sudo remet
l'environnement à zéro : sans env_keep, une installation lancée par sudo
rejette l'autorité du cache là où PAM ne relit pas /etc/environment. Le
journal de console n'était gardé que pour la voie installateur, si bien
qu'une image cloud bloquée avant ssh ne laissait rien à lire. Et une VM de
8 Gio restait allumée après sa mesure, épuisant la mémoire de l'hôte.

--- EN ---

Four faults a full campaign over the catalogue's seven systems brought out.
The zypper family pointed at a certificate bundle openSUSE does not write,
and pip stopped there, online as well as offline. sudo resets the
environment: without env_keep, an install run by sudo rejects the cache
authority where PAM does not reread /etc/environment. The console log was
kept for the installer path only, so a cloud image stuck before ssh left
nothing to read. And an 8 GiB VM stayed up after its measurement, exhausting
the host's memory.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-16 03:00:18 -04:00
parent f20e21b7d2
commit 15477d52ac
6 changed files with 236 additions and 18 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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
}

View file

@ -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 {

View file

@ -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()

View file

@ -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(