Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit a5fa019

Browse files
committed
feat(driver-ssh): add --user selection to j ssh
Allow `j ssh -u someuser` to be able to select the user with which to connect to the board. Signed-off-by: Albert Esteve <aesteve@redhat.com>
1 parent 5300757 commit a5fa019

2 files changed

Lines changed: 75 additions & 11 deletions

File tree

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,18 @@ def cli(self):
6262
help="Run SSH command with arguments",
6363
)
6464
@click.option("--direct", is_flag=True, help="Use direct TCP address")
65+
@click.option("-u", "--user", help="Username to use for SSH connection")
6566
@click.argument("args", nargs=-1)
66-
def ssh(direct, args):
67+
def ssh(direct, user, args):
68+
"""Run SSH command with arguments."""
6769
options = SSHCommandRunOptions(
6870
direct=direct,
6971
# For the CLI, we never capture output so that interactive shells
7072
# and long-running commands stream their output directly.
7173
capture_output=False,
7274
)
7375

74-
result = self.run(options, args)
76+
result = self.run(options, args, user=user)
7577
self.logger.debug("SSH exit code: %s", result.return_code)
7678

7779
if result.stdout:
@@ -118,8 +120,15 @@ def username(self) -> str:
118120
"""Get the default SSH username"""
119121
return self.call("get_default_username")
120122

121-
def run(self, options: SSHCommandRunOptions, args) -> SSHCommandRunResult:
122-
"""Run SSH command with the given parameters and arguments"""
123+
def run(self, options: SSHCommandRunOptions, args, user: str | None = None) -> SSHCommandRunResult:
124+
"""
125+
Run SSH command with the given parameters and arguments
126+
127+
Args:
128+
options: SSH command run options.
129+
args: Command arguments.
130+
user: Optional username to override the default.
131+
"""
123132
# Get SSH command and default username from driver
124133
if options.direct:
125134
# Use direct TCP address
@@ -131,14 +140,14 @@ def run(self, options: SSHCommandRunOptions, args) -> SSHCommandRunResult:
131140
if not host or not port:
132141
raise ValueError(f"Invalid address format: {address}")
133142
self.logger.debug("Using direct TCP connection for SSH - host: %s, port: %s", host, port)
134-
return self._run_ssh_local(host, port, options, args)
143+
return self._run_ssh_local(host, port, options, args, user)
135144
except (DriverMethodNotImplemented, ValueError) as e:
136145
self.logger.error("Direct address connection failed (%s), falling back to SSH port forwarding", e)
137146
return self.run(SSHCommandRunOptions(
138147
direct=False,
139148
capture_output=options.capture_output,
140149
capture_as_text=options.capture_as_text,
141-
), args)
150+
), args, user=user)
142151
else:
143152
# Use SSH port forwarding (default behavior)
144153
self.logger.debug("Using SSH port forwarding for SSH connection")
@@ -147,9 +156,9 @@ def run(self, options: SSHCommandRunOptions, args) -> SSHCommandRunResult:
147156
) as addr:
148157
host, port = addr
149158
self.logger.debug("SSH port forward established - host: %s, port: %s", host, port)
150-
return self._run_ssh_local(host, port, options, args)
159+
return self._run_ssh_local(host, port, options, args, user)
151160

152-
def _run_ssh_local(self, host, port, options, args):
161+
def _run_ssh_local(self, host, port, options, args, user: str | None = None):
153162
"""Run SSH command with the given host, port, and arguments"""
154163
# Create temporary identity file if needed
155164
ssh_identity = self.identity
@@ -175,7 +184,7 @@ def _run_ssh_local(self, host, port, options, args):
175184

176185
try:
177186
# Build SSH command arguments
178-
ssh_args = self._build_ssh_command_args(port, identity_file, args)
187+
ssh_args = self._build_ssh_command_args(port, identity_file, args, user)
179188

180189
# Separate SSH options from command arguments
181190
ssh_options, command_args = self._separate_ssh_options_and_command_args(args)
@@ -194,11 +203,11 @@ def _run_ssh_local(self, host, port, options, args):
194203
except Exception as e:
195204
self.logger.warning("Failed to clean up temporary identity file %s: %s", identity_file, str(e))
196205

197-
def _build_ssh_command_args(self, port, identity_file, args):
206+
def _build_ssh_command_args(self, port, identity_file, args, user: str | None = None):
198207
"""Build initial SSH command arguments"""
199208
# Split the SSH command into individual arguments
200209
ssh_args = shlex.split(self.command)
201-
default_username = self.username
210+
default_username = user or self.username
202211

203212
# Add identity file if provided
204213
if identity_file:

packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,61 @@ def test_ssh_command_without_default_username():
105105
assert result.stdout == "some stdout"
106106

107107

108+
def test_ssh_command_with_explicit_user_parameter():
109+
"""Test SSH command execution with the user parameter overriding the default."""
110+
instance = SSHWrapper(
111+
children={"tcp": TcpNetwork(host="127.0.0.1", port=22)},
112+
default_username="testuser",
113+
)
114+
115+
with serve(instance) as client:
116+
with patch("subprocess.run") as mock_run:
117+
mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="")
118+
119+
# Call run with an explicit user.
120+
result = client.run(SSHCommandRunOptions(direct=False), ["hostname"], user="overrideuser")
121+
assert isinstance(result, SSHCommandRunResult)
122+
123+
# Verify subprocess.run was called.
124+
assert mock_run.called
125+
call_args = mock_run.call_args[0][0]
126+
127+
# Check that the override user is present.
128+
assert "-l" in call_args
129+
assert "overrideuser" in call_args
130+
assert "testuser" not in call_args
131+
assert call_args[call_args.index("-l") + 1] == "overrideuser"
132+
133+
assert "127.0.0.1" in call_args
134+
assert "hostname" in call_args
135+
136+
assert result.return_code == 0
137+
assert result.stdout == "some stdout"
138+
139+
140+
def test_ssh_command_with_explicit_user_parameter_fallback():
141+
"""Test that user parameter is preserved during direct-to-portforward fallback."""
142+
instance = SSHWrapper(
143+
children={"tcp": TcpNetwork(host="127.0.0.1", port=22)},
144+
default_username="testuser",
145+
)
146+
147+
with serve(instance) as client:
148+
with patch("subprocess.run") as mock_run:
149+
mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="")
150+
151+
# Mock tcp.address() to fail, triggering fallback
152+
with patch.object(client.tcp, 'address', side_effect=ValueError("Connection failed")):
153+
client.run(SSHCommandRunOptions(direct=True), ["hostname"], user="overrideuser")
154+
155+
# Verify that overrideuser is still used after fallback
156+
call_args = mock_run.call_args[0][0]
157+
assert "-l" in call_args
158+
assert "overrideuser" in call_args
159+
assert "testuser" not in call_args
160+
assert call_args[call_args.index("-l") + 1] == "overrideuser"
161+
162+
108163
def test_ssh_command_with_user_override():
109164
"""Test SSH command execution with -l flag overriding default username"""
110165
instance = SSHWrapper(

0 commit comments

Comments
 (0)