diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 6e171db..e7d4fd3 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -37,6 +37,9 @@ class ProxmoxMenuMixin: # la machine locale. Il faut donc d'abord SAVOIR OÙ, et le retenir — sans # quoi chacune des dix-sept commandes reposerait la question. _PVE_PREF_KEY = "proxmox_host" + # Le script qui transforme une Debian en hyperviseur. Autonome : il se + # laisse exécuter par un tube, sans être copié d'abord. + PVE_INSTALL_SCRIPT = "script/proxmox/install_proxmox.sh" def _pve_host(self, ask=True): """Hôte Proxmox retenu, ou None. Demande au besoin. @@ -170,6 +173,45 @@ class ProxmoxMenuMixin: or "no ed25519 host key is known" in bas ) + @staticmethod + def _pve_clean_output(sortie): + """Les lignes de la sortie qui APPRENNENT quelque chose. + + « Warning: Permanently added … to the list of known hosts » arrive sur + stderr à chaque connexion d'un hôte en UserKnownHostsFile=/dev/null. + Affichée comme preuve d'un échec, elle envoyait chercher du côté de la + clé d'hôte un problème qui n'avait rien à voir — rapporté. + """ + gardees = [] + for ligne in (sortie or "").splitlines(): + nue = ligne.strip() + if not nue or nue.startswith("Warning: Permanently added"): + continue + gardees.append(nue) + return gardees + + def _pve_ssh_alive(self, host): + """(ssh passe-t-il ?, ce qu'il a dit) — sans rien exiger de la machine. + + C'est la question qu'il fallait poser AVANT de conclure : une machine + qui répond mais n'a pas Proxmox n'est pas « injoignable », et les deux + pannes ne se corrigent pas du même côté.""" + from script.proxmox import proxmox_deploy as pve + + code, out = pve.run(host, "true", timeout=20) + lignes = self._pve_clean_output(out) + return code == 0, (lignes[0] if lignes else t("no answer")) + + def _pve_install_hint(self, host): + """La commande qui poserait Proxmox VE sur cette machine. + + Le script du dépôt, poussé par le tube : il est autonome, donc + « bash -s » suffit et il n'y a rien à copier d'abord.""" + return ( + f"cat {self.PVE_INSTALL_SCRIPT} | " + f"ssh {host['target']} sudo bash -s" + ) + def _pve_add_hostkey(self, host): """Enregistre la clé d'hôte, après accord explicite. @@ -230,12 +272,31 @@ class ProxmoxMenuMixin: code, out = pve.run(host, "pveversion", timeout=30) version = pve.parse_pveversion(out) if not version: - print(f" ✗ {t('Not a Proxmox host (or unreachable):')}") - premiere = (out or "").strip().splitlines() - print(f" {premiere[0] if premiere else t('no answer')}") - print( - f" → {t('Check the address, the SSH access and pveversion.')}" - ) + # Un seul message confondait deux pannes : « ou il est + # injoignable » envoyait vérifier le réseau alors que la machine + # répondait, et la seule ligne montrée était l'avertissement de + # ssh sur la clé d'hôte. On demande donc à ssh s'il passe. + joignable, detail = self._pve_ssh_alive(host) + # Ce que « pveversion » a répondu, et non ce que la sonde a dit : + # « command not found » est LA preuve utile. + dit = self._pve_clean_output(out) + if joignable: + print(f" ✗ {t('Reachable, but Proxmox VE is not there:')}") + print( + f" ssh {host['target']} : ok — pveversion : " + f"{dit[0] if dit else t('absent')}" + ) + print(f" → {t('Install it:')}") + print(f" {self._pve_install_hint(host)}") + print( + f" → {t('Or redeploy the VM with the hypervisor profile.')}" + ) + else: + print(f" ✗ {t('SSH does not get through:')}") + print(f" {detail}") + print( + f" → {t('Check the address, the SSH access and pveversion.')}" + ) return None # « qm » exige les privilèges. La voie « VM QEMU locale » donne # l'accès d'erplibre, pas de root : il faut donc sudo, et il faut le diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 1d597b6..0acd13e 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3206,6 +3206,22 @@ TRANSLATIONS = { "fr": "Ce n'est pas un hôte Proxmox (ou il est injoignable) :", "en": "Not a Proxmox host (or unreachable):", }, + "Reachable, but Proxmox VE is not there:": { + "fr": "Machine joignable, mais Proxmox VE n'y est pas :", + "en": "Reachable, but Proxmox VE is not there:", + }, + "SSH does not get through:": { + "fr": "SSH ne passe pas :", + "en": "SSH does not get through:", + }, + "Install it:": { + "fr": "Pour l'installer :", + "en": "Install it:", + }, + "Or redeploy the VM with the hypervisor profile.": { + "fr": "Ou redéployer la VM avec le profil hyperviseur.", + "en": "Or redeploy the VM with the hypervisor profile.", + }, "Check the address, the SSH access and pveversion.": { "fr": "Vérifier l'adresse, l'accès SSH et pveversion.", "en": "Check the address, the SSH access and pveversion.", diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 6f3cdbe..0211c88 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -36,6 +36,12 @@ from script.todo.todo import TODO # noqa: E402 PVEVERSION = ( "pve-manager/9.2.11/f6997e698c7933ea (running kernel: 7.0.14-12-pve)" ) +# Ce que ssh écrit sur stderr à chaque connexion d'un hôte en +# UserKnownHostsFile=/dev/null. Ce n'est pas un diagnostic. +AVERTISSEMENT = ( + "Warning: Permanently added '192.168.123.227' (ED25519) to the list " + "of known hosts.\n" +) QM_LIST = """ VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID 100 vm-essai running 2048 16.00 2726 101 avec un espace stopped 4096 32.00 0 @@ -334,10 +340,62 @@ class TestChoixDeLHote(unittest.TestCase): return host, out.getvalue() def test_a_non_proxmox_host_is_refused_with_what_was_seen(self): - host, sortie = self._confirm([(127, "bash: pveversion: not found")]) + # Deux appels : « pveversion », puis la sonde qui demande à ssh s'il + # passe — c'est elle qui distingue les deux pannes. + host, sortie = self._confirm( + [(127, "bash: pveversion: not found"), (0, "")] + ) self.assertIsNone(host) self.assertIn("pveversion", sortie) + def test_a_reachable_machine_without_proxmox_says_exactly_that(self): + """Le cas rapporté : « je n'arrive pas à me connecter, pourtant il est + accessible ». La machine répondait ; c'est Proxmox qui manquait, et le + message parlait d'injoignabilité.""" + host, sortie = self._confirm( + [ + (127, AVERTISSEMENT + "bash: pveversion: command not found"), + (0, AVERTISSEMENT), + ] + ) + self.assertIsNone(host) + self.assertIn("joignable", sortie.lower()) + self.assertIn("install_proxmox.sh", sortie) + # Et surtout : ne plus envoyer chercher un problème de réseau. + self.assertNotIn("SSH ne passe pas", sortie) + + def test_an_unreachable_machine_says_ssh_does_not_get_through(self): + panne = "ssh: connect to host 10.0.0.9 port 22: No route to host" + host, sortie = self._confirm([(255, panne), (255, panne)]) + self.assertIsNone(host) + self.assertIn("No route to host", sortie) + self.assertNotIn("install_proxmox.sh", sortie) + + def test_the_ssh_key_warning_is_never_shown_as_the_error(self): + # Affichée comme preuve, elle envoyait chercher un problème de clé + # d'hôte qui n'existait pas — c'est ce qu'on voyait dans le rapport. + host, sortie = self._confirm( + [(127, AVERTISSEMENT), (0, AVERTISSEMENT)] + ) + self.assertIsNone(host) + self.assertNotIn("Permanently added", sortie) + + def test_only_the_lines_that_teach_something_are_kept(self): + self.assertEqual( + TODO._pve_clean_output( + AVERTISSEMENT + "\nbash: pveversion: command not found\n" + ), + ["bash: pveversion: command not found"], + ) + self.assertEqual(TODO._pve_clean_output(AVERTISSEMENT), []) + self.assertEqual(TODO._pve_clean_output(""), []) + + def test_the_install_hint_pipes_the_repo_script(self): + # Le script est autonome : « bash -s » suffit, rien à copier d'abord. + indice = self._todo()._pve_install_hint({"target": "pve1"}) + self.assertIn(TODO.PVE_INSTALL_SCRIPT, indice) + self.assertIn("ssh pve1 sudo bash -s", indice) + def test_a_non_root_access_gets_sudo(self): """C'est le cas de la voie « VM QEMU locale » : cloud-init crée erplibre, pas root."""