Source code for src.tools.connection

"""
SSH connection helper for transferring SLURM scripts to HPC and submitting jobs.

Uses Fabric (https://www.fabfile.org/) for robust SSH connection management.
This module reads your SSH config file and provides utilities to:
1. Transfer generated SLURM scripts to a remote HPC cluster
2. Submit jobs via sbatch
3. Monitor job status
4. Download results

Usage:
    from src.tools.connection import HPCConnection

    # Initialize connection (reads ~/.ssh/config)
    hpc = HPCConnection(host_alias="mycluster")

    # Copy SLURM script and submit job
    job_id = hpc.submit_job(slurm_file="outputs/train_cgan_cnn_2d_beams2d_seed1.slurm")
    print(f"Job submitted with ID: {job_id}")

    # Check job status
    status = hpc.get_job_status(job_id)
    print(status)
"""

import base64
import logging
import os
import re
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any, Protocol

import requests
from fabric import Connection

logger = logging.getLogger(__name__)

# Type alias for progress callback
ProgressCallback = Callable[[str, str], None]


[docs] class HPCConnectionLike(Protocol): """Operations used by EngiAI regardless of the SSH transport.""" host_alias: str auth_mode: str
[docs] def get_remote_directory(self) -> str: ...
[docs] def ensure_remote_dir(self, remote_dir: str) -> None: ...
[docs] def put_file(self, local_file: str, remote_path: str) -> None: ...
[docs] def get_file(self, remote_file: str, local_path: str) -> None: ...
[docs] def test_slurm_script(self, remote_path: str) -> str: ...
[docs] def remove_remote_file(self, remote_path: str) -> None: ...
[docs] def submit_job(self, slurm_file: str, remote_dir: str = "~/slurm_jobs") -> str: ...
[docs] def get_job_status(self, job_id: str) -> str: ...
[docs] def get_job_final_state(self, job_id: str) -> tuple[str, str]: ...
[docs] def cancel_job(self, job_id: str) -> None: ...
[docs] def cancel_jobs(self, job_ids: list[str]) -> None: ...
[docs] def get_job_output(
self, job_id: str, remote_dir: str = "~/slurm_jobs", local_dir: str = "outputs" ) -> None: ...
[docs] def get_job_diagnostics(
self, job_id: str, remote_dir: str = "~/slurm_jobs" ) -> tuple[str, str]: ...
[docs] def list_active_jobs(self) -> str: ...
[docs] def close(self) -> None: ...
[docs] class HPCBrokerConnection: """HPC connection backed by the native, key-holding host broker."""
[docs] def __init__( self, broker_url: str, token: str, host_alias: str = "euler", progress_callback: ProgressCallback | None = None, timeout: int = 120, ) -> None: if not broker_url or not token: raise RuntimeError("HPC broker URL and token are required") self.base_url = broker_url.rstrip("/") self.token = token self.host_alias = host_alias or "euler" self.progress_callback = progress_callback self.timeout = timeout self.auth_mode = "managed broker"
def _emit_progress(self, step: str, message: str) -> None: if self.progress_callback: self.progress_callback(step, message) logger.info("[%s] %s", step, message) def _request( self, path: str, payload: dict[str, Any] | None = None, ) -> dict[str, Any]: try: response = requests.post( f"{self.base_url}{path}", json=payload or {}, headers={"Authorization": f"Bearer {self.token}"}, timeout=self.timeout, ) response.raise_for_status() data = response.json() except requests.RequestException as exc: detail = "" if exc.response is not None: try: detail = str(exc.response.json().get("error", "")) except (ValueError, AttributeError): detail = exc.response.text[-500:] message = detail or type(exc).__name__ raise RuntimeError(f"HPC broker request failed: {message}") from exc except ValueError as exc: raise RuntimeError("HPC broker returned invalid JSON") from exc if not isinstance(data, dict): raise TypeError("HPC broker returned an invalid response") return data @staticmethod def _encode_file(path: Path) -> str: return base64.b64encode(path.read_bytes()).decode("ascii")
[docs] def get_remote_directory(self) -> str: return str(self._request("/v1/test")["remote_directory"])
[docs] def ensure_remote_dir(self, remote_dir: str) -> None: self._request("/v1/directories", {"remote_dir": remote_dir})
[docs] def put_file(self, local_file: str, remote_path: str) -> None: local_path = Path(local_file) if not local_path.exists(): raise FileNotFoundError(f"Local file not found: {local_file}") self._request( "/v1/files/upload", { "remote_path": remote_path, "content_base64": self._encode_file(local_path), }, )
[docs] def get_file(self, remote_file: str, local_path: str) -> None: data = self._request("/v1/files/download", {"remote_path": remote_file}) try: content = base64.b64decode(str(data["content_base64"]), validate=True) except (KeyError, ValueError) as exc: raise RuntimeError("HPC broker returned invalid file content") from exc destination = Path(local_path) destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(content)
[docs] def test_slurm_script(self, remote_path: str) -> str: return str( self._request("/v1/slurm/validate", {"remote_path": remote_path})["output"] )
[docs] def remove_remote_file(self, remote_path: str) -> None: self._request("/v1/files/remove", {"remote_path": remote_path})
[docs] def submit_job( self, slurm_file: str, remote_dir: str = "~/slurm_jobs", ) -> str: local_path = Path(slurm_file) if not local_path.exists(): raise FileNotFoundError(f"SLURM script not found: {slurm_file}") self._emit_progress("transfer", f"Transferring {local_path.name} to Euler") data = self._request( "/v1/jobs/submit", { "filename": local_path.name, "remote_dir": remote_dir, "content_base64": self._encode_file(local_path), }, ) job_id = str(data["job_id"]) self._emit_progress("complete", f"Job submitted successfully! Job ID: {job_id}") return job_id
[docs] def get_job_status(self, job_id: str) -> str: return str(self._request(f"/v1/jobs/{job_id}/status")["output"])
[docs] def get_job_final_state(self, job_id: str) -> tuple[str, str]: data = self._request(f"/v1/jobs/{job_id}/final") return str(data.get("state", "")), str(data.get("exit_code", ""))
[docs] def cancel_job(self, job_id: str) -> None: self.cancel_jobs([job_id])
[docs] def cancel_jobs(self, job_ids: list[str]) -> None: self._request("/v1/jobs/cancel", {"job_ids": job_ids})
[docs] def get_job_output( self, job_id: str, remote_dir: str = "~/slurm_jobs", local_dir: str = "outputs", ) -> None: data = self._request(f"/v1/jobs/{job_id}/outputs", {"remote_dir": remote_dir}) destination = Path(local_dir) destination.mkdir(parents=True, exist_ok=True) for item in data.get("files", []): name = Path(str(item["name"])).name content = base64.b64decode(str(item["content_base64"]), validate=True) (destination / name).write_bytes(content)
[docs] def get_job_diagnostics( self, job_id: str, remote_dir: str = "~/slurm_jobs", ) -> tuple[str, str]: data = self._request( f"/v1/jobs/{job_id}/diagnostics", {"remote_dir": remote_dir} ) return str(data.get("sacct", "")), str(data.get("logs", ""))
[docs] def list_active_jobs(self) -> str: return str(self._request("/v1/jobs/active")["output"])
[docs] def close(self) -> None: """The broker owns SSH process lifetimes; there is no client socket to close."""
[docs] class HPCConnection: """SSH connection to HPC cluster for job submission using Fabric."""
[docs] def __init__( # noqa: PLR0913 self, host_alias: str = "", host: str | None = None, user: str | None = None, password: str | None = None, port: int = 22, progress_callback: ProgressCallback | None = None, ) -> None: """ Initialize HPC connection. Supports two authentication modes: 1. SSH config mode (default): Uses ~/.ssh/config with SSH keys 2. Password mode: Uses explicit username/password credentials Args: host_alias: Host alias from ~/.ssh/config (e.g., "mycluster") host: Explicit hostname (e.g., "hpc.example.com"). If provided, uses password auth. user: Username for password authentication password: Password for authentication port: SSH port (default: 22) progress_callback: Optional callback for progress updates (step, message) """ self.host_alias = host_alias self.progress_callback = progress_callback self.auth_mode = "password" if host and user and password else "SSH config" # Determine authentication mode if host and user and password: # Password authentication mode (don't log credentials for security) logger.info("Establishing SSH connection (password auth)") try: self.connection = Connection( host=host, user=user, port=port, connect_kwargs={ "password": password, "look_for_keys": False, "allow_agent": False, }, ) self.host_alias = host except Exception as e: # Don't expose credentials in error messages msg = "SSH connection failed. Please verify:\n" msg += " 1. Hostname is correct\n" msg += " 2. Username and password are correct\n" msg += " 3. SSH access is enabled on the server\n" msg += " 4. Network connectivity to the host" logger.exception(f"SSH connection failed: {type(e).__name__}") raise RuntimeError(msg) from e else: # SSH config mode (original behavior) if not host_alias: msg = ( "No HPC host configured. Set HPC_HOST_ALIAS in your .env file " "(e.g., HPC_HOST_ALIAS=mycluster) or provide host/user/password " "for direct authentication." ) raise RuntimeError(msg) # Fabric automatically uses ~/.ssh/config for connection details logger.info(f"Connecting to {host_alias} (SSH config mode)") logger.info( "Using host alias: %s (from HPC_HOST_ALIAS in .env)", host_alias ) try: # Enable SSH agent to handle encrypted private keys self.connection = Connection( host_alias, connect_kwargs={ "allow_agent": True, # Use SSH agent for encrypted keys "look_for_keys": True, # Look for keys in standard locations }, ) logger.info(f"SSH config connection established for {host_alias}") except Exception as e: logger.exception(f"SSH config connection failed for {host_alias}") msg = f"Failed to connect to '{host_alias}': {e}\n" msg += "Make sure:\n" msg += f" 1. Host {host_alias} is configured in ~/.ssh/config\n" msg += " 2. SSH key is set up correctly\n" msg += ( " 3. SSH agent is running (if using Docker, mount SSH_AUTH_SOCK)\n" ) msg += f" 4. Test manually: ssh {host_alias}" raise RuntimeError(msg) from e
def _emit_progress(self, step: str, message: str) -> None: """ Emit progress update if callback is provided. Args: step: Step identifier (e.g., "connect", "transfer", "submit") message: Human-readable message """ if self.progress_callback: self.progress_callback(step, message) logger.info(f"[{step}] {message}")
[docs] def run_command(self, command: str) -> str: """ Run a command over SSH. Args: command: Command to execute on remote Returns: Command output """ result = self.connection.run(command, hide=False, warn=False) if result.failed: msg = f"SSH command failed: {command}\nError: {result.stderr}" raise RuntimeError(msg) return result.stdout.strip()
[docs] def get_remote_directory(self) -> str: """Return the Euler working directory for a connection smoke test.""" return self.run_command("pwd")
[docs] def ensure_remote_dir(self, remote_dir: str) -> None: """Create an allowed working directory on the remote host.""" self.run_command(f"mkdir -p {remote_dir}")
[docs] def test_slurm_script(self, remote_path: str) -> str: """Ask SLURM to validate a staged script without submitting it.""" return self.run_command(f"sbatch --test-only {remote_path}")
[docs] def remove_remote_file(self, remote_path: str) -> None: """Remove a staged remote file.""" self.run_command(f"rm -f {remote_path}")
[docs] def put_file(self, local_file: str, remote_path: str) -> None: """ Copy a file to remote using SFTP. Args: local_file: Path to local file remote_path: Destination path on remote (can be relative to home) """ local_path = Path(local_file) if not local_path.exists(): msg = f"Local file not found: {local_file}" raise FileNotFoundError(msg) # Expand tilde in remote path using remote home directory if remote_path.startswith("~"): home_dir = self.run_command("echo $HOME").strip() expanded_remote = remote_path.replace("~", home_dir, 1) else: expanded_remote = remote_path # Fabric's put method handles SFTP file transfer try: self.connection.put(str(local_path.absolute()), expanded_remote) except Exception as e: msg = f"SFTP transfer failed: {e}" raise RuntimeError(msg) from e
[docs] def get_file(self, remote_file: str, local_path: str) -> None: """ Download a file from remote using SFTP. Args: remote_file: Path to remote file local_path: Destination path on local """ self.connection.get(remote_file, local_path)
[docs] def submit_job( self, slurm_file: str, remote_dir: str = "~/slurm_jobs", ) -> str: """ Transfer SLURM script and submit job to HPC. Args: slurm_file: Path to local SLURM script remote_dir: Directory on remote to store scripts (default: ~/slurm_jobs) Returns: Job ID returned by sbatch """ local_path = Path(slurm_file) if not local_path.exists(): msg = f"SLURM script not found: {slurm_file}" raise FileNotFoundError(msg) filename = local_path.name # Ensure remote directory exists (silently) try: self.ensure_remote_dir(remote_dir) except RuntimeError as e: msg = f"Failed to create remote directory {remote_dir}: {e}" raise RuntimeError(msg) from e # Emit progress: Transferring file remote_path = f"{remote_dir}/{filename}" # Capitalize Euler for display display_host = ( self.host_alias.capitalize() if self.host_alias.lower() == "euler" else self.host_alias ) self._emit_progress("transfer", f"Transferring {filename} to {display_host}") logger.info(f"Copying {filename} to {self.host_alias}:{remote_path}") self.put_file(slurm_file, remote_path) self._emit_progress("transfer", "File transferred successfully") # Emit progress: Submitting job self._emit_progress("submit", "Submitting SLURM job...") sbatch_cmd = f"cd {remote_dir} && sbatch {filename}" output = self.run_command(sbatch_cmd) # Parse job ID from sbatch output # Expected format: "Submitted batch job 12345" match = re.search(r"Submitted batch job (\d+)", output) if not match: msg = f"Could not parse job ID from sbatch output: {output}" raise RuntimeError(msg) job_id = match.group(1) self._emit_progress("complete", f"Job submitted successfully! Job ID: {job_id}") logger.info(f"Job submitted with ID: {job_id}") return job_id
[docs] def close(self) -> None: """Close the underlying SSH connection, releasing the socket.""" try: self.connection.close() except Exception as e: logger.warning(f"Error closing SSH connection to {self.host_alias}: {e}")
[docs] def get_job_status(self, job_id: str) -> str: """ Get status of a submitted job. Args: job_id: SLURM job ID Returns: Job status output """ try: output = self.run_command(f"squeue -j {job_id}") except RuntimeError as e: return f"Error getting job status: {e}" else: if output: return output return f"Job {job_id} not found or completed"
[docs] def get_job_final_state(self, job_id: str) -> tuple[str, str]: """Return a finished job's terminal ``(state, exit_code)`` via ``sacct``. ``squeue`` only lists pending/running jobs, so a finished job -- whether it COMPLETED or FAILED -- simply vanishes from it; callers that only poll ``get_job_status`` cannot tell success from failure. ``sacct`` records the real final ``State`` and ``ExitCode`` (e.g. a crashed training run that exits non-zero shows ``FAILED``), which is what failure detection needs. ``state`` is ``""`` when sacct has no record yet -- accounting can lag a few seconds behind ``squeue`` -- so callers should retry briefly on empty. """ try: # -n no header, -P parsable, -X main allocation only (skip .batch/.extern) output = self.run_command( f"sacct -j {job_id} -n -P -X --format=State,ExitCode" ) except RuntimeError as e: logger.warning(f"sacct query failed for job {job_id}: {e}") return "", "" line = next((ln for ln in output.strip().splitlines() if ln.strip()), "") if not line: return "", "" parts = line.split("|") # sacct decorates some states, e.g. "CANCELLED by 12345" -> keep the first token state = parts[0].strip().split()[0] if parts and parts[0].strip() else "" exit_code = parts[1].strip() if len(parts) > 1 else "" return state, exit_code
[docs] def cancel_job(self, job_id: str) -> None: """ Cancel a running job. Args: job_id: SLURM job ID """ logger.info(f"Cancelling job {job_id}") self.run_command(f"scancel {job_id}") logger.info(f"Job {job_id} cancelled")
[docs] def cancel_jobs(self, job_ids: list[str]) -> None: """Cancel one or more jobs in one SSH round-trip.""" if not job_ids: return self.run_command("scancel " + " ".join(job_ids))
[docs] def list_active_jobs(self) -> str: """List active jobs owned by the SSH account.""" return self.run_command("squeue -u $USER")
[docs] def get_job_diagnostics( self, job_id: str, remote_dir: str = "~/slurm_jobs", ) -> tuple[str, str]: """Return the accounting line and recent stdout/stderr for a job.""" sacct = self.run_command( f"sacct -j {job_id} --format=State,ExitCode --noheader --parsable2 " f"2>/dev/null | head -1 || true" ) logs = self.run_command( f"{{ tail -n 400 {remote_dir}/engiopt_*_{job_id}.err 2>/dev/null; " f"echo '----- STDOUT -----'; " f"tail -n 100 {remote_dir}/engiopt_*_{job_id}.out 2>/dev/null; }} || true" ) return sacct, logs
[docs] def get_job_output( self, job_id: str, remote_dir: str = "~/slurm_jobs", local_dir: str = "outputs", ) -> None: """ Download job output files. Args: job_id: SLURM job ID remote_dir: Remote directory where SLURM outputs are stored local_dir: Local directory to download to """ local_path = Path(local_dir) local_path.mkdir(parents=True, exist_ok=True) # Download output and error files for file_type in ["out", "err"]: # List files matching pattern in remote directory list_cmd = ( f"ls {remote_dir}/engiopt_*_{job_id}.{file_type} 2>/dev/null || true" ) try: file_list_output = self.run_command(list_cmd) if not file_list_output: logger.warning(f"No .{file_type} files found for job {job_id}") continue # Download each file for remote_file in file_list_output.split("\n"): if remote_file.strip(): local_dest = str(local_path / Path(remote_file).name) self.get_file(remote_file, local_dest) logger.info( f"Downloaded {Path(remote_file).name} to {local_dir}/" ) except RuntimeError as e: if "No such file" not in str(e): logger.warning(f"Warning downloading .{file_type} files: {e}")
if __name__ == "__main__": min_args = 2 if len(sys.argv) < min_args: print("Usage:") print( " python connection.py test # Test SSH connection" ) print(" python connection.py submit <slurm_file>") print(" python connection.py status <job_id>") print(" python connection.py cancel <job_id>") print(" python connection.py download <job_id>") sys.exit(1) command = sys.argv[1] try: alias = os.getenv("HPC_HOST_ALIAS", "") hpc = HPCConnection(host_alias=alias) if command == "test": output = hpc.run_command("pwd") print("✅ SSH connection successful!") print(f" Remote directory: {output}") elif command == "submit" and len(sys.argv) > min_args: job_id = hpc.submit_job(sys.argv[2]) print(f"Job ID: {job_id}") elif command == "status" and len(sys.argv) > min_args: status = hpc.get_job_status(sys.argv[2]) print(status) elif command == "cancel" and len(sys.argv) > min_args: hpc.cancel_job(sys.argv[2]) elif command == "download" and len(sys.argv) > min_args: hpc.get_job_output(sys.argv[2]) else: msg = f"Unknown command: {command}" print(msg) sys.exit(1) except Exception as e: print(f"❌ Error: {e}") sys.exit(1)