"""Host implementation that uses separate connections for commands and file transfers.
This is useful for HPC systems where login nodes have SFTP disabled but a dedicated
data transfer node is available (e.g., LRC at LBNL).
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from jobflow_remote.remote.host.base import BaseHost
if TYPE_CHECKING:
from pathlib import Path
from jobflow_remote.remote.host.remote import RemoteHost
logger = logging.getLogger(__name__)
[docs]
class SeparatedTransferHost(BaseHost):
"""
Host that delegates commands and file transfers to separate connections.
This enables HPC systems where:
- Login node: SSH/scheduler commands work, but SFTP is disabled
- Transfer node: SFTP works, but scheduler commands don't work
Command execution (execute, shell) goes through the command_host.
File operations (put, get, mkdir, etc.) go through the transfer_host.
Parameters
----------
command_host : RemoteHost
Host for executing commands (e.g., login node with SLURM access).
transfer_host : RemoteHost
Host for file transfers (e.g., data transfer node with SFTP).
"""
def __init__(
self,
command_host: RemoteHost,
transfer_host: RemoteHost,
) -> None:
self.command_host = command_host
self.transfer_host = transfer_host
# Use sanitize setting from command host
super().__init__(sanitize=command_host.sanitize)
def __eq__(self, other):
if not isinstance(other, SeparatedTransferHost):
return False
return (
self.command_host == other.command_host
and self.transfer_host == other.transfer_host
)
def __hash__(self):
return hash((SeparatedTransferHost, self.command_host, self.transfer_host))
# -------------------------------------------------------------------------
# Command execution - delegated to command_host
# -------------------------------------------------------------------------
[docs]
def execute(
self,
command: str | list[str],
workdir: str | Path | None = None,
timeout: int | None = None,
) -> tuple[str, str, int]:
"""Execute the given command on the command host.
Parameters
----------
command : str or list of str
Command to execute, as a str or list of str.
workdir : str or Path or None
Path where the command will be executed.
timeout : int or None
Timeout for the execution of the command.
Returns
-------
stdout : str
Standard output of the command.
stderr : str
Standard error of the command.
exit_code : int
Exit code of the command.
"""
return self.command_host.execute(command, workdir, timeout)
[docs]
def shell(self, pre_cmd: str | None = None, shell: str = "bash"):
"""Open a shell on the command host.
Parameters
----------
pre_cmd : str or None
Any command to be executed before starting the shell.
shell : str
The name of the shell to start.
"""
return self.command_host.shell(pre_cmd, shell)
[docs]
def mkdir(
self, directory: str | Path, recursive: bool = True, exist_ok: bool = True
) -> bool:
"""Create directory on the command host.
Parameters
----------
directory : str or Path
Path of the directory to create.
recursive : bool
If True, create parent directories as needed.
exist_ok : bool
If True, do not raise an error if directory exists.
Returns
-------
bool
True if the directory was created successfully.
"""
return self.command_host.mkdir(directory, recursive, exist_ok)
[docs]
def copy(self, src, dst) -> None:
"""Copy a file on the command host.
Parameters
----------
src : str or Path
Source path on remote host.
dst : str or Path
Destination path on remote host.
"""
return self.command_host.copy(src, dst)
[docs]
def move(self, src, dst) -> None:
"""Move a file on the command host.
Parameters
----------
src : str or Path
Source path on remote host.
dst : str or Path
Destination path on remote host.
"""
return self.command_host.move(src, dst)
[docs]
def rmtree(self, path: str | Path, raise_on_error: bool = False) -> bool:
"""Recursively delete a directory tree on the command host.
Parameters
----------
path : str or Path
Path to the directory tree to be removed.
raise_on_error : bool
If False (default), errors will be ignored. Otherwise, errors
will raise an exception.
Returns
-------
bool
True if the directory tree was successfully removed.
"""
return self.command_host.rmtree(path, raise_on_error=raise_on_error)
# -------------------------------------------------------------------------
# File operations via SFTP commands - delegated to transfer_host
# -------------------------------------------------------------------------
[docs]
def write_text_file(self, filepath: str | Path, content: str) -> None:
"""Write content to a file on the transfer host.
Parameters
----------
filepath : str or Path
Path to the file to write.
content : str
Content to write to the file.
"""
return self.transfer_host.write_text_file(filepath, content)
[docs]
def read_text_file(self, filepath: str | Path) -> str:
"""Read content from a file on the transfer host.
Parameters
----------
filepath : str or Path
Path to the file to read.
Returns
-------
str
Content of the file.
"""
return self.transfer_host.read_text_file(filepath)
[docs]
def put(self, src, dst) -> None:
"""Upload a file to the transfer host.
Parameters
----------
src : str or Path or file-like
Local source path or file-like object.
dst : str or Path
Remote destination path.
"""
return self.transfer_host.put(src, dst)
[docs]
def get(self, src, dst) -> None:
"""Download a file from the transfer host.
Parameters
----------
src : str or Path
Remote source path.
dst : str or Path or file-like
Local destination path or file-like object.
"""
return self.transfer_host.get(src, dst)
[docs]
def listdir(self, path: str | Path) -> list[str]:
"""List directory contents on the transfer host.
Parameters
----------
path : str or Path
Path to the directory to list.
Returns
-------
list of str
List of filenames in the directory.
"""
return self.transfer_host.listdir(path)
[docs]
def remove(self, path: str | Path) -> None:
"""Remove a file on the transfer host.
Parameters
----------
path : str or Path
Path to the file to remove.
"""
return self.transfer_host.remove(path)
[docs]
def exists(self, path: str | Path) -> bool:
"""Check if a path exists on the transfer host.
Parameters
----------
path : str or Path
The path to check.
Returns
-------
bool
True if the path exists.
"""
return self.transfer_host.exists(path)
# -------------------------------------------------------------------------
# Connection management - manages both hosts
# -------------------------------------------------------------------------
[docs]
def connect(self) -> None:
"""Open connections to both the command and transfer hosts."""
self.command_host.connect()
self.transfer_host.connect()
[docs]
def close(self) -> bool:
"""Close connections to both hosts.
Returns
-------
bool
True if both connections were closed successfully.
"""
cmd_closed = self.command_host.close()
transfer_closed = self.transfer_host.close()
return cmd_closed and transfer_closed
@property
def is_connected(self) -> bool:
"""Check if both connections are open.
Returns
-------
bool
True if both command and transfer hosts are connected.
"""
return self.command_host.is_connected and self.transfer_host.is_connected
@property
def interactive_login(self) -> bool:
"""Check if either host requires interactive login.
Returns
-------
bool
True if either host requires interactive login.
"""
return (
self.command_host.interactive_login or self.transfer_host.interactive_login
)