[FIX] sshfs : n'annoncer un montage que s'il a eu lieu

Après un code 1, le menu affichait « Monté sur … », la commande pour démonter
et celle pour ouvrir le répertoire : le montage n'avait pas eu lieu, et on
cherchait des fichiers dans un répertoire vide. Le succès ne s'affiche plus
qu'en cas de succès, et le point de montage inutilisé est retiré.

L'échec, lui, avait une cause : sshfs lit « a+b » comme un chaînage d'hôtes et
ne consulte jamais ~/.ssh/config pour l'alias entier. Or c'est todo.py qui
nomme les VM « rebond+domaine », et cette seconde moitié est un domaine
libvirt, pas un alias SSH du rebond. On résout donc l'alias par « ssh -G » et
on rend à sshfs une cible qu'il ne peut plus mal lire, ProxyJump comprise.

--- EN ---

After exit code 1, the menu still printed "Mounted on …", the unmount command
and the file-manager command: nothing had been mounted, and one went looking
for files in an empty directory. The success block now only prints on success,
and the unused mount point is removed.

The failure itself had a cause: sshfs reads "a+b" as host chaining and never
consults ~/.ssh/config for the whole alias. But todo.py is what names VMs
"jump+domain", and that second half is a libvirt domain, not an SSH alias on
the jump host. So we resolve the alias with "ssh -G" and hand sshfs a target
it can no longer misread, ProxyJump included.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-22 22:32:34 -04:00
parent 6a24687fe0
commit 5da0ebaed3
3 changed files with 692 additions and 31 deletions

View file

