Source code for romtools.hpc.dispatchers.remote_dispatcher
import posixpath as ppath
import shlex
from typing import Optional
from romtools.hpc.logger import Logger
from romtools.hpc.connection import Connection, Result
from romtools.hpc.dispatchers.base_dispatcher import BaseDispatcher
from romtools.hpc.components.caller import RemoteCaller
from romtools.hpc.components.file_manager import RemoteFileManager
from romtools.hpc.components.slurm_job_manager import SlurmJobManager
from romtools.hpc.components.transfer_manager import RemoteTransferManager
def _normalize_campaign_directory(campaign_directory: str) -> str:
"""The campaign directory as a clean relative path, mirrored on both hosts."""
cleaned = ppath.normpath((campaign_directory or "").strip())
if not cleaned or cleaned == ".":
raise ValueError("The campaign directory must name a directory.")
if ppath.isabs(cleaned) or cleaned == ".." or cleaned.startswith("../"):
raise ValueError(
f"The campaign directory must be relative to the remote root (received: "
f"{campaign_directory}). This workflow also creates the same directory "
"on the local machine."
)
return cleaned
[docs]
class RemoteDispatcher(BaseDispatcher):
"""
Runs ROM workflows on a remote host over SSH, connecting as user@remote:port.
Coordinates composed helpers rather than doing the work itself: files go through
RemoteFileManager, Python calls through RemoteCaller, batch jobs through
SlurmJobManager, and archives through RemoteTransferManager. Paths are taken relative
to the configured remote root.
Arguments:
logger: An instance of the Logger class for logging
campaign_directory: An optional string naming the directory this campaign
runs in, relative to the remote root. It is mirrored locally and
under the remote root.
config: A YAML path or a Configuration. Defaults to reading the command line.
"""
def __init__(self, campaign_directory: str = "hpctools", logger: Logger = None,
connection: Optional[Connection] = None, config=None):
# Normalized before super() so every component is built with the same value
super().__init__(_normalize_campaign_directory(campaign_directory), logger, config=config)
# Core members
self.conn : Optional[Connection] = None
# Confirm that connection is possible
if not self.config.get("remote") or not self.config.get("user"):
raise ValueError("Remote host and user must be specified in the configuration to use RemoteDispatcher.")
# Establish connection, or use the one provided (e.g. by tests)
if connection is not None:
self.conn = connection
self.logger.set_hostname(self.conn.host)
else:
self._connect_to_remote()
# Composed sub-classes
self.files = RemoteFileManager(
connection=self.conn,
config=self.config,
logger=self.logger)
self.caller = RemoteCaller(
connection=self.conn,
config=self.config,
logger=self.logger,
files=self.files)
self.slurm = SlurmJobManager(
run_cmd=self.conn.run,
config=self.config,
logger=self.logger,
campaign_directory=self.campaign_directory,
files=self.files)
self.transfer = RemoteTransferManager(
connection=self.conn,
config=self.config,
logger=self.logger,
campaign_directory=self.campaign_directory,
files=self.files)
# ------------------------------------------------------------------
# Initialization and setup
# ------------------------------------------------------------------
def _connect_to_remote(self) -> None:
"""Establish the SSH connection described by the configuration."""
try:
self.conn = Connection(host=self.config.get("remote"), user=self.config.get("user"), port=self.config.get("port"))
except Exception as e:
raise RuntimeError(f"Failed to establish SSH connection: {e}") from e
self.logger.set_hostname(self.conn.host)
self.logger.log(f"Connection established with {self.conn.host}.", local=True)
# ------------------------------------------------------------------
# Resource management
# ------------------------------------------------------------------
def close(self):
self.conn.close()
self.logger.log("Connection closed.", local=True)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def require_relative_path(self, path: str) -> None:
if ppath.isabs(path):
raise ValueError(
f"You must provide a path relative to the remote root (received: {path}). "
"This workflow also creates the same directory on the local machine."
)
def require_supported_concurrency(self, concurrency: int) -> None:
if concurrency != 1:
raise ValueError(
f"Concurrency > 1 is not supported with a RemoteDispatcher (received: {concurrency}). "
"Use a concurrency of 1 and let SLURM provide the parallelism."
)
[docs]
def run(self, cmd: str, run_directory: str = None) -> Result:
"""
Run a command directly on the remote host, without SLURM.
This lands wherever the SSH session does, which on a cluster is the login
node, so it suits quick staging or preprocessing; anything long or
parallel belongs in submit_job(). run_directory is relative to the remote
root, and defaults to the root itself.
Returns a Result object (with stdout, stderr, exit_code, ok)
"""
remote_cmd = f"cd {shlex.quote(self.files.resolve_path(run_directory))} && {cmd}"
res = self.conn.run(remote_cmd)
if res.ok:
self.logger.debug(f"Executed command on remote host: {cmd}")
else:
self.logger.log(f"Command failed ({cmd}), exit code {res.exit_code}: {res.stderr}")
return res