diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 9dd0c07..ac68eef 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -1080,6 +1080,94 @@ class ProxmoxMenuMixin: fh.write("\n".join(entete) + "\n") return chemin + def _pve_write_guide(self, cible, vm, spec, mod): + """Pose le guide de connexion et l'identité git DANS la VM. + + La voie libvirt les livre par le « write_files » de cloud-init ; + « qm set » n'offre pas cela, donc une VM Proxmox n'avait AUCUN guide — + quelle que soit sa distribution. Rapporté sur Arch. + + Même contenu, livrée par ssh une fois la VM debout : `guide_files` est + la source unique, comme sa docstring le promet. Un seul appel, tous les + fichiers. + """ + import types + + from script.todo.todo_i18n import get_lang + + install = spec.get("install") or {} + cmd_install = vm.get("install_cmd") or install.get("cmd") or "" + args = types.SimpleNamespace( + distro=vm.get("distro") or "", + version=vm.get("version") or "", + arch=vm.get("arch") or "amd64", + lang=get_lang(), + # La section ERPLibre n'apparaît que si ERPLibre y sera : un guide + # qui annonce un dépôt absent est un guide qui ment. + erplibre_dir=( + self._qemu_guide_dir(False) + if self._qemu_installs_erplibre( + install.get("branch"), cmd_install + ) + else "" + ), + erplibre_make=self._qemu_make_target(cmd_install), + desktop=bool(vm.get("desktop")), + no_git_identity=False, + user=spec.get("user") or "erplibre", + ) + try: + fichiers = mod.guide_files(args) + except Exception as exc: # pragma: no cover - dépend du module + print(f" ⚠ {t('guide not written')} : {exc}") + return False + morceaux = [] + for chemin, mode, contenu, proprio in fichiers: + q = shlex.quote(chemin) + morceaux.append( + f"printf '%s' {shlex.quote(contenu)} | sudo tee {q} " + f">/dev/null && sudo chmod {mode} {q}" + ) + if proprio: + morceaux.append(f"sudo chown {shlex.quote(proprio)}: {q}") + code, _o = self._pve_ssh(cible, " && ".join(morceaux)) + if code: + print(f" ⚠ {t('guide not written')} ({code})") + return False + print(f" ✓ {t('connection guide written')}") + return True + + @staticmethod + def _pve_ssh(cible, remote, timeout=60): + """(code, sortie) d'une commande exécutée DANS la VM, par son alias. + + Par l'alias et non par l'adresse : lui seul porte le rebond vers le + réseau interne de l'hôte.""" + from script.proxmox import proxmox_deploy as pve + + argv = [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=10", + cible, + remote, + ] + try: + res = subprocess.run( + argv, capture_output=True, text=True, timeout=timeout + ) + except (OSError, subprocess.SubprocessError) as exc: + return 255, str(exc) + return res.returncode, pve.strip_ssh_noise( + (res.stdout or "") + (res.stderr or "") + ) + def _pve_print_summary(self, spec, joignables, session): """Sommaire final : ce qui existe, où, et comment y entrer. @@ -1144,6 +1232,10 @@ class ProxmoxMenuMixin: # Les domaines LOCAUX : un nom partagé avec l'un d'eux fait dérailler # l'alias ssh et le suivi d'installation. locaux = set(self._qemu_list_domains()) + try: + mod_qemu = self._qemu_import_module() + except Exception: + mod_qemu = None def alias_chaine(nom): """« hôte+vm », la convention déjà utilisée pour les VM @@ -1208,6 +1300,10 @@ class ProxmoxMenuMixin: print(f" ✓ ~/.ssh/config : ssh {noms_alias[-1]}") vm["adresse"] = ip vm["alias"] = alias.get(vm["name"], vm["name"]) + # Le guide AVANT l'installation : il doit être là même si rien ne + # s'installe, et l'installation ne le touche pas. + if vm["alias"] and mod_qemu: + self._pve_write_guide(vm["alias"], vm, spec, mod_qemu) joignables.append(vm) install = spec.get("install") # Rendu à l'appelant pour son sommaire : lui seul sait ce qui a été diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 7ed7c8b..3046315 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3986,6 +3986,14 @@ TRANSLATIONS = { "fr": "Fichiers orphelins :", "en": "Orphan files:", }, + "connection guide written": { + "fr": "guide de connexion écrit (/etc/motd)", + "en": "connection guide written (/etc/motd)", + }, + "guide not written": { + "fr": "guide non écrit", + "en": "guide not written", + }, "Quit (q) to start the ERPLibre install": { "fr": "Quitter (q) pour lancer l'installation d'ERPLibre", "en": "Quit (q) to start the ERPLibre install", diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index 34f4f9b..c418a83 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -519,6 +519,97 @@ class TestLInterpretePython(unittest.TestCase): self.assertEqual(self._ecran(mise_arches=("s390x",))["choix"], "") +class TestLeGuideDeConnexion(unittest.TestCase): + """Une VM Proxmox n'avait AUCUN guide, quelle que soit sa distribution. + + Rapporté sur Arch : « pas l'écran de connexion, avec le guide qui dit de + prendre pacman, comme sur ubuntu ». La voie libvirt livre /etc/motd par le + « write_files » de cloud-init ; « qm set » n'offre pas cela. Le contenu + vient de la MÊME source (`guide_files`) et part par ssh. + """ + + def _ecrit(self, vm=None, install=None, distro="arch"): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + vus = {} + todo._pve_ssh = lambda cible, remote, timeout=60: ( + vus.update(cible=cible, remote=remote) or (0, "") + ) + mod = todo._qemu_import_module() + vm = vm or { + "name": "vm-a", + "distro": distro, + "version": "latest", + "arch": "amd64", + "desktop": "", + "install_cmd": "", + } + spec = {"user": "erplibre", "install": install} + import contextlib + import io + + with contextlib.redirect_stdout(io.StringIO()): + ok = todo._pve_write_guide("hote+vm-a", vm, spec, mod) + vus["ok"] = ok + return vus + + def test_the_guide_goes_to_etc_motd_through_the_alias(self): + vus = self._ecrit() + self.assertTrue(vus["ok"]) + # Par l'ALIAS : lui seul porte le rebond vers le réseau interne. + self.assertEqual(vus["cible"], "hote+vm-a") + self.assertIn("/etc/motd", vus["remote"]) + self.assertIn("sudo tee", vus["remote"]) + + def test_an_arch_vm_is_told_about_pacman(self): + self.assertIn("pacman", self._ecrit(distro="arch")["remote"]) + + def test_a_debian_vm_is_told_about_apt(self): + self.assertIn("apt", self._ecrit(distro="debian")["remote"]) + + def test_without_erplibre_the_guide_does_not_promise_a_repository(self): + # Un guide qui annonce un dépôt absent est un guide qui mente. + sans = self._ecrit(install=None)["remote"] + self.assertNotIn("git/erplibre", sans) + + def test_with_erplibre_it_says_where_it_lives(self): + avec = self._ecrit( + install={ + "branch": "develop", + "cmd": "make install_os && make install_odoo_18", + } + )["remote"] + self.assertIn("git/erplibre", avec) + + def test_a_failure_is_said_not_swallowed(self): + import contextlib + import io + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + todo._pve_ssh = lambda *a, **k: (255, "no route") + mod = todo._qemu_import_module() + vm = { + "name": "vm-a", + "distro": "arch", + "version": "latest", + "arch": "amd64", + "desktop": "", + "install_cmd": "", + } + with contextlib.redirect_stdout(io.StringIO()) as sortie: + ok = todo._pve_write_guide("x", vm, {"user": "erplibre"}, mod) + self.assertFalse(ok) + self.assertIn("⚠", sortie.getvalue()) + + class TestLeSuivi(unittest.TestCase): """La case « Suivre l'installation » doit commander quelque chose.