@ -9442,6 +9442,214 @@ class TODO:
pass
print(f"\n {t('Tunnel closed.')}")
# sshfs lit « a+b » comme un CHAÎNAGE d'hôtes — « ssh a, puis ssh b depuis
# a » — et ne consulte donc PAS ~/.ssh/config pour l'alias entier. Or c'est
# todo.py qui nomme les VM découvertes « jump+domaine » (voir la marche
# SSH) : ce sont les alias les plus utiles, et les seuls que sshfs échoue à
# monter tel quel. Vécu : « read: Connection reset by peer », parce que la
# seconde moitié du nom est un domaine libvirt, pas un alias SSH du rebond.
SSHFS_CHAIN_SEP = "+"
# Options à rendre à sshfs quand on contourne l'alias : exactement celles
# que todo.py écrit dans l'entrée qu'il génère. Sans elles, une VM dont la
# clé d'hôte a changé — IP DHCP réutilisée — ferait échouer le montage.
SSH_FORWARD_OPTS = (
("port", "Port"),
("proxyjump", "ProxyJump"),
("identityfile", "IdentityFile"),
("identitiesonly", "IdentitiesOnly"),
("stricthostkeychecking", "StrictHostKeyChecking"),
("userknownhostsfile", "UserKnownHostsFile"),
)
# Ce que dit stderr, et ce qu'il faut aller corriger. L'ordre compte : le
# premier motif trouvé gagne.
SSH_FAILURE_HINTS = (
("could not resolve hostname", "unknown host name: check HostName"),
("name or service not known", "unknown host name: check HostName"),
("connection timed out", "no answer: is the server up and reachable?"),
("operation timed out", "no answer: is the server up and reachable?"),
("no route to host", "no route: check the network or the ProxyJump"),
("connection refused", "nothing listening on the SSH port"),
("permission denied", "authentication refused: check User and key"),
("host key verification failed", "host key changed for this address"),
)
@staticmethod
def _ssh_config_entries(path):
"""[(alias, {hostname, user})] de ~/.ssh/config, dans l'ordre du fichier.
Pendant de `_ssh_config_hosts`, qui ne rend que les NOMS : ici le menu
de montage a besoin d'afficher aussi l'adresse et l'utilisateur.
« Host a b » déclare DEUX alias pour la même machine — c'est ce que
todo.py écrit lui-même quand une VM porte plusieurs noms. Les prendre
pour un seul nom donnait un alias « a b », que sshfs ne peut pas
monter. Les motifs génériques (« * », « web-? ») sont écartés : ils ne
désignent aucune machine.
"""
hosts = []
noms = []
info = {}
def clore():
for nom in noms:
hosts.append((nom, dict(info)))
try:
with open(path, encoding="utf-8", errors="replace") as fh:
lignes = fh.readlines()
except OSError:
return []
for ligne in lignes:
ligne = ligne.strip()
if ligne.lower().startswith("host "):
clore()
noms = [
m
for m in ligne.split()[1:]
if "*" not in m and "?" not in m and not m.startswith("!")
]
info = {}
elif noms:
paire = ligne.split(None, 1)
if len(paire) == 2 and paire[0].lower() in (
"hostname",
"user",
):
info[paire[0].lower()] = paire[1].strip()
clore()
return hosts
@staticmethod
def _ssh_resolve(alias):
"""Configuration RÉSOLUE de l'alias, telle que ssh la voit (ssh -G).
On délègue à ssh au lieu de relire le fichier : lui seul connaît les
Include, les Match, l'ordre des motifs et ses propres défauts.
"""
try:
res = subprocess.run(
["ssh", "-G", alias],
capture_output=True,
text=True,
timeout=15,
env=TODO._qemu_c_env(),
)
except (OSError, subprocess.SubprocessError):
return {}
if res.returncode != 0:
return {}
out = {}
for ligne in res.stdout.splitlines():
cle, _, val = ligne.strip().partition(" ")
# ssh -G répète « identityfile » : la PREMIÈRE est celle qui compte.
if cle and val and cle.lower() not in out:
out[cle.lower()] = val
return out
def _sshfs_command(self, alias, mount_point, resolved=None):
"""(commande sshfs, alias contourné ?) pour monter cet alias.
Sans « + » dans le nom, on laisse sshfs faire : c'est ssh qui lit la
config, et rien ne vaut mieux. Avec un « + », on résout l'alias
soi-même et on rend à sshfs une cible qu'il ne peut plus mal lire.
"""
base = "sshfs -o follow_symlinks"
if self.SSHFS_CHAIN_SEP not in alias:
return f"{base} {alias}:/ {mount_point}", False
cfg = resolved if resolved is not None else self._ssh_resolve(alias)
host = cfg.get("hostname")
# Un hostname qui contient encore un « + » ne réglerait rien, et un
# alias non résolu vaut mieux qu'une cible inventée.
if not host or self.SSHFS_CHAIN_SEP in host:
return f"{base} {alias}:/ {mount_point}", False
opts = []
for cle, nom in self.SSH_FORWARD_OPTS:
val = cfg.get(cle)
if val and val.lower() != "none":
opts.append(f"-o {nom}={val}")
user = cfg.get("user")
cible = f"{user}@{host}" if user else host
pieces = [base] + opts + [f"{cible}:/", mount_point]
return " ".join(pieces), True
@classmethod
def _ssh_failure_hint(cls, stderr):
"""Première ligne utile de stderr, et ce qu'elle désigne."""
texte = (stderr or "").lower()
for motif, indice in cls.SSH_FAILURE_HINTS:
if motif in texte:
return indice
return ""
@staticmethod
def _ssh_probe(alias, timeout=8):
"""(code, stderr) d'un « ssh <alias> true » sans invite de mot de passe.
BatchMode : une invite bloquerait le menu. Un refus d'authentification
se distingue donc d'un hôte injoignable, et le diagnostic le dit.
"""
try:
res = subprocess.run(
[
"ssh",
"-o",
"BatchMode=yes",
"-o",
f"ConnectTimeout={timeout}",
alias,
"true",
],
capture_output=True,
text=True,
timeout=timeout + 12,
env=TODO._qemu_c_env(),
)
except subprocess.TimeoutExpired:
return 255, "Connection timed out"
except (OSError, subprocess.SubprocessError) as exc:
return 255, str(exc)
return res.returncode, res.stderr.strip()
def _sshfs_diagnose(self, alias, mount_point, bypassed):
"""Dit POURQUOI le montage a échoué, et où aller corriger.
Le message est ciblé, pas une liste de causes possibles : on interroge
ssh, et selon qu'il passe ou non, le fautif n'est pas le même.
"""
print(f"\n ⚠ {t('sshfs mount failed.')}")
if not alias:
print(f" → {t('Check the SSH host and that the server is up.')}")
return
print(f" {t('Checking SSH access…')} ({alias})")
code, err = self._ssh_probe(alias)
if code == 0:
print(f" ✓ {t('SSH reaches this host: ~/.ssh/config is fine.')}")
if not bypassed and self.SSHFS_CHAIN_SEP in alias:
print(f" → {t('sshfs reads the « + » as host chaining.')}")
cmd, ok = self._sshfs_command(alias, mount_point)
if ok:
print(f" → {t('Run this instead:')}")
print(f" {cmd}")
else:
# Annoncer une commande puis n'en donner aucune serait
# pire que se taire : on dit ce qui manque.
print(
f" → {t('ssh -G resolved nothing: check ~/.ssh/config.')}"
)
else:
print(f" → {t('Is sshfs (and fuse) installed here?')}")
return
indice = self._ssh_failure_hint(err)
if indice:
print(f" ✗ {t('SSH fails too:')} {t(indice)}")
else:
print(
f" ✗ {t('SSH fails too:')} {err.splitlines()[0] if err else code}"
)
print(f" → {t('Update ~/.ssh/config, or check the server is up.')}")
def _configure_sshfs(self):
import getpass
import re
@ -9458,31 +9666,7 @@ class TODO:
if choice == "2":
ssh_config_path = os.path.expanduser("~/.ssh/config")
hosts = []
if os.path.exists(ssh_config_path):
current_host = None
current_info = {}
with open(ssh_config_path) as f:
for line in f:
line = line.strip()
if line.lower().startswith("host "):
host_val = line.split(None, 1)[1].strip()
if host_val != "*":
if current_host:
hosts.append((current_host, current_info))
current_host = host_val
current_info = {}
elif current_host:
key = line.split(None, 1)
if len(key) == 2:
k = key[0].lower()
v = key[1].strip()
if k == "hostname":
current_info["hostname"] = v
elif k == "user":
current_info["user"] = v
if current_host:
hosts.append((current_host, current_info))
hosts = self._ssh_config_entries(ssh_config_path)
if not hosts:
print(t("No SSH hosts found in ~/.ssh/config"))
@ -9539,17 +9723,41 @@ class TODO:
# une chaîne de symlinks relatifs profonds (.repo/projects -> project-
# objects) que git ne peut pas traverser sur un montage sshfs par
# défaut (« erreur à la lecture de .git » -> git status/commit KO).
cmd = f"sshfs -o follow_symlinks {target} {mount_point}"
# L'alias vient de ~/.ssh/config : c'est lui qui peut porter un « + »,
# et lui qu'on peut interroger en cas d'échec. Une saisie manuelle est
# rendue telle quelle — si elle contient un « + », c'est un chaînage
# demandé exprès.
alias = ssh_name if choice == "2" else ""
if alias:
cmd, bypassed = self._sshfs_command(alias, mount_point)
else:
cmd, bypassed = (
f"sshfs -o follow_symlinks {target} {mount_point}",
False,
)
print(f"{t('Mounting sshfs on: ')}{mount_point}")
print(f"{t('Will execute:')} {cmd}")
try:
self.execute.exec_command_live(cmd, source_erplibre=False)
print(f"{t('Mounted on: ')}{mount_point}")
print(f"mount | grep sshfs")
print(f"{t('To unmount: ')}" f"fusermount -u {mount_point}")
print(f"nautilus {mount_point}/home/{user}")
status = self.execute.exec_command_live(cmd, source_erplibre=False)
except Exception as e:
print(f"{t('Error mounting sshfs: ')}{e}")
status = 1
# Le reste ne s'affiche QUE si le montage a réussi : « Monté sur … »
# après un code 1 envoyait chercher des fichiers dans un répertoire
# vide, et faisait passer l'échec pour un détail.
if status:
self._sshfs_diagnose(alias, mount_point, bypassed)
# Le point de montage n'a jamais servi : le laisser accumulerait
# un répertoire vide dans /tmp à chaque tentative.
try:
os.rmdir(mount_point)
except OSError:
pass
return
print(f"{t('Mounted on: ')}{mount_point}")
print("mount | grep sshfs")
print(f"{t('To unmount: ')}" f"fusermount -u {mount_point}")
print(f"nautilus {mount_point}/home/{user}")
def _get_ssh_params(self):
"""Prompt for SSH connection parameters. Returns dict or None on cancel."""

