diff --git a/python/docs/source/reference/package-apis/drivers/index.md b/python/docs/source/reference/package-apis/drivers/index.md index ee778f06c..57b8d116d 100644 --- a/python/docs/source/reference/package-apis/drivers/index.md +++ b/python/docs/source/reference/package-apis/drivers/index.md @@ -109,6 +109,7 @@ General-purpose utility drivers: * **[Shell](shell.md)** (`jumpstarter-driver-shell`) - Shell command execution * **[TMT](tmt.md)** (`jumpstarter-driver-tmt`) - TMT (Test Management Tool) wrapper driver * **[SSH](ssh.md)** (`jumpstarter-driver-ssh`) - SSH wrapper driver +* **[SSH Mount](ssh-mount.md)** (`jumpstarter-driver-ssh-mount`) - SSHFS remote filesystem mounting ```{toctree} :hidden: @@ -139,6 +140,7 @@ ridesx.md sdwire.md shell.md ssh.md +ssh-mount.md snmp.md tasmota.md tmt.md diff --git a/python/docs/source/reference/package-apis/drivers/ssh-mount.md b/python/docs/source/reference/package-apis/drivers/ssh-mount.md new file mode 120000 index 000000000..17b1fa0bd --- /dev/null +++ b/python/docs/source/reference/package-apis/drivers/ssh-mount.md @@ -0,0 +1 @@ +../../../../../packages/jumpstarter-driver-ssh-mount/README.md \ No newline at end of file diff --git a/python/packages/jumpstarter-driver-ssh-mount/.gitignore b/python/packages/jumpstarter-driver-ssh-mount/.gitignore new file mode 100644 index 000000000..cbc5d672b --- /dev/null +++ b/python/packages/jumpstarter-driver-ssh-mount/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +.coverage +coverage.xml diff --git a/python/packages/jumpstarter-driver-ssh-mount/README.md b/python/packages/jumpstarter-driver-ssh-mount/README.md new file mode 100644 index 000000000..231b10e4e --- /dev/null +++ b/python/packages/jumpstarter-driver-ssh-mount/README.md @@ -0,0 +1,83 @@ +# SSHMount Driver + +`jumpstarter-driver-ssh-mount` provides remote filesystem mounting via sshfs. It allows you to mount remote directories from a target device to your local machine using SSHFS (SSH Filesystem). + +## Installation + +```shell +pip3 install --extra-index-url https://pkg.jumpstarter.dev/simple/ jumpstarter-driver-ssh-mount +``` + +You also need `sshfs` installed on the client machine: + +- **Fedora/RHEL**: `sudo dnf install fuse-sshfs` +- **Debian/Ubuntu**: `sudo apt-get install sshfs` +- **macOS**: Install macFUSE from https://macfuse.github.io/ and then install + sshfs from source, as Homebrew has removed sshfs support. + +## Configuration + +The SSHMount driver references an existing SSH driver to inherit credentials +(username, identity key) and TCP connectivity. No duplicate configuration is needed. + +Example exporter configuration: + +```yaml +export: + ssh: + type: jumpstarter_driver_ssh.driver.SSHWrapper + config: + default_username: "root" + # ssh_identity_file: "/path/to/ssh/key" + children: + tcp: + type: jumpstarter_driver_network.driver.TcpNetwork + config: + host: "192.168.1.100" + port: 22 + mount: + type: jumpstarter_driver_ssh_mount.driver.SSHMount + children: + ssh: + ref: "ssh" +``` + +## CLI Usage + +Inside a `jmp shell` session: + +```shell +# Mount remote filesystem (spawns a subshell; type 'exit' to unmount) +j mount /local/mountpoint +j mount /local/mountpoint -r /remote/path +j mount /local/mountpoint --direct + +# Mount in foreground mode (blocks until Ctrl+C) +j mount /local/mountpoint --foreground + +# Unmount an orphaned mount +j mount --umount /local/mountpoint +j mount --umount /local/mountpoint --lazy +``` + +By default, `j mount` runs sshfs in foreground mode and spawns a subshell +with a modified prompt. The mount stays active while the subshell is running. +When you type `exit` (or press Ctrl+D), sshfs is terminated and all resources +(port forwards, temporary identity files) are cleaned up automatically. + +Use `--foreground` to skip the subshell and block directly on sshfs. Press +Ctrl+C to unmount. + +The `--umount` flag is available as a fallback for mounts that were orphaned +(e.g., if the process was killed without cleanup). + +## API Reference + +### SSHMountClient + +- `mount(mountpoint, *, remote_path="/", direct=False, foreground=False, extra_args=None)` - Mount remote filesystem locally via sshfs +- `umount(mountpoint, *, lazy=False)` - Unmount an sshfs filesystem (fallback for orphaned mounts) + +### CLI + +The driver registers as `mount` in the exporter config. When used in a `jmp shell` session, the CLI is a single command with a `--umount` flag for unmounting. diff --git a/python/packages/jumpstarter-driver-ssh-mount/examples/exporter.yaml b/python/packages/jumpstarter-driver-ssh-mount/examples/exporter.yaml new file mode 100644 index 000000000..be0600156 --- /dev/null +++ b/python/packages/jumpstarter-driver-ssh-mount/examples/exporter.yaml @@ -0,0 +1,24 @@ +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +metadata: + namespace: default + name: demo +endpoint: grpc.jumpstarter.192.168.0.203.nip.io:8082 +token: "" +export: + ssh: + type: jumpstarter_driver_ssh.driver.SSHWrapper + config: + default_username: "root" + # ssh_identity_file: "/path/to/key" + children: + tcp: + type: jumpstarter_driver_network.driver.TcpNetwork + config: + host: "192.168.1.100" + port: 22 + mount: + type: jumpstarter_driver_ssh_mount.driver.SSHMount + children: + ssh: + ref: "ssh" diff --git a/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/__init__.py b/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/__init__.py @@ -0,0 +1 @@ + diff --git a/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/client.py b/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/client.py new file mode 100644 index 000000000..4e08c57a3 --- /dev/null +++ b/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/client.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +from dataclasses import dataclass +from urllib.parse import urlparse + +import click +from jumpstarter_driver_composite.client import CompositeClient +from jumpstarter_driver_network.adapters import TcpPortforwardAdapter + +from jumpstarter.client.core import DriverMethodNotImplemented +from jumpstarter.client.decorators import driver_click_command + +# Timeout in seconds for subprocess calls (umount) +SUBPROCESS_TIMEOUT = 120 + + +@dataclass(kw_only=True) +class SSHMountClient(CompositeClient): + + def cli(self): + @driver_click_command(self) + @click.argument("mountpoint", type=click.Path()) + @click.option("--umount", "-u", is_flag=True, help="Unmount instead of mount") + @click.option("--remote-path", "-r", default="/", help="Remote path to mount (default: /)") + @click.option("--direct", is_flag=True, help="Use direct TCP address") + @click.option("--lazy", "-l", is_flag=True, help="Lazy unmount (detach filesystem now, clean up later)") + @click.option("--foreground", is_flag=True, help="Block on sshfs in foreground without spawning a subshell") + @click.option("--extra-args", "-o", multiple=True, help="Extra arguments to pass to sshfs") + def mount(mountpoint, umount, remote_path, direct, lazy, foreground, extra_args): + """Mount or unmount remote filesystem via sshfs""" + if umount: + self.umount(mountpoint, lazy=lazy) + else: + self.mount( + mountpoint, + remote_path=remote_path, + direct=direct, + foreground=foreground, + extra_args=list(extra_args), + ) + + return mount + + @property + def identity(self) -> str | None: + return self.ssh.identity + + @property + def username(self) -> str: + return self.ssh.username + + def mount(self, mountpoint, *, remote_path="/", direct=False, foreground=False, extra_args=None): + """Mount remote filesystem locally via sshfs. + + Runs sshfs in foreground mode (-f) and spawns a subshell so that + the mount stays alive while the user works. When the subshell exits, + sshfs is terminated and all resources are cleaned up automatically. + + Args: + mountpoint: Local directory to mount the remote filesystem on. + remote_path: Remote path to mount (default: /). + direct: If True, connect directly to the host's TCP address. + foreground: If True, block on sshfs without spawning a subshell. + extra_args: Extra arguments to pass to sshfs. + """ + sshfs_path = self._find_executable("sshfs") + if not sshfs_path: + raise click.ClickException( + "sshfs is not installed. Please install it:\n" + " Fedora/RHEL: sudo dnf install fuse-sshfs\n" + " Debian/Ubuntu: sudo apt-get install sshfs\n" + " macOS: Install macFUSE from https://macfuse.github.io/ and then install\n" + " sshfs from source, as Homebrew has removed sshfs support." + ) + + mountpoint = os.path.realpath(mountpoint) + os.makedirs(mountpoint, exist_ok=True) + + if direct: + try: + address = self.ssh.tcp.address() + parsed = urlparse(address) + host = parsed.hostname + port = parsed.port + if not host or not port: + raise ValueError(f"Invalid address format: {address}") + self.logger.debug("Using direct TCP connection for sshfs - host: %s, port: %s", host, port) + self._run_sshfs(host, port, mountpoint, remote_path, extra_args, + port_forward=None, foreground=foreground) + except (DriverMethodNotImplemented, ValueError) as e: + self.logger.error( + "Direct address connection failed (%s), falling back to port forwarding", e + ) + self.mount(mountpoint, remote_path=remote_path, direct=False, + foreground=foreground, extra_args=extra_args) + else: + self.logger.debug("Using SSH port forwarding for sshfs connection") + adapter = TcpPortforwardAdapter(client=self.ssh.tcp) + host, port = adapter.__enter__() + self.logger.debug("SSH port forward established - host: %s, port: %s", host, port) + try: + self._run_sshfs(host, port, mountpoint, remote_path, extra_args, + port_forward=adapter, foreground=foreground) + finally: + adapter.__exit__(None, None, None) + + def _run_sshfs(self, host, port, mountpoint, remote_path, extra_args, *, port_forward, foreground): + identity_file = self._create_temp_identity_file() + + try: + sshfs_args = self._build_sshfs_args(host, port, mountpoint, remote_path, identity_file, extra_args) + # Add -f to run sshfs in foreground mode so it blocks + sshfs_args.append("-f") + + self.logger.debug("Running sshfs command: %s", sshfs_args) + + # First try with allow_other; if that fails, retry without it + sshfs_proc = self._start_sshfs_with_fallback(sshfs_args) + + default_username = self.username + user_prefix = f"{default_username}@" if default_username else "" + remote_spec = f"{user_prefix}{host}:{remote_path}" + click.echo(f"Mounted {remote_spec} on {mountpoint}") + + if foreground: + click.echo("Press Ctrl+C to unmount and exit.") + try: + sshfs_proc.wait() + except KeyboardInterrupt: + click.echo("\nUnmounting...") + else: + click.echo("Type 'exit' to unmount and return.") + self._run_subshell(mountpoint, remote_path) + + # Terminate sshfs if it's still running + if sshfs_proc.poll() is None: + sshfs_proc.terminate() + try: + sshfs_proc.wait(timeout=10) + except subprocess.TimeoutExpired: + sshfs_proc.kill() + sshfs_proc.wait() + + # Run fusermount/umount to ensure clean unmount + self._force_umount(mountpoint) + click.echo(f"Unmounted {mountpoint}") + finally: + self._cleanup_identity_file(identity_file) + + def _start_sshfs_with_fallback(self, sshfs_args): + """Start sshfs, retrying without allow_other if it fails on that option. + + We do a quick test run (without -f) to check if sshfs can mount + successfully, then start the real foreground process. + """ + # Test run without -f to validate args quickly + test_args = [a for a in sshfs_args if a != "-f"] + result = subprocess.run(test_args, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT) + + if result.returncode != 0 and "allow_other" in result.stderr: + self.logger.debug("Retrying sshfs without allow_other option") + sshfs_args = self._remove_allow_other(sshfs_args) + # Test again without allow_other + test_args = [a for a in sshfs_args if a != "-f"] + result = subprocess.run(test_args, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT) + + if result.returncode != 0: + stderr = result.stderr.strip() + raise click.ClickException( + f"sshfs mount failed (exit code {result.returncode}): {stderr}" + ) + + # The test mount succeeded. Unmount it, then re-mount in foreground mode. + # Extract the mountpoint from args (it's the 3rd arg: sshfs remote mount) + mountpoint = sshfs_args[2] + self._force_umount(mountpoint) + + # Now start the real foreground process + proc = subprocess.Popen( + sshfs_args, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + # Give sshfs a moment to start and check it hasn't failed immediately + try: + proc.wait(timeout=1) + # If it exited already, something went wrong + stderr = proc.stderr.read().decode() if proc.stderr else "" + raise click.ClickException( + f"sshfs mount failed (exit code {proc.returncode}): {stderr.strip()}" + ) + except subprocess.TimeoutExpired: + # Good -- sshfs is running in foreground mode + pass + + return proc + + def _remove_allow_other(self, sshfs_args): + filtered = [] + skip_next = False + for i, arg in enumerate(sshfs_args): + if skip_next: + skip_next = False + continue + if arg == "-o" and i + 1 < len(sshfs_args) and sshfs_args[i + 1] == "allow_other": + skip_next = True + continue + filtered.append(arg) + return filtered + + def _run_subshell(self, mountpoint, remote_path): + """Spawn an interactive subshell with a modified prompt.""" + shell = os.environ.get("SHELL", "/bin/sh") + env = os.environ.copy() + + # Modify the prompt to indicate the active mount + prompt_prefix = f"[sshfs:{remote_path}] " + if "bash" in shell: + env["PS1"] = prompt_prefix + env.get("PS1", r"\$ ") + # Prevent bash from reading ~/.bashrc which would override PS1 + # Instead, use --rcfile with a custom init that sources bashrc then sets PS1 + env["JUMPSTARTER_SSHFS_PROMPT"] = prompt_prefix + subprocess.run( + [shell, "--norc", "--noprofile", "-i"], + env=env, + ) + elif "zsh" in shell: + env["PS1"] = prompt_prefix + env.get("PS1", "%# ") + subprocess.run([shell, "-i"], env=env) + else: + subprocess.run([shell, "-i"], env=env) + + def _build_sshfs_args(self, host, port, mountpoint, remote_path, identity_file, extra_args): + default_username = self.username + user_prefix = f"{default_username}@" if default_username else "" + remote_spec = f"{user_prefix}{host}:{remote_path}" + + sshfs_args = ["sshfs", remote_spec, mountpoint] + + ssh_opts = [ + "StrictHostKeyChecking=no", + "UserKnownHostsFile=/dev/null", + "LogLevel=ERROR", + ] + + if port and port != 22: + sshfs_args.extend(["-p", str(port)]) + + if identity_file: + ssh_opts.append(f"IdentityFile={identity_file}") + + ssh_opts.append("allow_other") + + for opt in ssh_opts: + sshfs_args.extend(["-o", opt]) + + if extra_args: + sshfs_args.extend(extra_args) + + return sshfs_args + + def _create_temp_identity_file(self): + """Create a temporary file with the SSH identity key, if configured.""" + ssh_identity = self.identity + if not ssh_identity: + return None + + temp_file = None + try: + temp_file = tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='_ssh_key') + temp_file.write(ssh_identity) + temp_file.close() + os.chmod(temp_file.name, 0o600) + self.logger.debug("Created temporary identity file: %s", temp_file.name) + return temp_file.name + except Exception as e: + self.logger.error("Failed to create temporary identity file: %s", e) + if temp_file: + try: + os.unlink(temp_file.name) + except Exception: + pass + raise + + def _cleanup_identity_file(self, identity_file): + if identity_file: + try: + os.unlink(identity_file) + self.logger.debug("Cleaned up temporary identity file: %s", identity_file) + except Exception as e: + self.logger.warning("Failed to clean up identity file %s: %s", identity_file, e) + + def umount(self, mountpoint, *, lazy=False): + """Unmount an sshfs filesystem (fallback for orphaned mounts).""" + mountpoint = os.path.realpath(mountpoint) + cmd = self._build_umount_cmd(mountpoint, lazy=lazy) + + self.logger.debug("Running unmount command: %s", cmd) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT) + + if result.returncode != 0: + stderr = result.stderr.strip() + raise click.ClickException(f"Unmount failed (exit code {result.returncode}): {stderr}") + + click.echo(f"Unmounted {mountpoint}") + + def _force_umount(self, mountpoint): + """Best-effort unmount, ignoring errors (used during cleanup).""" + cmd = self._build_umount_cmd(mountpoint, lazy=False) + try: + subprocess.run(cmd, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT) + except Exception: + pass + + def _build_umount_cmd(self, mountpoint, *, lazy=False): + fusermount = self._find_executable("fusermount3") or self._find_executable("fusermount") + if fusermount: + cmd = [fusermount, "-u"] + if lazy: + cmd.append("-z") + else: + cmd = ["umount"] + if lazy: + cmd.append("-l") + cmd.append(mountpoint) + return cmd + + @staticmethod + def _find_executable(name): + import shutil + return shutil.which(name) diff --git a/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/driver.py b/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/driver.py new file mode 100644 index 000000000..48c3fae84 --- /dev/null +++ b/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/driver.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass + +from jumpstarter.common.exceptions import ConfigurationError +from jumpstarter.driver import Driver + + +@dataclass(kw_only=True) +class SSHMount(Driver): + """SSHFS mount/umount driver for Jumpstarter + + This driver provides remote filesystem mounting via sshfs. + It requires an 'ssh' child driver (SSHWrapper) which provides + SSH credentials and a 'tcp' sub-child for network connectivity. + """ + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + + if "ssh" not in self.children: + raise ConfigurationError( + "'ssh' child is required via ref to an SSHWrapper driver instance" + ) + + @classmethod + def client(cls) -> str: + return "jumpstarter_driver_ssh_mount.client.SSHMountClient" diff --git a/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/driver_test.py b/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/driver_test.py new file mode 100644 index 000000000..cc759df34 --- /dev/null +++ b/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/driver_test.py @@ -0,0 +1,488 @@ +import os +import subprocess +from unittest.mock import MagicMock, patch + +import pytest +from jumpstarter_driver_network.driver import TcpNetwork +from jumpstarter_driver_ssh.driver import SSHWrapper + +from jumpstarter_driver_ssh_mount.driver import SSHMount + +from jumpstarter.common.exceptions import ConfigurationError +from jumpstarter.common.utils import serve + +# Test SSH key content used in multiple tests +TEST_SSH_KEY = ( + "-----BEGIN OPENSSH PRIVATE KEY-----\n" + "test-key-content\n" + "-----END OPENSSH PRIVATE KEY-----" +) + + +def _make_ssh_child(default_username="testuser", ssh_identity=None, ssh_identity_file=None, + host="127.0.0.1", port=22): + """Helper to create an SSHWrapper driver instance for use as a child of SSHMount.""" + kwargs = { + "default_username": default_username, + "children": {"tcp": TcpNetwork(host=host, port=port)}, + } + if ssh_identity is not None: + kwargs["ssh_identity"] = ssh_identity + if ssh_identity_file is not None: + kwargs["ssh_identity_file"] = ssh_identity_file + return SSHWrapper(**kwargs) + + +def test_ssh_mount_requires_ssh_child(): + """Test that SSHMount driver requires an ssh child""" + with pytest.raises(ConfigurationError, match="'ssh' child is required"): + SSHMount() + + +def test_mount_sshfs_not_installed(): + """Test mount fails gracefully when sshfs is not installed""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + with patch.object(client, '_find_executable', return_value=None): + with pytest.raises(Exception, match="sshfs is not installed"): + client.mount("/tmp/test-mount") + + +def test_mount_sshfs_success(): + """Test successful sshfs mount via port forwarding with subshell""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + mock_proc = MagicMock() + mock_proc.poll.return_value = 0 # sshfs already exited + mock_proc.stderr = None + + with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): + with patch('subprocess.run') as mock_run: + with patch('subprocess.Popen', return_value=mock_proc): + # Test run succeeds, then foreground popen exits immediately (simulated) + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + mock_proc.wait.side_effect = [None] # wait returns immediately (exited) + + with patch('os.makedirs'): + with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 2222)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + # The foreground popen will fail because sshfs exits immediately, + # which raises ClickException. That's expected in unit tests + # where sshfs isn't really running. + with pytest.raises(Exception, match="sshfs mount failed"): + client.mount("/tmp/test-mount", remote_path="/home/user") + + # Verify test run was called with correct args + test_run_args = mock_run.call_args_list[0][0][0] + assert test_run_args[0] == "sshfs" + assert "testuser@127.0.0.1:/home/user" in test_run_args + assert os.path.realpath("/tmp/test-mount") in test_run_args + assert "-p" in test_run_args + assert "2222" in test_run_args + # -f should NOT be in the test run (it's removed for validation) + assert "-f" not in test_run_args + + +def test_mount_sshfs_with_identity(): + """Test sshfs mount with SSH identity""" + instance = SSHMount( + children={"ssh": _make_ssh_child(ssh_identity=TEST_SSH_KEY)}, + ) + + with serve(instance) as client: + mock_proc = MagicMock() + mock_proc.poll.return_value = 0 + mock_proc.stderr = None + + with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): + with patch('subprocess.run') as mock_run: + with patch('subprocess.Popen', return_value=mock_proc): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + mock_proc.wait.side_effect = [None] + + with patch('os.makedirs'): + with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with pytest.raises(Exception, match="sshfs mount failed"): + client.mount("/tmp/test-mount") + + test_run_args = mock_run.call_args_list[0][0][0] + identity_opts = [ + test_run_args[i + 1] for i in range(len(test_run_args) - 1) + if test_run_args[i] == "-o" and test_run_args[i + 1].startswith("IdentityFile=") + ] + assert len(identity_opts) == 1 + + +def test_mount_sshfs_allow_other_fallback(): + """Test sshfs mount falls back when allow_other fails, removing both -o and allow_other""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + mock_proc = MagicMock() + mock_proc.poll.return_value = 0 + mock_proc.stderr = None + + with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): + with patch('subprocess.run') as mock_run: + with patch('subprocess.Popen', return_value=mock_proc): + # First test run fails with allow_other, second succeeds + mock_run.side_effect = [ + MagicMock(returncode=1, stdout="", stderr="allow_other: permission denied"), + MagicMock(returncode=0, stdout="", stderr=""), # retry without allow_other + MagicMock(returncode=0, stdout="", stderr=""), # force_umount after test + MagicMock(returncode=0, stdout="", stderr=""), # force_umount after popen + ] + mock_proc.wait.side_effect = [None] + + with patch('os.makedirs'): + with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with pytest.raises(Exception, match="sshfs mount failed"): + client.mount("/tmp/test-mount") + + # Second test run should not have allow_other + second_call_args = mock_run.call_args_list[1][0][0] + assert "allow_other" not in second_call_args + # Verify no orphaned -o flags + for i, arg in enumerate(second_call_args): + if arg == "-o": + assert i + 1 < len(second_call_args), "Orphaned -o flag found" + assert not second_call_args[i + 1].startswith("-"), \ + f"Orphaned -o flag followed by {second_call_args[i + 1]}" + + +def test_mount_sshfs_generic_failure(): + """Test mount failure with a non-allow_other error""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="Connection refused" + ) + with patch('os.makedirs'): + with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with pytest.raises(Exception, match="sshfs mount failed"): + client.mount("/tmp/test-mount") + + # Should only have been called once (no retry) + assert mock_run.call_count == 1 + + +def test_mount_sshfs_direct_success(): + """Test sshfs mount using direct TCP address""" + instance = SSHMount( + children={"ssh": _make_ssh_child(host="10.0.0.1", port=2222)}, + ) + + with serve(instance) as client: + mock_proc = MagicMock() + mock_proc.poll.return_value = 0 + mock_proc.stderr = None + + with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): + with patch('subprocess.run') as mock_run: + with patch('subprocess.Popen', return_value=mock_proc): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + mock_proc.wait.side_effect = [None] + + with patch('os.makedirs'): + with pytest.raises(Exception, match="sshfs mount failed"): + client.mount("/tmp/test-mount", direct=True) + + test_run_args = mock_run.call_args_list[0][0][0] + assert test_run_args[0] == "sshfs" + assert "testuser@10.0.0.1:/" in test_run_args + assert "-p" in test_run_args + assert "2222" in test_run_args + + +def test_mount_sshfs_direct_fallback_to_portforward(): + """Test that direct mount falls back to port forwarding on failure""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + mock_proc = MagicMock() + mock_proc.poll.return_value = 0 + mock_proc.stderr = None + + with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): + with patch('subprocess.run') as mock_run: + with patch('subprocess.Popen', return_value=mock_proc): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + mock_proc.wait.side_effect = [None] + + with patch('os.makedirs'): + with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 3333)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + original_ssh = client.ssh + + class FakeTcp: + def address(self): + raise ValueError("not available") + + class FakeSsh: + def __getattr__(self, name): + if name == "tcp": + return FakeTcp() + return getattr(original_ssh, name) + + with patch.object(client, 'ssh', FakeSsh()): + with pytest.raises(Exception, match="sshfs mount failed"): + client.mount("/tmp/test-mount", direct=True) + + test_run_args = mock_run.call_args_list[0][0][0] + # Should have used port forwarding (port 3333) + assert "3333" in test_run_args + + +def test_mount_foreground_mode(): + """Test that foreground flag blocks on sshfs without spawning subshell""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + mock_proc = MagicMock() + mock_proc.poll.return_value = None # Still running when cleanup checks + mock_proc.wait.side_effect = [ + subprocess.TimeoutExpired("sshfs", 1), # First wait (startup check) - still running + None, # Second wait (foreground blocking) - exited + None, # Third wait (cleanup after terminate) - exited + ] + mock_proc.returncode = 0 + + with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): + with patch('subprocess.run') as mock_run: + with patch('subprocess.Popen', return_value=mock_proc): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with patch('os.makedirs'): + with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + client.mount("/tmp/test-mount", foreground=True) + + # Should have waited on sshfs (foreground mode) + assert mock_proc.wait.call_count >= 2 + # Port forward should be cleaned up + mock_adapter.return_value.__exit__.assert_called() + + +def test_mount_subshell_mode(): + """Test that default mode spawns a subshell""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + mock_proc = MagicMock() + mock_proc.poll.return_value = None # Still running when cleanup checks + mock_proc.wait.side_effect = [ + subprocess.TimeoutExpired("sshfs", 1), # Startup check - still running + None, # Cleanup wait after terminate - exited + ] + mock_proc.returncode = 0 + + with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): + with patch('subprocess.run') as mock_run: + with patch('subprocess.Popen', return_value=mock_proc): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with patch('os.makedirs'): + with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with patch.object(client, '_run_subshell') as mock_subshell: + client.mount("/tmp/test-mount") + + # Subshell should have been called + resolved = os.path.realpath("/tmp/test-mount") + mock_subshell.assert_called_once_with(resolved, "/") + + +def test_mount_cleanup_on_failure(): + """Test that identity file is cleaned up when mount fails""" + instance = SSHMount( + children={"ssh": _make_ssh_child(ssh_identity=TEST_SSH_KEY)}, + ) + + with serve(instance) as client: + with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="Connection refused" + ) + with patch('os.makedirs'): + with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with patch('os.unlink') as mock_unlink: + with pytest.raises(Exception, match="sshfs mount failed"): + client.mount("/tmp/test-mount") + + # Identity file should be cleaned up on failure + assert mock_unlink.called + + +def test_umount_with_fusermount(): + """Test unmount using fusermount""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + def _fake_find(name): + return "/usr/bin/fusermount" if name == "fusermount" else None + + with patch.object(client, '_find_executable', side_effect=_fake_find): + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + client.umount("/tmp/test-mount") + + assert mock_run.called + call_args = mock_run.call_args[0][0] + assert call_args[0] == "/usr/bin/fusermount" + assert "-u" in call_args + + +def test_umount_with_system_umount_fallback(): + """Test unmount falls back to system umount when fusermount is not available""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + with patch.object(client, '_find_executable', return_value=None): + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + client.umount("/tmp/test-mount") + + assert mock_run.called + call_args = mock_run.call_args[0][0] + assert call_args[0] == "umount" + + +def test_umount_lazy(): + """Test lazy unmount""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + def _fake_find(name): + return "/usr/bin/fusermount" if name == "fusermount" else None + + with patch.object(client, '_find_executable', side_effect=_fake_find): + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + client.umount("/tmp/test-mount", lazy=True) + + assert mock_run.called + call_args = mock_run.call_args[0][0] + assert "-z" in call_args + + +def test_umount_failure(): + """Test unmount failure""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + def _fake_find(name): + return "/usr/bin/fusermount" if name == "fusermount" else None + + with patch.object(client, '_find_executable', side_effect=_fake_find): + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="not mounted") + + with pytest.raises(Exception, match="Unmount failed"): + client.umount("/tmp/test-mount") + + +def test_cli_has_mount_and_umount_flag(): + """Test that the CLI exposes mount command with --umount and --foreground flags""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + cli = client.cli() + from click.testing import CliRunner + runner = CliRunner() + result = runner.invoke(cli, ["--help"]) + assert "mountpoint" in result.output.lower() or "MOUNTPOINT" in result.output + assert "--umount" in result.output + assert "--foreground" in result.output + + +def test_cli_dispatches_mount(): + """Test that CLI invocation with a mountpoint dispatches to self.mount()""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + cli = client.cli() + from click.testing import CliRunner + runner = CliRunner() + + with patch.object(client, 'mount') as mock_mount: + result = runner.invoke(cli, ["/tmp/test-cli-mount", "-r", "/home"]) + assert result.exit_code == 0 + mock_mount.assert_called_once_with( + "/tmp/test-cli-mount", + remote_path="/home", + direct=False, + foreground=False, + extra_args=[], + ) + + +def test_cli_dispatches_umount(): + """Test that CLI invocation with --umount dispatches to self.umount()""" + instance = SSHMount( + children={"ssh": _make_ssh_child()}, + ) + + with serve(instance) as client: + cli = client.cli() + from click.testing import CliRunner + runner = CliRunner() + + with patch.object(client, 'umount') as mock_umount: + result = runner.invoke(cli, ["--umount", "/tmp/test-cli-mount", "--lazy"]) + assert result.exit_code == 0 + mock_umount.assert_called_once_with("/tmp/test-cli-mount", lazy=True) diff --git a/python/packages/jumpstarter-driver-ssh-mount/pyproject.toml b/python/packages/jumpstarter-driver-ssh-mount/pyproject.toml new file mode 100644 index 000000000..4d974e83c --- /dev/null +++ b/python/packages/jumpstarter-driver-ssh-mount/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = "jumpstarter-driver-ssh-mount" +dynamic = ["version", "urls"] +description = "SSHFS mount/umount driver for Jumpstarter that provides remote filesystem mounting via sshfs" +readme = "README.md" +license = "Apache-2.0" +authors = [ + { name = "The Jumpstarter Authors" } +] +requires-python = ">=3.11" +dependencies = [ + "anyio>=4.10.0", + "click>=8.0.0", + "jumpstarter", + "jumpstarter-driver-composite", + "jumpstarter-driver-network", + "jumpstarter-driver-ssh", +] + +[project.entry-points."jumpstarter.drivers"] +SSHMount = "jumpstarter_driver_ssh_mount.driver:SSHMount" + +[tool.hatch.version] +source = "vcs" +raw-options = { 'root' = '../../../'} + +[tool.hatch.metadata.hooks.vcs.urls] +Homepage = "https://jumpstarter.dev" +source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" + +[tool.pytest.ini_options] +addopts = "--cov --cov-report=html --cov-report=xml" +log_cli = true +log_cli_level = "INFO" +testpaths = ["jumpstarter_driver_ssh_mount"] +asyncio_mode = "auto" + +[build-system] +requires = ["hatchling", "hatch-vcs", "hatch-pin-jumpstarter"] +build-backend = "hatchling.build" + +[tool.hatch.build.hooks.pin_jumpstarter] +name = "pin_jumpstarter" + +[dependency-groups] +dev = [ + "pytest-cov>=6.0.0", + "pytest>=8.3.3", +] diff --git a/python/pyproject.toml b/python/pyproject.toml index dbecb9b3b..f60828624 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -36,6 +36,7 @@ jumpstarter-driver-tftp = { workspace = true } jumpstarter-driver-snmp = { workspace = true } jumpstarter-driver-shell = { workspace = true } jumpstarter-driver-ssh = { workspace = true } +jumpstarter-driver-ssh-mount = { workspace = true } jumpstarter-driver-uboot = { workspace = true } jumpstarter-driver-uds = { workspace = true } jumpstarter-driver-uds-can = { workspace = true }