[FIX] proxmox : distinguer « injoignable » de « pas Proxmox »

Rapporté : « je n'arrive pas à me connecter, pourtant il est accessible ».
La machine répondait bel et bien — c'est Proxmox VE qui n'y était pas. Un
seul message couvrait les deux pannes, et la seule ligne montrée en preuve
était l'avertissement de ssh sur la clé d'hôte, qui envoyait chercher un
problème de réseau inexistant.

On demande donc à ssh s'il passe avant de conclure, et le bruit de la clé
d'hôte ne sort plus comme diagnostic. Machine joignable sans Proxmox : la
commande qui l'installe est affichée telle quelle. Vérifié sur les deux VM
du parc — l'une répond sans pveversion, l'autre ne répond plus.

--- EN ---

Reported: "I cannot connect, yet it is reachable". The machine did answer —
Proxmox VE simply was not on it. One message covered both failures, and the
only line shown as evidence was ssh's host-key warning, which sent the
reader looking for a network problem that did not exist.

So we now ask ssh whether it gets through before concluding, and the
host-key noise no longer comes out as a diagnosis. Reachable without
Proxmox: the command that installs it is printed as is. Checked against both
VMs here — one answers without pveversion, the other no longer answers.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 01:44:01 -04:00
parent c0d6b114af
commit 009ee6e2f6
3 changed files with 142 additions and 7 deletions

View file

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

View file

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

View file

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