View file

@ -3103,6 +3103,70 @@ TRANSLATIONS = {
"fr": "Nœud de rendu",
"en": "Render node",
},
"sshfs mount failed.": {
"fr": "Le montage sshfs a échoué.",
"en": "sshfs mount failed.",
},
"Checking SSH access…": {
"fr": "Vérification de l'accès SSH…",
"en": "Checking SSH access…",
},
"SSH reaches this host: ~/.ssh/config is fine.": {
"fr": "SSH joint cet hôte : ~/.ssh/config est bon.",
"en": "SSH reaches this host: ~/.ssh/config is fine.",
},
"sshfs reads the « + » as host chaining.": {
"fr": "sshfs lit le « + » comme un chaînage d'hôtes.",
"en": "sshfs reads the « + » as host chaining.",
},
"Run this instead:": {
"fr": "À lancer plutôt :",
"en": "Run this instead:",
},
"ssh -G resolved nothing: check ~/.ssh/config.": {
"fr": "« ssh -G » n'a rien résolu : vérifier ~/.ssh/config.",
"en": "ssh -G resolved nothing: check ~/.ssh/config.",
},
"Is sshfs (and fuse) installed here?": {
"fr": "sshfs (et fuse) sont-ils installés ici ?",
"en": "Is sshfs (and fuse) installed here?",
},
"SSH fails too:": {
"fr": "SSH échoue aussi :",
"en": "SSH fails too:",
},
"Update ~/.ssh/config, or check the server is up.": {
"fr": "Mettre à jour ~/.ssh/config, ou vérifier que le serveur répond.",
"en": "Update ~/.ssh/config, or check the server is up.",
},
"Check the SSH host and that the server is up.": {
"fr": "Vérifier l'hôte SSH saisi et que le serveur répond.",
"en": "Check the SSH host and that the server is up.",
},
"unknown host name: check HostName": {
"fr": "nom d'hôte inconnu : vérifier HostName",
"en": "unknown host name: check HostName",
},
"no answer: is the server up and reachable?": {
"fr": "aucune réponse : le serveur est-il allumé et joignable ?",
"en": "no answer: is the server up and reachable?",
},
"no route: check the network or the ProxyJump": {
"fr": "pas de route : vérifier le réseau ou le ProxyJump",
"en": "no route: check the network or the ProxyJump",
},
"nothing listening on the SSH port": {
"fr": "rien n'écoute sur le port SSH",
"en": "nothing listening on the SSH port",
},
"authentication refused: check User and key": {
"fr": "authentification refusée : vérifier User et la clé",
"en": "authentication refused: check User and key",
},
"host key changed for this address": {
"fr": "la clé d'hôte a changé pour cette adresse",
"en": "host key changed for this address",
},
"no statistics yet": {
"fr": "pas encore de statistiques",
"en": "no statistics yet",

389
test/test_todo_sshfs.py Normal file
View file

@ -0,0 +1,389 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Montage sshfs : ne rien annoncer qui n'ait eu lieu, et dire pourquoi.
Le symptôme rapporté : « read: Connection reset by peer », « code : 1 », et
juste après « Monté sur /tmp/sshfs_… », la commande pour démonter et celle
pour ouvrir le répertoire dans un explorateur. Le montage n'avait pas eu lieu.
La cause, elle, est plus profonde : sshfs lit « a+b » comme un CHAÎNAGE
d'hôtes — « ssh a, puis ssh b depuis a » — et ne consulte donc jamais
~/.ssh/config pour l'alias entier. Or c'est todo.py qui nomme les VM
découvertes « rebond+domaine », et la seconde moitié de ce nom est un domaine
libvirt, pas un alias SSH du rebond. Ces alias-là, les plus utiles, étaient
les seuls que sshfs ne pouvait pas monter.
Ce que ces tests gardent :
- Aucune ligne de succès après un code non nul.
- Le point de montage inutilisé est retiré, pas laissé dans /tmp.
- Un alias à « + » est résolu par ssh lui-même (« ssh -G ») et rendu à sshfs
sous une forme qu'il ne peut plus mal lire, options comprises — sans
StrictHostKeyChecking, une VM à l'IP recyclée échouerait sur sa clé d'hôte.
- « Host a b » déclare DEUX alias : c'est ce que le générateur du dépôt écrit.
"""
import contextlib
import io
import os
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
from script.todo.todo_i18n import t # noqa: E402
# Entrée réelle écrite par todo.py pour une VM derrière un rebond.
CONFIG = """Host *
ServerAliveInterval 60
Host novipro_private
HostName 192.168.100.110
User mathben
Host novipro_private+ERPLibre01
HostName 192.168.122.50
User mathben
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
IdentityFile /home/erplibre/.ssh/id_ed25519
IdentitiesOnly yes
ProxyJump novipro_private
Host erplibre-ubuntu-2604 erplibre-2604-bis
HostName 192.168.123.165
User erplibre
Host web-?
User www
"""
# Sortie de « ssh -G » pour l'alias à « + », réduite à ce qui compte.
SSH_G = """host novipro_private+erplibre01
hostname 192.168.122.50
user mathben
port 22
proxyjump novipro_private
identityfile /home/erplibre/.ssh/id_ed25519
identityfile ~/.ssh/id_rsa
identitiesonly yes
stricthostkeychecking false
userknownhostsfile /dev/null
"""
def _config(texte=CONFIG):
tmp = tempfile.NamedTemporaryFile(
"w", suffix=".config", delete=False, encoding="utf-8"
)
tmp.write(texte)
tmp.close()
return tmp.name
class TestLectureConfig(unittest.TestCase):
def setUp(self):
self.chemin = _config()
self.addCleanup(os.unlink, self.chemin)
def test_it_reads_hosts_in_file_order(self):
hosts = TODO._ssh_config_entries(self.chemin)
noms = [n for n, _i in hosts]
self.assertEqual("novipro_private", noms[0])
self.assertIn("novipro_private+ERPLibre01", noms)
def test_a_host_line_with_two_patterns_gives_two_aliases(self):
"""C'est ce que le générateur du dépôt écrit (« Host {' '.join(names)} »).
Les prendre pour un seul nom donnait l'alias « a b », que sshfs ne peut
pas monter — et qui n'existe pour personne."""
hosts = dict(TODO._ssh_config_entries(self.chemin))
self.assertIn("erplibre-ubuntu-2604", hosts)
self.assertIn("erplibre-2604-bis", hosts)
self.assertEqual(
"192.168.123.165", hosts["erplibre-ubuntu-2604"]["hostname"]
)
self.assertEqual(
hosts["erplibre-ubuntu-2604"], hosts["erplibre-2604-bis"]
)
def test_wildcard_patterns_are_left_out(self):
"""« Host * » et « Host web-? » ne désignent aucune machine : les
proposer dans un menu de montage n'a pas de sens."""
noms = [n for n, _i in TODO._ssh_config_entries(self.chemin)]
self.assertNotIn("*", noms)
self.assertNotIn("web-?", noms)
def test_the_plus_alias_stays_one_name(self):
noms = [n for n, _i in TODO._ssh_config_entries(self.chemin)]
self.assertNotIn("novipro_private", noms[1:2] and [])
self.assertIn("novipro_private+ERPLibre01", noms)
def test_hostname_and_user_are_kept(self):
hosts = dict(TODO._ssh_config_entries(self.chemin))
info = hosts["novipro_private+ERPLibre01"]
self.assertEqual("192.168.122.50", info["hostname"])
self.assertEqual("mathben", info["user"])
def test_a_missing_file_is_not_a_crash(self):
self.assertEqual([], TODO._ssh_config_entries("/nexistepas/config"))
class TestResolution(unittest.TestCase):
def test_it_reads_ssh_dash_g(self):
with mock.patch(
"subprocess.run",
return_value=subprocess.CompletedProcess([], 0, SSH_G, ""),
):
cfg = TODO._ssh_resolve("novipro_private+ERPLibre01")
self.assertEqual("192.168.122.50", cfg["hostname"])
self.assertEqual("novipro_private", cfg["proxyjump"])
def test_the_first_identityfile_wins(self):
"""ssh -G les répète toutes ; la première est celle qu'il essaiera."""
with mock.patch(
"subprocess.run",
return_value=subprocess.CompletedProcess([], 0, SSH_G, ""),
):
cfg = TODO._ssh_resolve("x")
self.assertEqual("/home/erplibre/.ssh/id_ed25519", cfg["identityfile"])
def test_a_failing_ssh_resolves_to_nothing(self):
with mock.patch(
"subprocess.run",
return_value=subprocess.CompletedProcess([], 255, "", "bad"),
):
self.assertEqual({}, TODO._ssh_resolve("x"))
with mock.patch("subprocess.run", side_effect=OSError):
self.assertEqual({}, TODO._ssh_resolve("x"))
class TestCommandeSshfs(unittest.TestCase):
def setUp(self):
# Méthode d'instance : elle interroge self._ssh_resolve quand aucune
# résolution ne lui est fournie.
self.todo = TODO.__new__(TODO)
self.todo._ssh_resolve = lambda alias: {}
def test_an_alias_without_plus_is_handed_over_untouched(self):
"""Sans « + », c'est ssh qui lit la config, et rien ne vaut mieux."""
cmd, contourne = self.todo._sshfs_command("vm-a", "/tmp/mnt")
self.assertEqual("sshfs -o follow_symlinks vm-a:/ /tmp/mnt", cmd)
self.assertFalse(contourne)
def _resolue(self):
return {
"hostname": "192.168.122.50",
"user": "mathben",
"port": "22",
"proxyjump": "novipro_private",
"identityfile": "/home/erplibre/.ssh/id_ed25519",
"identitiesonly": "yes",
"stricthostkeychecking": "false",
"userknownhostsfile": "/dev/null",
}
def test_a_plus_alias_becomes_a_resolved_target(self):
cmd, contourne = self.todo._sshfs_command(
"novipro_private+ERPLibre01", "/tmp/mnt", self._resolue()
)
self.assertTrue(contourne)
self.assertIn("mathben@192.168.122.50:/", cmd)
self.assertNotIn("+", cmd)
def test_the_options_that_matter_travel_with_it(self):
"""Sans ProxyJump, la VM est injoignable ; sans StrictHostKeyChecking,
une IP DHCP recyclée fait échouer le montage sur sa clé d'hôte."""
cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", self._resolue())
for attendu in (
"-o ProxyJump=novipro_private",
"-o Port=22",
"-o IdentityFile=/home/erplibre/.ssh/id_ed25519",
"-o IdentitiesOnly=yes",
"-o StrictHostKeyChecking=false",
"-o UserKnownHostsFile=/dev/null",
):
self.assertIn(attendu, cmd)
def test_options_ssh_reports_as_none_are_not_forwarded(self):
"""« ssh -G » écrit « proxyjump none » quand il n'y en a pas ; le
transmettre ferait échouer ssh sur une valeur qu'il vient d'inventer.
"""
cfg = dict(self._resolue(), proxyjump="none")
cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", cfg)
self.assertNotIn("ProxyJump", cmd)
def test_an_unresolvable_alias_is_left_alone(self):
"""Mieux vaut la commande d'origine, qui échouera en le disant, qu'une
cible inventée qui monterait la mauvaise machine."""
cmd, contourne = self.todo._sshfs_command("a+b", "/tmp/mnt", {})
self.assertEqual("sshfs -o follow_symlinks a+b:/ /tmp/mnt", cmd)
self.assertFalse(contourne)
def test_the_mount_point_stays_last(self):
"""La syntaxe de sshfs : cible puis point de montage. Une option glissée
après monterait ailleurs."""
cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", self._resolue())
self.assertTrue(cmd.endswith(" /tmp/mnt"), cmd)
class TestDiagnostic(unittest.TestCase):
def test_each_kind_of_failure_names_its_culprit(self):
cas = (
(
"ssh: Could not resolve hostname zz: Name or service not known",
"unknown host name: check HostName",
),
(
"ssh: connect to host x port 22: Connection timed out",
"no answer: is the server up and reachable?",
),
(
"ssh: connect to host x port 22: No route to host",
"no route: check the network or the ProxyJump",
),
(
"ssh: connect to host x port 22: Connection refused",
"nothing listening on the SSH port",
),
(
"mathben@x: Permission denied (publickey).",
"authentication refused: check User and key",
),
(
"Host key verification failed.",
"host key changed for this address",
),
)
for stderr, attendu in cas:
self.assertEqual(attendu, TODO._ssh_failure_hint(stderr), stderr)
def test_an_unknown_error_is_not_guessed(self):
"""Rien à dire plutôt qu'un diagnostic inventé : la ligne brute de ssh
sera affichée telle quelle."""
self.assertEqual("", TODO._ssh_failure_hint("sshfs: fuse: bidule"))
self.assertEqual("", TODO._ssh_failure_hint(""))
class TestFlux(unittest.TestCase):
"""Le parcours complet, exécuteur bouchonné."""
def _todo(self, code, probe=(0, "")):
todo = TODO.__new__(TODO)
todo.lances = []
exe = mock.Mock()
def lance(cmd, **kw):
todo.lances.append(cmd)
return code
exe.exec_command_live = lance
todo.execute = exe
todo._ssh_probe = lambda alias, timeout=8: probe
todo._ssh_resolve = lambda alias: {
"hostname": "192.168.122.50",
"user": "mathben",
"proxyjump": "novipro_private",
}
return todo
def _joue(self, todo, config, selection="2"):
chemin = _config(config)
self.addCleanup(os.unlink, chemin)
reponses = iter(["2", selection])
crees = []
vrai_makedirs = os.makedirs
vrai_rmdir = os.rmdir
def makedirs(path, **kw):
crees.append(path)
return vrai_makedirs(path, **kw)
retires = []
def rmdir(path):
retires.append(path)
return vrai_rmdir(path)
out = io.StringIO()
with mock.patch(
"builtins.input", lambda *a: next(reponses, "")
), mock.patch(
"os.path.expanduser",
lambda p: chemin if p.endswith("config") else p,
), mock.patch(
"os.makedirs", makedirs
), mock.patch(
"os.rmdir", rmdir
):
with contextlib.redirect_stdout(out):
todo._configure_sshfs()
return out.getvalue(), crees, retires
def test_a_failed_mount_announces_nothing_mounted(self):
"""Le cœur du problème rapporté : « Monté sur … » après un code 1."""
todo = self._todo(1)
sortie, _crees, _retires = self._joue(todo, CONFIG, "2")
self.assertNotIn(t("Mounted on: "), sortie)
self.assertNotIn("fusermount", sortie)
self.assertNotIn("nautilus", sortie)
self.assertIn(t("sshfs mount failed."), sortie)
def test_a_failed_mount_leaves_no_empty_directory(self):
"""Une tentative par jour pendant un mois laissait trente répertoires
vides dans /tmp."""
todo = self._todo(1)
_s, crees, retires = self._joue(todo, CONFIG, "2")
self.assertEqual(crees, retires)
for chemin in retires:
self.assertFalse(os.path.exists(chemin))
def test_a_successful_mount_says_how_to_unmount(self):
todo = self._todo(0)
sortie, crees, retires = self._joue(todo, CONFIG, "2")
self.assertIn(t("Mounted on: "), sortie)
self.assertIn("fusermount -u", sortie)
self.assertEqual([], retires)
for chemin in crees:
self.addCleanup(lambda p=chemin: os.path.isdir(p) and os.rmdir(p))
def test_ssh_working_points_at_the_plus_not_at_the_network(self):
"""Quand ssh joint l'hôte, ce n'est pas le réseau : c'est sshfs qui a
mal lu l'alias. Le message doit envoyer là, et donner la commande."""
todo = self._todo(1, probe=(0, ""))
# L'alias à « + » est le troisième de la config (après le rebond seul).
sortie, _c, _r = self._joue(todo, CONFIG, "2")
self.assertIn(
t("SSH reaches this host: ~/.ssh/config is fine."), sortie
)
def test_ssh_failing_sends_to_the_config_or_the_server(self):
todo = self._todo(
1,
probe=(
255,
"ssh: connect to host x port 22: Connection timed out",
),
)
sortie, _c, _r = self._joue(todo, CONFIG, "2")
self.assertIn(t("no answer: is the server up and reachable?"), sortie)
self.assertIn(
t("Update ~/.ssh/config, or check the server is up."), sortie
)
def test_the_plus_alias_is_bypassed_before_being_run(self):
"""Le vrai correctif : la commande lancée ne contient plus le « + »."""
todo = self._todo(0)
# [1] novipro_private · [2] novipro_private+ERPLibre01 · [3] la VM
_s, _c, _r = self._joue(todo, CONFIG, "2")
lancee = todo.lances[0]
self.assertIn("mathben@192.168.122.50:/", lancee)
self.assertIn("-o ProxyJump=novipro_private", lancee)
self.assertNotIn("+ERPLibre01", lancee)
if __name__ == "__main__":
unittest.main(verbosity=1)