[ADD] déploiement qemu : couper l'audit npm, traduire l'exception

L'audit de npm interroge un service qu'aucun cache ne rejoue : amont coupé,
il échoue à chaque installation sans rien vérifier. Une VM déployée hors
ligne reçoit donc NPM_CONFIG_AUDIT=false, écrit dans /etc/environment et
relu après cloud-init ; une VM en ligne garde son audit. Le retrait d'une
exception du détournement parle désormais la langue du menu, son message
allant à l'erreur standard tandis que la sortie standard garde les règles
que nft lit, qui ne se traduisent jamais.

--- EN ---

The npm audit queries a service no cache can replay: upstream cut, it fails
every install without checking anything. A VM deployed offline therefore gets
NPM_CONFIG_AUDIT=false, written in /etc/environment and read back after
cloud-init; an online VM keeps its audit. Removing a redirection exception
now speaks the menu's language, its message going to standard error while
standard output keeps the rules nft reads, which are never translated.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-16 03:00:17 -04:00
parent 90ef1d24e7
commit 4b60dcba30
9 changed files with 218 additions and 6 deletions

View file

@ -622,9 +622,13 @@ def deployer(
distro=DISTRO,
version=VERSION,
charge="minimum",
hors_ligne=False,
):
"""Une VM Arch, branchée sur le cache ou non. Rend son adresse, ou ''.
`hors_ligne` : la VM naît l'amont du cache coupé. Le déploiement y
désactive ce qu'aucun cache ne rejoue, comme le ferait le formulaire.
Sans le cache, la VM télécharge en direct : c'est le TÉMOIN, la mesure de
ce que coûte une installation quand rien n'est gardé. Un gain ne veut rien
dire sans lui.
@ -645,6 +649,7 @@ def deployer(
f" --ssh-key {shlex.quote(cle_publique())}"
+ (
f" --cache-ca {shlex.quote(CA)}"
+ (" --offline" if hors_ligne else "")
if avec_cache
else " --cache-bypass"
)
@ -949,6 +954,7 @@ def contre_epreuve(
distro=distro,
version=version,
charge=charge,
hors_ligne=True,
)
if not adresse:
return False

View file

@ -2523,6 +2523,14 @@ CACHE_TRUST = {
# disparaît alors que la VM garde sa variable.
CACHE_ENV_VARS = ("PIP_CERT", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS")
# Ce qu'une VM déployée l'amont du cache coupé reçoit en plus. L'audit de npm
# interroge un service distant qu'aucun cache ne peut rejouer : hors ligne il
# échoue à chaque installation sans rien vérifier. La variable reste dans la
# VM après son installation ; un « npm config set audit true » la contredit.
# Séparée de CACHE_ENV_VARS, que le binaire du cache recopie et que le test
# d'accord avec le Go compare.
OFFLINE_ENV_VARS = (("NPM_CONFIG_AUDIT", "false"),)
CACHE_CERT_NAME = "erplibre-cache.crt"
@ -2667,6 +2675,9 @@ def cache_bypass_apply(args: argparse.Namespace, runner: Runner) -> str:
geste = (
f"{shlex.quote(CACHE_BIN)} --bypass-add {shlex.quote(mac)}"
f" --bypass-name {shlex.quote(args.name)}"
# La commande passe par sudo, qui retire EL_LANG : la langue du
# message du binaire va donc en option.
f" --lang {shlex.quote(langue_des_messages(args))}"
)
if shutil.which("nft"):
# Le binaire écrit le fichier et rend sur sa sortie le geste à chaud.
@ -2681,6 +2692,20 @@ def cache_bypass_apply(args: argparse.Namespace, runner: Runner) -> str:
return mac
def langue_des_messages(args: argparse.Namespace) -> str:
"""La langue des messages : --lang du déploiement, sinon celle de todo.py,
sinon le français. Rend toujours « fr » ou « en »."""
langue = (getattr(args, "lang", "") or "").strip().lower()
if not langue:
try:
from script.todo.todo_i18n import get_lang
langue = get_lang()
except Exception:
langue = "fr"
return "en" if langue.startswith("en") else "fr"
def cache_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]:
"""L'autorité du cache, posée par cloud-init à l'étape init.
@ -2742,6 +2767,12 @@ def cache_commands(args: argparse.Namespace) -> list[str]:
f"sh -c 'grep -q ^{var}= /etc/environment"
f" || echo {var}={faisceau} >> /etc/environment'"
)
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'"
)
return commandes
@ -2765,7 +2796,7 @@ def cache_env_reload(fichier: str = "/etc/environment") -> str:
séparateur final, sans effet quand le fichier est absent ou ne les porte
pas, et qui ne fait pas échouer une commande sous « set -e ».
"""
motif = "|".join(CACHE_ENV_VARS)
motif = "|".join(CACHE_ENV_VARS + tuple(v for v, _ in OFFLINE_ENV_VARS))
return (
f'if [ -r {fichier} ]; then eval "$(grep -E "^({motif})=" {fichier}'
' | sed "s/^/export /")"; fi'
@ -4479,6 +4510,13 @@ def build_parser() -> argparse.ArgumentParser:
"cette autorité dès son premier démarrage et ses téléchargements "
"passent par le cache. Absent, rien n'est posé.",
)
g_cloud.add_argument(
"--offline",
action="store_true",
help="La VM est déployée l'amont du cache coupé : ce qu'aucun cache ne "
"peut rejouer y est désactivé, l'audit de npm d'abord. Sans effet "
"sans --cache-ca.",
)
g_cloud.add_argument(
"--cache-bypass",
action="store_true",

View file

@ -1690,9 +1690,12 @@ class ProxmoxMenuMixin:
return ""
return self._qemu_cache_ca_path()
def _pve_set_cache_ca(self, cible, vm, ca):
def _pve_set_cache_ca(self, cible, vm, ca, hors_ligne=False):
"""Pose l'autorité du cache DANS la VM, par ssh.
`hors_ligne` : l'amont du cache est coupé pour ce déploiement ; la VM
reçoit en plus ce que la voie libvirt pose sous « --offline ».
Même source que la voie libvirt — `cache_files` et `cache_commands` de
deploy_qemu — livrée autrement : « qm set » ne sait écrire aucun
fichier, comme pour le guide et pour le fuseau.
@ -1707,7 +1710,10 @@ class ProxmoxMenuMixin:
except Exception: # pragma: no cover - dépend du module
return False
args = types.SimpleNamespace(
distro=vm.get("distro") or "", cache_ca=ca, cache_bypass=False
distro=vm.get("distro") or "",
cache_ca=ca,
cache_bypass=False,
offline=hors_ligne,
)
fichiers = mod.cache_files(args)
if not fichiers:
@ -2122,7 +2128,9 @@ class ProxmoxMenuMixin:
# range ses index sous l'hôte demandé, et une VM qui en
# réclame un autre ne retrouve rien de ce qui est gardé.
self._pve_set_apt_mirror(vm["alias"], vm, mod_qemu)
self._pve_set_cache_ca(vm["alias"], vm, ca_cache)
self._pve_set_cache_ca(
vm["alias"], vm, ca_cache, hors_ligne=bool(coupee)
)
# Après la création, qui a posé l'écran accéléré : l'accès au
# nœud de rendu est une affaire de COMPTE, et il se donne
# dans l'invité.

