Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/guides/executors/slurm.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Launch tasks on a Slurm HPC cluster, optionally from your local machine over SSH

- Access to a Slurm cluster with Pyxis installed
- SSH key authentication set up (for remote launch via `SSHTunnel`)
- OpenSSH `ssh` and `scp` executables when using persistent connection multiplexing
- A container image accessible from the cluster (e.g. on a shared registry or pulled to the nodes)

## Executor configuration
Expand All @@ -20,6 +21,11 @@ ssh_tunnel = run.SSHTunnel(
user="your-username",
job_dir="/scratch/your-username/nemo-runs", # where NeMo-Run stores metadata on the cluster
identity="~/.ssh/id_ed25519", # optional SSH key path
# Opt into OpenSSH and allow NeMo Run to create a persistent master.
use_openssh=True,
control_persist="10m",
# Optional override; creation mode defaults to ~/.nemo_run/.ssh/control-%C.
# control_path="~/.ssh/nemo-run-%C",
)

executor = run.SlurmExecutor(
Expand All @@ -38,6 +44,44 @@ executor = run.SlurmExecutor(

Use `run.LocalTunnel()` instead of `SSHTunnel` when launching from a login node directly.

### Persistent SSH multiplexing

`use_openssh` is configured on the `SSHTunnel` passed to `SlurmExecutor`. In creation mode,
`control_persist` accepts an OpenSSH duration such as `"10m"`; NeMo Run reuses a compatible master
or starts one with that lifetime. The master is a separate process, so later NeMo Run invocations
can reuse it until it has had no clients for the configured duration.

For MFA-protected hosts, create the authenticated master yourself and prevent NeMo Run from opening
a new connection that could prompt unexpectedly:

```python
ssh_tunnel = run.SSHTunnel(
host="login-ptyche",
user="your-username",
job_dir="/scratch/your-username/nemo-runs",
use_openssh=True,
require_existing_master=True,
)
```

This mode runs `ssh -O check` and only reuses an existing master. It never creates one. Configure
`ControlMaster`, `ControlPath`, and `ControlPersist` in `~/.ssh/config`, then start the master (for
example, `ssh -fN login-ptyche`) before launching NeMo Run. If no master exists, NeMo Run fails with
an actionable startup command.

Existing-master mode reads `ControlPath`, `ControlPersist`, and other connection settings from
OpenSSH configuration unless explicitly overridden. Creation mode defaults to the stable
`~/.nemo_run/.ssh/control-%C` path when `control_path` is omitted. Keep an override stable,
include a token such as `%C` for multiple destinations, and place it in a directory owned by the
current user that is not group/world-writable and does not traverse symlinks.

This mode requires working `ssh` and `scp` executables and key-based, agent-based, or otherwise
non-interactive OpenSSH authentication. Configuration errors and OpenSSH connection failures are
reported directly; NeMo Run does **not** dynamically fall back to Paramiko after multiplexing is
selected. Omit `use_openssh`, `control_persist`, and `require_existing_master` to retain the existing
in-process Fabric/Paramiko behavior. For backward compatibility, setting `control_persist` also
selects OpenSSH creation mode.

Key parameters:

| Parameter | Description |
Expand All @@ -50,6 +94,10 @@ Key parameters:
| `container_image` | Container image URI |
| `time` | Wall-time limit (`"HH:MM:SS"`) |
| `tunnel` | `SSHTunnel` (remote) or `LocalTunnel` (on-cluster) |
| `use_openssh` | Select the OpenSSH backend instead of Fabric/Paramiko |
| `require_existing_master` | Reuse a pre-authenticated master and never create a connection |
| `control_persist` | Lifetime applied only when NeMo Run creates a master |
| `control_path` | Optional `ControlPath` override |
| `packager` | Code packaging strategy |

## E2E workflow
Expand Down
221 changes: 218 additions & 3 deletions nemo_run/core/tunnel/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (c) 2024-2025, NVIDIA CORPORATION.
# Copyright (c) 2024-2026, NVIDIA CORPORATION.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand All @@ -15,6 +15,7 @@
import getpass
import logging
import os
import shlex
import shutil
import socket
import subprocess
Expand Down Expand Up @@ -166,13 +167,190 @@ def cleanup(self):
self.session.clear()


class _OpenSSHSession:
"""Fabric-compatible subset backed by a persistent OpenSSH control master."""

def __init__(
self,
*,
host: str,
user: str,
port: Optional[int],
identity: Optional[str],
control_persist: Optional[str],
control_path: Optional[str],
require_existing_master: bool,
):
self.host = host
self.user = user
self.port = port
self.connect_kwargs = {"key_filename": [identity]} if identity else {}
self.control_persist = control_persist
self.control_path = os.path.expanduser(control_path) if control_path else None
self.require_existing_master = require_existing_master
self._context = Context()

@property
def _target(self) -> str:
return f"{self.user}@{self.host}"

@property
def _scp_target(self) -> str:
host = f"[{self.host}]" if self.host.count(":") > 1 else self.host
return f"{self.user}@{host}"

@property
def _control_options(self) -> list[str]:
options: list[str] = []
if self.control_persist:
options.extend(
["-o", "ControlMaster=auto", "-o", f"ControlPersist={self.control_persist}"]
)
if self.control_path:
options.extend(["-o", f"ControlPath={self.control_path}"])
if self.require_existing_master:
# A vanished master must fail instead of opening a direct connection or prompting.
options.extend(
[
"-o",
"ControlMaster=no",
"-o",
"BatchMode=yes",
"-o",
"ProxyCommand=false",
]
)
return options

def _connection_options(self, executable: str) -> list[str]:
options = [*self._control_options]
if self.port is not None:
port_flag = "-P" if executable == "scp" else "-p"
options.extend([port_flag, str(self.port)])
if self.connect_kwargs:
options.extend(["-i", self.connect_kwargs["key_filename"][0]])
return options

@property
def ssh_options(self) -> str:
"""Options which let rsync reuse this session's control master."""
return shlex.join(self._control_options)

@property
def is_connected(self) -> bool:
result = self._context.run(
self._command("ssh", "-O", "check", self._target), hide=True, warn=True
)
return result.ok

def _command(self, executable: str, *args: str) -> str:
return shlex.join([executable, *self._connection_options(executable), *args])

def _master_start_command(self) -> str:
options: list[str] = []
if self.control_path:
options.extend(["-o", f"ControlPath={self.control_path}"])
if self.port is not None:
options.extend(["-p", str(self.port)])
if self.connect_kwargs:
options.extend(["-i", self.connect_kwargs["key_filename"][0]])
return shlex.join(["ssh", *options, "-o", "ControlMaster=yes", "-fN", self._target])

def _prepare_control_directory(self) -> None:
assert self.control_path
parent = Path(self.control_path).parent
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
resolved_parent = parent.resolve(strict=True)
if parent.absolute() != resolved_parent:
raise RuntimeError(
f"OpenSSH control socket directory must not contain symlinks: {parent}"
)
directory_mode = resolved_parent.stat()
if directory_mode.st_uid != os.getuid() or directory_mode.st_mode & 0o022:
raise RuntimeError(
f"OpenSSH control socket directory must be owned by the current user and not "
f"group/world-writable: {resolved_parent}"
)

def open(self) -> None:
if self.is_connected:
return
if self.require_existing_master:
raise RuntimeError(
f"No existing OpenSSH control master found for {self._target}. "
f"Start one first (for example, `{self._master_start_command()}`) and retry."
)
assert self.control_persist
if self.control_path:
self._prepare_control_directory()
self._context.run(self._command("ssh", "-fN", self._target), hide=False)

def run(self, command: str, hide: bool = True, warn: bool = False, **kwargs) -> RunResult:
self.open()
return self._context.run(
self._command("ssh", self._target, command), hide=hide, warn=warn, **kwargs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@svcnemo-autobot Security/policy gap: reuse-only mode can still fall back to a new connection. ssh -O check and this ordinary ssh invocation are separate processes. If the master disappears between them, the documented ControlMaster auto configuration permits the second process to connect normally, potentially displaying an unexpected MFA prompt and creating another master. The same check/use gap applies to SCP and rsync. At minimum, force BatchMode=yes for reuse-only operations so they cannot prompt; strict reuse-only behavior must also prevent direct-connection fallback. Please add tests where the master disappears after the check but before command, SCP, and rsync execution.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 This policy gap was addressed before merge in head 89dedd7b1d3390d6d76440a7be58bf7ea760b5d8. Reuse-only command, SCP, and rsync operations apply ControlMaster=no, BatchMode=yes, and ProxyCommand=false, so a vanished master cannot prompt, create another master, or open a direct connection. Regression coverage verifies these options across command, SCP, and rsync paths. PR #583 is merged.

)

def local(self, command: str, hide: bool = True, **kwargs) -> RunResult:
return self._context.run(command, hide=hide, **kwargs)

def put(self, local_path: str, remote_path: str) -> None:
self.open()
self._context.run(
self._command("scp", local_path, f"{self._scp_target}:{remote_path}"), hide=True
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@svcnemo-autobot These SCP targets do not bracket IPv6 hosts. For a host such as 2001:db8::1, user@host:path is ambiguous and SCP interprets the first colon as the path separator. The existing rsync path already handles IPv6, so multiplex mode introduces a regression. Please construct IPv6 SCP targets as user@[host]:path and add put/get coverage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Agreed. Multiplex mode must preserve existing IPv6 transfer behavior. I’ll bracket IPv6 SCP hosts for both put/get and add coverage alongside the stale-control-master expiration fix. The review workstation is still initializing.


def get(self, remote_path: str, local_path: str) -> None:
self.open()
self._context.run(
self._command("scp", f"{self._scp_target}:{remote_path}", local_path), hide=True
)

def forward_local(
self,
local_port: int,
remote_port: Optional[int] = None,
remote_host: str = "localhost",
local_host: str = "localhost",
):
self.open()
forward = f"{local_host}:{local_port}:{remote_host}:{remote_port or local_port}"
start = self._command("ssh", "-O", "forward", "-L", forward, self._target)
stop = self._command("ssh", "-O", "cancel", "-L", forward, self._target)

class ForwardContext:
def __enter__(self):
self._session.open()
self._session._context.run(start, hide=True)
return self

def __exit__(self, *_):
self._session.open()
self._session._context.run(stop, hide=True, warn=True)

def __init__(self, session):
self._session = session

return ForwardContext(self)

def close(self) -> None:
# The control master intentionally outlives this Python process. OpenSSH exits it after
# ControlPersist has elapsed without clients; later operations probe the socket first.
pass


@dataclass(kw_only=True)
class SSHTunnel(Tunnel):
"""
SSH Tunnel for supported executors.
Currently only supports SlurmExecutor.

Uses key based authentication if *identity* is provided else password authentication.
Set *use_openssh* to multiplex commands and transfers through an OpenSSH control master.
Set *require_existing_master* to reuse a master configured and started outside NeMo Run without
ever creating a connection. Otherwise, *control_persist* specifies the lifetime of a master
which NeMo Run may create. Without *use_openssh* or *control_persist*, the existing in-process
Fabric/Paramiko connection is used.

Examples
--------
Expand All @@ -188,7 +366,9 @@ class SSHTunnel(Tunnel):
host=os.environ["ANOTHER_SSH_HOST"],
user=os.environ["ANOTHER_SSH_USER"],
job_dir=os.environ["ANOTHER_REMOTE_JOBDIR"],
identity="path_to_private_key"
identity="path_to_private_key",
use_openssh=True,
control_persist="10m",
)

"""
Expand All @@ -199,8 +379,28 @@ class SSHTunnel(Tunnel):
identity: Optional[str] = None
shell: Optional[str] = None
pre_command: Optional[str] = None
use_openssh: bool = False
require_existing_master: bool = False
control_persist: Optional[str] = None
control_path: Optional[str] = None

def __post_init__(self):
if self.control_persist:
self.use_openssh = True
if self.require_existing_master and not self.use_openssh:
raise ValueError("require_existing_master requires use_openssh")
if self.require_existing_master and self.control_persist:
raise ValueError("require_existing_master cannot be combined with control_persist")
if self.use_openssh and not self.require_existing_master and not self.control_persist:
raise ValueError("OpenSSH master creation requires control_persist")
if self.control_path and not self.use_openssh:
raise ValueError("control_path requires use_openssh")
if self.control_persist == "":
raise ValueError("control_persist must not be empty")
if self.use_openssh and not shutil.which("ssh"):
raise RuntimeError("OpenSSH multiplexing requires the ssh executable")
if self.use_openssh and not shutil.which("scp"):
raise RuntimeError("OpenSSH multiplexing requires the scp executable")
self.console = CONSOLE
self.session = None
self.auth_handler: Callable = authentication_handler
Expand All @@ -224,8 +424,23 @@ def _create_job_dir(self, tunnel: Tunnel):
tunnel.run(command)

def connect(self):
if self.use_openssh and not self.session:
self.session = _OpenSSHSession(
host=self.host,
user=self.user,
port=self.port,
identity=self.identity,
control_persist=self.control_persist,
control_path=self.control_path
or (
None
if self.require_existing_master
else os.path.join(get_nemorun_home(), ".ssh", "control-%C")
),
require_existing_master=self.require_existing_master,
)
if not (self.session and self.session.is_connected):
self._authenticate()
self.session.open() if self.use_openssh else self._authenticate()

def _check_connect(self):
if not (self.session and self.session.is_connected):
Expand Down
Loading
Loading