[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:
parent
f20e21b7d2
commit
15477d52ac
6 changed files with 236 additions and 18 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue