diff --git a/docs/source/reference/package-apis/drivers/index.md b/docs/source/reference/package-apis/drivers/index.md index 661f56745..35bc68e72 100644 --- a/docs/source/reference/package-apis/drivers/index.md +++ b/docs/source/reference/package-apis/drivers/index.md @@ -80,6 +80,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 ```{toctree} :hidden: @@ -101,6 +102,7 @@ gpiod.md ridesx.md sdwire.md shell.md +ssh.md snmp.md tasmota.md tmt.md diff --git a/docs/source/reference/package-apis/drivers/ssh.md b/docs/source/reference/package-apis/drivers/ssh.md new file mode 120000 index 000000000..c4f9344cf --- /dev/null +++ b/docs/source/reference/package-apis/drivers/ssh.md @@ -0,0 +1 @@ +../../../../../packages/jumpstarter-driver-ssh/README.md \ No newline at end of file diff --git a/packages/jumpstarter-driver-ssh/README.md b/packages/jumpstarter-driver-ssh/README.md new file mode 100644 index 000000000..91b708751 --- /dev/null +++ b/packages/jumpstarter-driver-ssh/README.md @@ -0,0 +1,92 @@ +# SSHWrapper Driver + +`jumpstarter-driver-ssh` provides SSH CLI functionality for Jumpstarter, allowing you to run SSH commands with configurable defaults and pass-through arguments. + +## Installation + +```shell +pip3 install --extra-index-url https://pkg.jumpstarter.dev/simple/ jumpstarter-driver-ssh +``` + +## Configuration + +Example configuration: + +```yaml +export: + ssh: + type: jumpstarter_driver_ssh.driver.SSHWrapper + config: + default_username: "root" + ssh_command: "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + children: + tcp: + type: jumpstarter_driver_network.driver.TcpNetwork + config: + host: "192.168.1.100" + port: 22 +``` + +## Usage + +The SSH driver provides a CLI command that accepts all standard SSH arguments: + +```bash +# Basic SSH connection (uses port forwarding by default) +j ssh + +# SSH with direct TCP address +j ssh --direct + +# SSH with specific user +j ssh -l myuser + +# SSH with other flags +j ssh -i ~/.ssh/id_rsa + +# Running a remote command +j ssh ls -la + +``` + +## CLI Options + +The SSH command supports the following options: + +- `--direct`: Use direct TCP address (default is port forwarding) + +All other arguments are passed directly to the SSH command. The driver uses the configured SSH command and default username from the driver configuration. + +### Username Handling + +The driver supports multiple ways to specify the username: + +1. **`-l username` flag**: Explicit username specification (takes precedence) +2. **Default username**: Used when no username is specified in arguments + +If no `-l` flag or `user@hostname` format is provided, the default username from the driver configuration will be used automatically. + +## Dependencies + +- `ssh`: Standard SSH client (usually pre-installed) + +## API Reference + +### Driver Methods + +```{eval-rst} +.. autoclass:: jumpstarter_driver_ssh.client.SSHWrapperClient() + :members: run +``` + + +### Configuration Parameters + +| Parameter | Description | Type | Required | Default | +| ---------------- | ---------------------------------------------------------------------------------------------- | ---- | -------- | ------------------------------------------------------------------------------------------ | +| default_username | Default SSH username to use when no username is specified in the command | str | no | "" | +| ssh_command | SSH command to use for connections | str | no | "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR" | + +### Required Children + +- `tcp`: A TcpNetwork driver instance that provides the connection details (host and port) \ No newline at end of file diff --git a/packages/jumpstarter-driver-ssh/examples/exporter.yaml b/packages/jumpstarter-driver-ssh/examples/exporter.yaml new file mode 100644 index 000000000..baed49133 --- /dev/null +++ b/packages/jumpstarter-driver-ssh/examples/exporter.yaml @@ -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: "" +export: + ssh: + type: jumpstarter_driver_ssh.driver.SSHWrapper + config: + default_username: "core" + children: + tcp: + type: jumpstarter_driver_network.driver.TcpNetwork + config: + host: "192.168.1.3" + port: 22 + diff --git a/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/__init__.py b/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py b/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py new file mode 100644 index 000000000..ba7e55e7f --- /dev/null +++ b/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py @@ -0,0 +1,183 @@ +import shlex +import subprocess +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 + + +@dataclass(kw_only=True) +class SSHWrapperClient(CompositeClient): + """ + Client interface for SSHWrapper driver + + This client provides methods to interact with SSH connections via CLI + """ + + def cli(self, click_group): + @click_group.command(context_settings={"ignore_unknown_options": True}) + @click.option("--direct", is_flag=True, help="Use direct TCP address") + @click.argument("args", nargs=-1) + def ssh(direct, args): + """Run SSH command with arguments""" + result = self.run(direct, args) + self.logger.debug(f"SSH result: {result}") + if result != 0: + click.get_current_context().exit(result) + return result + + return ssh + + # wrap the underlying tcp stream connections, so we can still use tcp forwarding or + # the fabric driver adapter on top of client.ssh + def stream(self, method="connect"): + return self.tcp.stream(method) + + async def stream_async(self, method): + return await self.tcp.stream_async(method) + + def run(self, direct, args): + """Run SSH command with the given parameters and arguments""" + # Get SSH command and default username from driver + ssh_command = self.call("get_ssh_command") + default_username = self.call("get_default_username") + + if direct: + # Use direct TCP address + try: + address = self.tcp.address() # (format: "tcp://host:port") + 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(f"Using direct TCP connection for SSH - host: {host}, port: {port}") + return self._run_ssh_local(host, port, ssh_command, default_username, args) + except (DriverMethodNotImplemented, ValueError) as e: + self.logger.error(f"Direct address connection failed ({e}), falling back to SSH port forwarding") + return self.run(False, args) + else: + # Use SSH port forwarding (default behavior) + self.logger.debug("Using SSH port forwarding for SSH connection") + with TcpPortforwardAdapter( + client=self.tcp, + ) as addr: + host = addr[0] + port = addr[1] + self.logger.debug(f"SSH port forward established - host: {host}, port: {port}") + return self._run_ssh_local(host, port, ssh_command, default_username, args) + + def _run_ssh_local(self, host, port, ssh_command, default_username, args): + """Run SSH command with the given host, port, and arguments""" + # Build SSH command arguments + ssh_args = self._build_ssh_command_args(ssh_command, port, default_username, args) + + # Separate SSH options from command arguments + ssh_options, command_args = self._separate_ssh_options_and_command_args(args) + + # Build final SSH command + ssh_args = self._build_final_ssh_command(ssh_args, ssh_options, host, command_args) + + # Execute the command + return self._execute_ssh_command(ssh_args) + + def _build_ssh_command_args(self, ssh_command, port, default_username, args): + """Build initial SSH command arguments""" + # Split the SSH command into individual arguments + ssh_args = shlex.split(ssh_command) + + # Add port if specified + if port and port != 22: + ssh_args.extend(["-p", str(port)]) + + # Check if user already provided a username with -l flag in SSH options only + # We need to separate SSH options from command args first to avoid false positives + ssh_options, _ = self._separate_ssh_options_and_command_args(args) + has_user_flag = any( + ssh_options[i] == "-l" and i + 1 < len(ssh_options) + for i in range(len(ssh_options)) + ) + + # Add default username if no -l flag provided and we have a default + if not has_user_flag and default_username: + ssh_args.extend(["-l", default_username]) + + return ssh_args + + + def _separate_ssh_options_and_command_args(self, args): + """Separate SSH options from command arguments""" + # SSH flags that do not expect a parameter (simple flags) + ssh_flags_no_param = { + '-4', '-6', '-A', '-a', '-C', '-f', '-G', '-g', '-K', '-k', '-M', '-N', + '-n', '-q', '-s', '-T', '-t', '-V', '-v', '-X', '-x', '-Y', '-y' + } + + # SSH flags that do expect a parameter + ssh_flags_with_param = { + '-B', '-b', '-c', '-D', '-E', '-e', '-F', '-I', '-i', '-J', '-L', '-l', + '-m', '-O', '-o', '-P', '-p', '-Q', '-R', '-S', '-W', '-w' + } + + ssh_options = [] + command_args = [] + i = 0 + while i < len(args): + arg = args[i] + if arg.startswith('-'): + # Check if it's a known SSH option + if arg in ssh_flags_no_param: + # This is a simple SSH flag without parameter + ssh_options.append(arg) + elif arg in ssh_flags_with_param: + # This is an SSH flag that expects a parameter + ssh_options.append(arg) + # If this option takes a value, add the next argument too + if i + 1 < len(args) and not args[i + 1].startswith('-'): + ssh_options.append(args[i + 1]) + i += 1 + else: + # This is a command argument - everything from here on is part of the command + command_args = args[i:] + break + else: + # This is a command argument - everything from here on is part of the command + command_args = args[i:] + break + i += 1 + + # Debug output + self.logger.debug(f"SSH options: {ssh_options}") + self.logger.debug(f"Command args: {command_args}") + return ssh_options, command_args + + + def _build_final_ssh_command(self, ssh_args, ssh_options, host, command_args): + """Build the final SSH command with all components""" + # Add SSH options + ssh_args.extend(ssh_options) + + # Add hostname before command arguments + if host: + ssh_args.append(host) + + # Add command arguments + ssh_args.extend(command_args) + + self.logger.debug(f"Running SSH command: {ssh_args}") + return ssh_args + + def _execute_ssh_command(self, ssh_args): + """Execute the SSH command and return the result""" + try: + result = subprocess.run(ssh_args) + return result.returncode + except FileNotFoundError: + self.logger.error( + f"SSH command '{ssh_args[0]}' not found. Please ensure SSH is installed and available in PATH." + ) + return 127 # Standard exit code for "command not found" diff --git a/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py b/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py new file mode 100644 index 000000000..657e4e8f8 --- /dev/null +++ b/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass + +from jumpstarter.common.exceptions import ConfigurationError +from jumpstarter.driver import Driver, export + + +@dataclass(kw_only=True) +class SSHWrapper(Driver): + """SSH wrapper driver for Jumpstarter that provides SSH CLI functionality""" + + default_username: str = "" + ssh_command: str = "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR" + + 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") + + @classmethod + def client(cls) -> str: + return "jumpstarter_driver_ssh.client.SSHWrapperClient" + + @export + def get_default_username(self): + """Get default SSH username""" + return self.default_username + + @export + def get_ssh_command(self): + """Get the SSH command to use""" + return self.ssh_command diff --git a/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py b/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py new file mode 100644 index 000000000..4501828c9 --- /dev/null +++ b/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py @@ -0,0 +1,350 @@ +"""Tests for the SSH wrapper driver""" + +from unittest.mock import MagicMock, patch + +import pytest +from jumpstarter_driver_network.driver import TcpNetwork + +from jumpstarter_driver_ssh.driver import SSHWrapper + +from jumpstarter.common.exceptions import ConfigurationError +from jumpstarter.common.utils import serve + + +def test_ssh_wrapper_defaults(): + """Test SSH wrapper with default configuration""" + instance = SSHWrapper( + children={"tcp": TcpNetwork(host="127.0.0.1", port=22)}, + default_username="" + ) + + # Test that the instance was created correctly + assert instance.default_username == "" + assert instance.ssh_command.startswith("ssh") + + # Test that the client class is correct + assert instance.client() == "jumpstarter_driver_ssh.client.SSHWrapperClient" + + +def test_ssh_wrapper_configuration_error(): + """Test SSH wrapper raises error when tcp child is missing""" + with pytest.raises(ConfigurationError): + SSHWrapper( + children={}, # Missing tcp child + default_username="" + ) + + +def test_ssh_command_with_default_username(): + """Test SSH command execution with default username provided""" + instance = SSHWrapper( + children={"tcp": TcpNetwork(host="127.0.0.1", port=22)}, + default_username="testuser" + ) + + with serve(instance) as client: + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + # Test SSH command with default username + result = client.run(False, ["hostname"]) + + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument + + # Should include -l testuser + assert "-l" in call_args + assert "testuser" in call_args + assert call_args[call_args.index("-l") + 1] == "testuser" + + # Should include the actual hostname (127.0.0.1) at the end, and preserve "hostname" as a command + assert "127.0.0.1" in call_args + assert "hostname" in call_args # Should be preserved as command argument + + assert result == 0 + + +def test_ssh_command_without_default_username(): + """Test SSH command execution without default username""" + instance = SSHWrapper( + children={"tcp": TcpNetwork(host="127.0.0.1", port=22)}, + default_username="" + ) + + with serve(instance) as client: + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + # Test SSH command without default username + result = client.run(False, ["hostname"]) + + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument + + # Should NOT include -l flag + assert "-l" not in call_args + + # Should include the actual hostname (127.0.0.1) at the end, and preserve "hostname" as a command + assert "127.0.0.1" in call_args + assert "hostname" in call_args # Should be preserved as command argument + + assert result == 0 + + +def test_ssh_command_with_user_override(): + """Test SSH command execution with -l flag overriding default username""" + instance = SSHWrapper( + children={"tcp": TcpNetwork(host="127.0.0.1", port=22)}, + default_username="testuser" + ) + + with serve(instance) as client: + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + # Test SSH command with -l flag overriding default username + result = client.run(False, ["-l", "myuser", "hostname"]) + + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument + + # Should include -l myuser (not testuser) + assert "-l" in call_args + assert "myuser" in call_args + assert "testuser" not in call_args + assert call_args[call_args.index("-l") + 1] == "myuser" + + # Should include the actual hostname (127.0.0.1) at the end, and preserve "hostname" as a command + assert "127.0.0.1" in call_args + assert "hostname" in call_args # Should be preserved as command argument + + assert result == 0 + + +def test_ssh_command_with_port(): + """Test SSH command execution with custom port""" + instance = SSHWrapper( + children={"tcp": TcpNetwork(host="127.0.0.1", port=2222)}, + default_username="testuser" + ) + + with serve(instance) as client: + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + # Mock the TcpPortforwardAdapter to return the expected port + with patch('jumpstarter_driver_ssh.client.TcpPortforwardAdapter') as mock_adapter: + mock_adapter.return_value.__enter__.return_value = ("127.0.0.1", 2222) + mock_adapter.return_value.__exit__.return_value = None + + # Test SSH command with custom port + result = client.run(False, ["hostname"]) + + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument + + # Should include -p 2222 + assert "-p" in call_args + assert "2222" in call_args + assert call_args[call_args.index("-p") + 1] == "2222" + + # Should include -l testuser + assert "-l" in call_args + assert "testuser" in call_args + + # Should include the actual hostname (127.0.0.1) at the end + assert "127.0.0.1" in call_args + assert "hostname" in call_args # Should be preserved as command argument + + assert result == 0 + + +def test_ssh_command_with_direct_flag(): + """Test SSH command execution with --direct flag""" + instance = SSHWrapper( + children={"tcp": TcpNetwork(host="192.168.1.100", port=22)}, + default_username="testuser" + ) + + with serve(instance) as client: + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + # Mock the tcp.address() method + with patch.object(client.tcp, 'address', return_value="tcp://192.168.1.100:22"): + # Test SSH command with direct flag + result = client.run(True, ["hostname"]) + + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument + + # Should include -l testuser + assert "-l" in call_args + assert "testuser" in call_args + + # Should include the actual hostname (192.168.1.100) at the end, and preserve "hostname" as a command + assert "192.168.1.100" in call_args + assert "hostname" in call_args # Should be preserved as command argument + + assert result == 0 + + +def test_ssh_command_error_handling(): + """Test SSH command error handling when SSH is not found""" + instance = SSHWrapper( + children={"tcp": TcpNetwork(host="127.0.0.1", port=22)}, + default_username="" + ) + + with serve(instance) as client: + with patch('subprocess.run') as mock_run: + mock_run.side_effect = FileNotFoundError("SSH not found") + + # Test SSH command error handling + result = client.run(False, ["hostname"]) + + # Should return error code 127 + assert result == 127 + + +def test_ssh_command_with_multiple_ssh_options(): + """Test SSH command execution with multiple SSH options""" + instance = SSHWrapper( + children={"tcp": TcpNetwork(host="127.0.0.1", port=22)}, + default_username="" + ) + + with serve(instance) as client: + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + # Test SSH command with multiple SSH options + result = client.run(False, [ + "-o", "StrictHostKeyChecking=no", "-i", "/path/to/key", "command", "arg1", "arg2" + ]) + + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument + + # Should include SSH options + assert "-o" in call_args + assert "StrictHostKeyChecking=no" in call_args + assert "-i" in call_args + assert "/path/to/key" in call_args + + # Should include the actual hostname (127.0.0.1) at the end + assert "127.0.0.1" in call_args + # Should preserve command arguments + assert "command" in call_args + assert "arg1" in call_args + assert "arg2" in call_args + + assert result == 0 + + +def test_ssh_command_with_unknown_option_treated_as_command(): + """Test SSH command execution with unknown option treated as command""" + instance = SSHWrapper( + children={"tcp": TcpNetwork(host="127.0.0.1", port=22)}, + default_username="" + ) + + with serve(instance) as client: + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + # Test SSH command with unknown option + result = client.run(False, ["-l", "user", "-unknown", "command", "arg1"]) + + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument + + # Should include known SSH options + assert "-l" in call_args + assert "user" in call_args + + # Should include the actual hostname (127.0.0.1) at the end + assert "127.0.0.1" in call_args + # Should treat everything after -l user as command (including -unknown) + assert "-unknown" in call_args + assert "command" in call_args + assert "arg1" in call_args + + assert result == 0 + + +def test_ssh_command_with_no_ssh_options(): + """Test SSH command execution with no SSH options, all arguments are command""" + instance = SSHWrapper( + children={"tcp": TcpNetwork(host="127.0.0.1", port=22)}, + default_username="" + ) + + with serve(instance) as client: + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + # Test SSH command with no SSH options + result = client.run(False, ["command", "arg1", "arg2"]) + + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument + + # Should include the actual hostname (127.0.0.1) at the end + assert "127.0.0.1" in call_args + # Should preserve all command arguments + assert "command" in call_args + assert "arg1" in call_args + assert "arg2" in call_args + + assert result == 0 + + +def test_ssh_command_with_command_l_flag_does_not_interfere_with_username_injection(): + """Test that command -l flags don't interfere with SSH username injection""" + instance = SSHWrapper( + children={"tcp": TcpNetwork(host="127.0.0.1", port=22)}, + default_username="testuser" + ) + + with serve(instance) as client: + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + # Test SSH command with -l flag in the command (like ls -la -l ajo) + result = client.run(False, ["ls", "-la", "-l", "ajo"]) + + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument + + # Should include -l testuser (SSH login flag) + assert "-l" in call_args + assert "testuser" in call_args + assert call_args[call_args.index("-l") + 1] == "testuser" + + # Should include the actual hostname (127.0.0.1) at the end + assert "127.0.0.1" in call_args + + # Should preserve command arguments including the -l flag for ls + assert "ls" in call_args + assert "-la" in call_args + assert "-l" in call_args # This should be the ls -l flag, not SSH -l + assert "ajo" in call_args + + # Verify that the SSH -l flag comes before the hostname, and command -l comes after + ssh_l_index = call_args.index("-l") + hostname_index = call_args.index("127.0.0.1") + command_l_index = call_args.index("-l", ssh_l_index + 1) # Find second -l + + assert ssh_l_index < hostname_index < command_l_index + + assert result == 0 diff --git a/packages/jumpstarter-driver-ssh/pyproject.toml b/packages/jumpstarter-driver-ssh/pyproject.toml new file mode 100644 index 000000000..ee557bc96 --- /dev/null +++ b/packages/jumpstarter-driver-ssh/pyproject.toml @@ -0,0 +1,45 @@ +[project] +name = "jumpstarter-driver-ssh" +dynamic = ["version", "urls"] +description = "SSH wrapper driver for Jumpstarter that provides SSH CLI functionality" +readme = "README.md" +license = "Apache-2.0" +authors = [ + { name = "Miguel Angel Ajo Pelayo", email = "miguelangel@ajo.es" } +] +requires-python = ">=3.11" +dependencies = [ + "anyio>=4.10.0", + "click>=8.0.0", + "jumpstarter", + "jumpstarter-driver-composite", + "jumpstarter-driver-network", +] + +[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"] +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/pyproject.toml b/pyproject.toml index f4363a40e..c071dd65d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ jumpstarter-driver-tasmota = { workspace = true } jumpstarter-driver-tftp = { workspace = true } jumpstarter-driver-snmp = { workspace = true } jumpstarter-driver-shell = { workspace = true } +jumpstarter-driver-ssh = { workspace = true } jumpstarter-driver-uboot = { workspace = true } jumpstarter-driver-iscsi = { workspace = true } jumpstarter-driver-ustreamer = { workspace = true } diff --git a/uv.lock b/uv.lock index 49ec60cca..a17a76cef 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" [manifest] @@ -31,6 +31,7 @@ members = [ "jumpstarter-driver-sdwire", "jumpstarter-driver-shell", "jumpstarter-driver-snmp", + "jumpstarter-driver-ssh", "jumpstarter-driver-tasmota", "jumpstarter-driver-tftp", "jumpstarter-driver-tmt", @@ -1920,6 +1921,38 @@ dev = [ { name = "pytest-cov", specifier = ">=6.0.0" }, ] +[[package]] +name = "jumpstarter-driver-ssh" +source = { editable = "packages/jumpstarter-driver-ssh" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "jumpstarter" }, + { name = "jumpstarter-driver-composite" }, + { name = "jumpstarter-driver-network" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.10.0" }, + { name = "click", specifier = ">=8.0.0" }, + { name = "jumpstarter", editable = "packages/jumpstarter" }, + { name = "jumpstarter-driver-composite", editable = "packages/jumpstarter-driver-composite" }, + { name = "jumpstarter-driver-network", editable = "packages/jumpstarter-driver-network" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, +] + [[package]] name = "jumpstarter-driver-tasmota" source = { editable = "packages/jumpstarter-driver-tasmota" }