View file

@ -1114,6 +1114,7 @@ class QemuDeployMixin:
git_email="",
cache_ca="",
cache_bypass=False,
offline=False,
):
"""Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu
dry-run ET le déploiement réel)."""
@ -1163,6 +1164,10 @@ class QemuDeployMixin:
# l'approuve dès son premier démarrage, sans quoi le détournement
# lui présente un certificat qu'elle rejette.
parts += ["--cache-ca", cache_ca]
if offline:
# L'amont du cache sera coupé : ce qu'aucun cache ne rejoue,
# l'audit de npm d'abord, est désactivé dans la VM.
parts.append("--offline")
# L'identité git de la VM. Sans ces options, deploy_qemu recopie celle
# de l'HÔTE : le formulaire la montre et permet de la changer, il ne
# la remplace pas par du vide.
@ -1259,6 +1264,7 @@ class QemuDeployMixin:
self._qemu_cache_ca_path() if self._qemu_cache_active() else ""
),
cache_bypass=bool(spec.get("cache_bypass")),
offline=bool(spec.get("offline")),
)
# Où l'installateur du cache pose son autorité. Un test compare cette

View file

@ -1262,7 +1262,7 @@ class TestLeMiroirAptDesVmProxmox(unittest.TestCase):
)
self.assertLess(
src.index('self._pve_set_apt_mirror(vm["alias"]'),
src.index('self._pve_set_cache_ca(vm["alias"]'),
src.index("self._pve_set_cache_ca("),
)
@ -2438,7 +2438,7 @@ class TestLAutoriteDuCacheDansUneVmImbriquee(unittest.TestCase):
todo._pve_write_guide = lambda *a, **k: True
todo._pve_set_timezone = lambda *a, **k: True
todo._qemu_import_module = lambda: None
todo._pve_set_cache_ca = lambda cible, vm, ca: ordre.append(
todo._pve_set_cache_ca = lambda cible, vm, ca, **k: ordre.append(
("autorité", cible, ca)
)
todo._qemu_install_erplibre_monitored = lambda *a, **k: ordre.append(
@ -2511,5 +2511,46 @@ class TestLAutoriteDuCacheDansUneVmImbriquee(unittest.TestCase):
self.assertEqual(appels, [])
class TestLAutoriteDUneVmProxmoxHorsLigne(unittest.TestCase):
"""Une VM Proxmox déployée hors ligne reçoit, comme la voie libvirt, de quoi
couper l'audit de npm : aucun cache ne rejoue ce service."""
def test_seule_la_vm_hors_ligne_coupe_l_audit(self):
import contextlib
import io
import sys
import tempfile
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
vu = {}
def faux_ssh(cible, cmd, timeout=120):
vu["cmd"] = cmd
return 0, ""
todo._pve_ssh = faux_ssh
with tempfile.NamedTemporaryFile(
"w", suffix=".crt", delete=False
) as fh:
fh.write("-----BEGIN CERTIFICATE-----\nZm F1eA==\n")
fh.write("-----END CERTIFICATE-----\n")
ca = fh.name
rendus = {}
with contextlib.redirect_stdout(io.StringIO()):
for hors_ligne in (True, False):
todo._pve_set_cache_ca(
"hote+vm-a",
{"name": "vm-a", "distro": "ubuntu"},
ca,
hors_ligne=hors_ligne,
)
rendus[hors_ligne] = vu.get("cmd", "")
self.assertIn("NPM_CONFIG_AUDIT", rendus[True])
self.assertNotIn("NPM_CONFIG_AUDIT", rendus[False])
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -118,6 +118,23 @@ class TestLExceptionEstPoseeAvant(unittest.TestCase):
self.assertTrue(pose, "l'exception n'a pas été posée")
self.assertIn("nft -f -", " ".join(pose[0]))
def test_le_message_du_binaire_suit_la_langue(self):
"""La commande passe par sudo, qui retire EL_LANG : sans l'option, le
binaire répondrait en français à qui déploie en anglais."""
args = args_neufs()
args.lang = "en"
runner = FauxRunner({"is-active": (0, "")})
with mock.patch.object(
deploy_qemu.os.path, "isfile", return_value=True
), mock.patch.object(
deploy_qemu.shutil, "which", return_value="/usr/bin/nft"
), mock.patch.object(
deploy_qemu, "macs_deja_prises", return_value=set()
):
deploy_qemu.cache_bypass_apply(args, runner)
pose = [c for c in runner.commandes if "--bypass-add" in " ".join(c)]
self.assertIn("--lang en", " ".join(pose[0]))
def test_sans_cache_installe_rien_nest_pose(self):
"""Exiger un cache pour pouvoir s'en passer n'aurait aucun sens :
sans lui, rien n'intercepte."""

View file

@ -250,5 +250,37 @@ class TestLeRapportSeClotSurUnEchec(unittest.TestCase):
self.assertEqual(len(ecrit["vms"]), 1)
class TestLaTroisiemeVmNaitHorsLigne(unittest.TestCase):
"""Le déploiement coupe l'audit de npm d'une VM hors ligne : le test long
doit le lui demander, comme le formulaire, sans quoi il mesure une VM que
personne ne déploierait ainsi."""
def commande(self, **kw):
from unittest import mock
with mock.patch.object(QC, "dire") as dit, mock.patch.object(
QC, "cle_publique", return_value="/tmp/cle.pub"
):
QC.deployer("vm", None, dry_run=True, **kw)
return " ".join(str(a) for c in dit.call_args_list for a in c.args)
def test_la_vm_hors_ligne_recoit_offline(self):
self.assertIn("--offline", self.commande(hors_ligne=True))
def test_les_vm_en_ligne_n_en_recoivent_pas(self):
self.assertNotIn("--offline", self.commande())
def test_le_temoin_sans_cache_n_en_recoit_pas(self):
self.assertNotIn(
"--offline", self.commande(avec_cache=False, hors_ligne=True)
)
def test_la_contre_epreuve_le_demande(self):
import inspect
source = inspect.getsource(QC.contre_epreuve)
self.assertIn("hors_ligne=True", source)
if __name__ == "__main__":
unittest.main()

View file

@ -121,6 +121,25 @@ class TestCommandeProduite(unittest.TestCase):
todo._qemu_cache_active = lambda: actif and bool(ca)
return todo._qemu_deploy_parts_for(self.vm(), spec, dry_run=True)
def test_hors_ligne_le_drapeau_offline(self):
"""Hors ligne, la VM reçoit de quoi couper l'audit de npm."""
parts = self.parts(
{"install": None, "offline": True}, ca="/tmp/essai-ca.crt"
)
self.assertIn("--offline", parts)
def test_en_ligne_aucun_drapeau_offline(self):
parts = self.parts({"install": None}, ca="/tmp/essai-ca.crt")
self.assertNotIn("--offline", parts)
def test_une_vm_exceptee_n_a_pas_de_drapeau_offline(self):
"""Elle ne traverse pas le cache : rien à couper chez elle."""
parts = self.parts(
{"install": None, "offline": True, "cache_bypass": True},
ca="/tmp/essai-ca.crt",
)
self.assertNotIn("--offline", parts)
def test_service_arrete_aucun_drapeau(self):
"""Sans interception, l'autorité n'a rien à faire dans la VM."""
parts = self.parts({"install": None}, ca="/tmp/ca.crt", actif=False)

View file

@ -33,10 +33,13 @@ from script.qemu.deploy_qemu import ( # noqa: E402
CACHE_CERT_NAME,
CACHE_ENV_VARS,
CACHE_TRUST,
OFFLINE_ENV_VARS,
cache_env_reload,
cache_family,
cache_files,
cache_commands,
cache_runcmd,
langue_des_messages,
)
RULES_GO = RACINE / "script" / "qemu_cache" / "rules.go"
@ -228,6 +231,48 @@ class TestLesVariablesRelues(unittest.TestCase):
self.assertEqual(code, 0, err)
class TestLeHorsLigneCoupeLAuditNpm(unittest.TestCase):
"""Hors ligne, l'audit de npm interroge un service qu'aucun cache ne rejoue."""
def setUp(self):
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
def commandes(self, offline):
args = faux_args(Path(self.tmp.name), "ubuntu")
args.offline = offline
return "\n".join(cache_commands(args))
def test_une_vm_hors_ligne_recoit_l_audit_coupe(self):
self.assertIn("NPM_CONFIG_AUDIT=false", self.commandes(True))
def test_une_vm_en_ligne_garde_son_audit(self):
self.assertNotIn("NPM_CONFIG_AUDIT", self.commandes(False))
def test_la_variable_est_relue_apres_cloud_init(self):
"""Le « npm install » lancé sans sudo vit dans une session ouverte
avant que cloud-init n'écrive la variable."""
for var, _ in OFFLINE_ENV_VARS:
self.assertIn(var, cache_env_reload())
class TestLaLangueDesMessagesDuCache(unittest.TestCase):
def test_l_option_du_deploiement_l_emporte(self):
self.assertEqual(
langue_des_messages(argparse.Namespace(lang="en")), "en"
)
self.assertEqual(
langue_des_messages(argparse.Namespace(lang="FR")), "fr"
)
def test_une_valeur_inconnue_rend_le_francais(self):
self.assertEqual(
langue_des_messages(argparse.Namespace(lang="de")), "fr"
)
class TestAccordAvecLeGo(unittest.TestCase):
"""La table Python et la table Go doivent dire la même chose.