-
Notifications
You must be signed in to change notification settings - Fork 19
Add jumpstarter-driver-ssh-mount package for remote filesystem mounting #434
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
2f57133
102d337
b9d8473
133978b
b221596
e1d1d66
9f20741
058cf54
9feb62e
35ddb82
4d3f152
0bea7c6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ../../../../../packages/jumpstarter-driver-ssh-mount/README.md | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| __pycache__/ | ||
| .coverage | ||
| coverage.xml |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| # 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**: `brew install macfuse && brew install sshfs` | ||
mangelajo marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| ## Configuration | ||
|
|
||
| Example exporter configuration: | ||
|
|
||
| ```yaml | ||
| export: | ||
| ssh-mount: | ||
| type: jumpstarter_driver_ssh_mount.driver.SSHMount | ||
| 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 | ||
| ``` | ||
|
|
||
| ## CLI Usage | ||
|
|
||
| Inside a `jmp shell` session: | ||
|
|
||
| ```shell | ||
| # Mount remote filesystem | ||
| j ssh-mount mount /local/mountpoint | ||
| j ssh-mount mount /local/mountpoint -r /remote/path | ||
| j ssh-mount mount /local/mountpoint --direct | ||
|
|
||
| # Unmount | ||
| j ssh-mount umount /local/mountpoint | ||
| j ssh-mount umount /local/mountpoint --lazy | ||
| ``` | ||
mangelajo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| ## API Reference | ||
|
|
||
| ### SSHMountClient | ||
|
|
||
| - `mount(mountpoint, *, remote_path="/", direct=False, extra_args=None)` - Mount remote filesystem locally via sshfs | ||
| - `umount(mountpoint, *, lazy=False)` - Unmount a previously mounted sshfs filesystem | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| apiVersion: jumpstarter.dev/v1alpha1 | ||
| kind: ExporterConfig | ||
| metadata: | ||
| namespace: default | ||
| name: demo | ||
| endpoint: grpc.jumpstarter.192.168.0.203.nip.io:8082 | ||
| token: "<token>" | ||
| export: | ||
| ssh-mount: | ||
| type: jumpstarter_driver_ssh_mount.driver.SSHMount | ||
| config: | ||
| default_username: "root" | ||
| # ssh_identity_file: "/path/to/key" | ||
| children: | ||
| tcp: | ||
| type: jumpstarter_driver_network.driver.TcpNetwork | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's better to use here an SSH driver instead, in that way the ssh_identity file and other settings are already provided and we don't need to do that here as well.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done -- updated the exporter config to reference the SSH driver directly via |
||
| config: | ||
| host: "192.168.1.100" | ||
| port: 22 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| 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_group | ||
|
|
||
|
|
||
| @dataclass(kw_only=True) | ||
| class SSHMountClient(CompositeClient): | ||
| """ | ||
| Client interface for SSHMount driver | ||
|
|
||
| This client provides mount/umount commands for remote filesystem | ||
| mounting via sshfs. | ||
| """ | ||
|
|
||
| def cli(self): | ||
| @driver_click_group(self) | ||
| def ssh_mount(): | ||
| """SSHFS mount/umount commands for remote filesystems""" | ||
| pass | ||
|
|
||
| @ssh_mount.command("mount") | ||
| @click.argument("mountpoint", type=click.Path()) | ||
| @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("--extra-args", "-o", multiple=True, help="Extra arguments to pass to sshfs") | ||
| def mount_cmd(mountpoint, remote_path, direct, extra_args): | ||
| """Mount remote filesystem locally via sshfs""" | ||
| self.mount(mountpoint, remote_path=remote_path, direct=direct, extra_args=list(extra_args)) | ||
|
|
||
| @ssh_mount.command("umount") | ||
| @click.argument("mountpoint", type=click.Path(exists=True)) | ||
| @click.option("--lazy", "-l", is_flag=True, help="Lazy unmount (detach filesystem now, clean up later)") | ||
| def umount_cmd(mountpoint, lazy): | ||
| """Unmount a previously mounted sshfs filesystem""" | ||
| self.umount(mountpoint, lazy=lazy) | ||
|
|
||
| return ssh_mount | ||
|
|
||
| @property | ||
| def identity(self) -> str | None: | ||
| """ | ||
| Get the SSH identity (private key) as a string. | ||
|
|
||
| Returns: | ||
| The SSH identity key content, or None if not configured. | ||
| """ | ||
| return self.call("get_ssh_identity") | ||
|
|
||
| @property | ||
| def username(self) -> str: | ||
| """Get the default SSH username""" | ||
| return self.call("get_default_username") | ||
|
|
||
| def mount(self, mountpoint, *, remote_path="/", direct=False, extra_args=None): | ||
| """Mount remote filesystem locally via sshfs | ||
|
|
||
| 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 | ||
| extra_args: Extra arguments to pass to sshfs | ||
| """ | ||
| # Verify sshfs is available | ||
| 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: brew install macfuse && brew install sshfs" | ||
| ) | ||
|
|
||
| # Create mountpoint directory if it doesn't exist | ||
| os.makedirs(mountpoint, exist_ok=True) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. never cleaned up
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed -- identity files are cleaned up in the |
||
|
|
||
| if direct: | ||
| try: | ||
| address = self.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) | ||
| 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, extra_args=extra_args) | ||
| else: | ||
| self.logger.debug("Using SSH port forwarding for sshfs connection") | ||
| with TcpPortforwardAdapter(client=self.tcp) as addr: | ||
| host, port = addr | ||
| self.logger.debug("SSH port forward established - host: %s, port: %s", host, port) | ||
| self._run_sshfs(host, port, mountpoint, remote_path, extra_args) | ||
|
|
||
| def _run_sshfs(self, host, port, mountpoint, remote_path, extra_args=None): | ||
| """Run sshfs to mount remote filesystem""" | ||
|
||
| identity_file = self._create_temp_identity_file() | ||
|
|
||
| try: | ||
| sshfs_args = self._build_sshfs_args(host, port, mountpoint, remote_path, identity_file, extra_args) | ||
| self.logger.debug("Running sshfs command: %s", sshfs_args) | ||
|
|
||
| result = subprocess.run(sshfs_args, capture_output=True, text=True) | ||
|
||
| result = self._retry_sshfs_without_allow_other(result, sshfs_args) | ||
|
|
||
| if result.returncode != 0: | ||
| stderr = result.stderr.strip() | ||
| raise click.ClickException( | ||
| f"sshfs mount failed (exit code {result.returncode}): {stderr}" | ||
| ) | ||
|
|
||
| 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}") | ||
| click.echo(f"To unmount: j ssh-mount umount {mountpoint}") | ||
| except click.ClickException: | ||
| raise | ||
| except Exception as e: | ||
| raise click.ClickException(f"Failed to mount: {e}") from e | ||
| finally: | ||
| if identity_file: | ||
| self.logger.info( | ||
| "Temporary SSH key file %s will persist until unmount. " | ||
| "It has permissions 0600.", | ||
| identity_file, | ||
| ) | ||
|
||
|
|
||
| def _build_sshfs_args(self, host, port, mountpoint, remote_path, identity_file, extra_args): | ||
| """Build the sshfs command arguments""" | ||
| 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 _retry_sshfs_without_allow_other(self, result, sshfs_args): | ||
| """Retry sshfs without allow_other if it failed due to that option""" | ||
| if result.returncode != 0 and "allow_other" in result.stderr: | ||
| self.logger.debug("Retrying sshfs without allow_other option") | ||
| sshfs_args = [arg for arg in sshfs_args if arg != "allow_other"] | ||
| return subprocess.run(sshfs_args, capture_output=True, text=True) | ||
|
||
| return result | ||
|
|
||
| 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 umount(self, mountpoint, *, lazy=False): | ||
|
||
| """Unmount a previously mounted sshfs filesystem | ||
|
|
||
| Args: | ||
| mountpoint: Local mount point to unmount | ||
| lazy: If True, use lazy unmount | ||
| """ | ||
| mountpoint = os.path.realpath(mountpoint) | ||
|
|
||
| # Try fusermount first (Linux), fall back to umount (macOS) | ||
| fusermount = self._find_executable("fusermount3") or self._find_executable("fusermount") | ||
| if fusermount: | ||
| cmd = [fusermount, "-u"] | ||
| if lazy: | ||
| cmd.append("-z") | ||
| cmd.append(mountpoint) | ||
| else: | ||
| cmd = ["umount"] | ||
| if lazy: | ||
| cmd.append("-l") | ||
| cmd.append(mountpoint) | ||
|
|
||
| self.logger.debug("Running unmount command: %s", cmd) | ||
| result = subprocess.run(cmd, capture_output=True, text=True) | ||
|
|
||
| if result.returncode != 0: | ||
| stderr = result.stderr.strip() | ||
| raise click.ClickException(f"Unmount failed (exit code {result.returncode}): {stderr}") | ||
|
|
||
| click.echo(f"Unmounted {mountpoint}") | ||
|
|
||
| @staticmethod | ||
| def _find_executable(name): | ||
| """Find an executable in PATH, return full path or None""" | ||
| import shutil | ||
| return shutil.which(name) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
|
|
||
| from jumpstarter.common.exceptions import ConfigurationError | ||
| from jumpstarter.driver import Driver, export | ||
|
|
||
|
|
||
| @dataclass(kw_only=True) | ||
| class SSHMount(Driver): | ||
| """SSHFS mount/umount driver for Jumpstarter | ||
|
|
||
| This driver provides remote filesystem mounting via sshfs. | ||
| It requires a 'tcp' child driver for network connectivity to the SSH server. | ||
| """ | ||
|
|
||
| default_username: str = "" | ||
| ssh_identity: str | None = None | ||
| ssh_identity_file: str | None = None | ||
|
|
||
| def __post_init__(self): | ||
| if hasattr(super(), "__post_init__"): | ||
| super().__post_init__() | ||
|
|
||
| if "tcp" not in self.children: | ||
| raise ConfigurationError("'tcp' child is required via ref, or directly as a TcpNetwork driver instance") | ||
|
|
||
| if self.ssh_identity and self.ssh_identity_file: | ||
| raise ConfigurationError("Cannot specify both ssh_identity and ssh_identity_file") | ||
|
|
||
| @classmethod | ||
| def client(cls) -> str: | ||
| return "jumpstarter_driver_ssh_mount.client.SSHMountClient" | ||
|
|
||
| @export | ||
| def get_default_username(self): | ||
| """Get default SSH username""" | ||
| return self.default_username | ||
|
|
||
| @export | ||
| def get_ssh_identity(self): | ||
| """Get the SSH identity key content""" | ||
| if self.ssh_identity is None and self.ssh_identity_file: | ||
| try: | ||
| self.ssh_identity = Path(self.ssh_identity_file).read_text() | ||
| except Exception as e: | ||
| raise ConfigurationError(f"Failed to read ssh_identity_file '{self.ssh_identity_file}': {e}") from None | ||
| return self.ssh_identity |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you need to add this one to the driver index or docs compilation will fail.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done -- added
ssh-mount.mdto both the Utility Drivers listing and thetoctreedirective in the drivers index.