Source code for src.tools.hpc

"""
HPC cluster job management tools for SLURM job submission and monitoring.

This module provides tools for interacting with HPC clusters via SSH,
including job submission, status monitoring, cancellation, and output retrieval.
"""

import logging
import os
import re
import time
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
from typing import Any

from cryptography.fernet import Fernet
from langchain_core.tools import tool

from config import config
from src.tools.connection import HPCBrokerConnection, HPCConnection, HPCConnectionLike
from src.tools.engibench import get_current_session_id

logger = logging.getLogger(__name__)

# Session credentials storage - keyed by session_id for multi-user isolation
_session_credentials_store: dict[str, dict[str, Any]] = {}

# Current session ID - set by Streamlit before tool invocation
_current_session_id: str | None = None

# Progress callback - set by Streamlit UI to receive progress updates
_progress_callback: Any | None = None

# Per-trial cap on full training-job submissions (self-correction budget for the
# training_code_* benchmark). ``None`` disables the cap, so interactive use and all
# non-training_code problems are unaffected. ``_submit_counts`` tracks successful
# full-run submits keyed by session id (the eval harness sets a unique session id
# per trial); the local 1-epoch dryrun and the cluster ``dryrun_*`` job never count.
# The session id is engibench's harness-set contextvar (get_current_session_id), NOT
# the local _current_session_id below (SSH-credential isolation for the Streamlit UI).
# ``_submit_job_ids`` records the job ids of those full-run submits (same keying) so
# the harness can scancel a dead trial's in-flight jobs at teardown -- see
# ``cancel_session_jobs`` (a trial that hits the agent recursion limit must not leave
# PENDING/RUNNING SLURM jobs running unwatched on the cluster).
_submit_budget: int | None = None
_submit_counts: dict[str, int] = {}
_submit_job_ids: dict[str, list[str]] = {}

# Prefix of the message ``submit_slurm_job`` returns once the per-trial cap is hit.
# ``HPCAgent._should_force_end`` greps the agent's tool messages for this marker to
# stop the loop churning past the budget -- keep the two in sync (single source).
SUBMISSION_BUDGET_EXHAUSTED_MARKER = "Submission budget exhausted"

# Credential expiration time in seconds (20 minutes for security)
CREDENTIAL_EXPIRATION_SECONDS = 1200

# Encryption key for password storage (generated once per application lifetime)
# This provides defense-in-depth - passwords are encrypted in memory
_encryption_key = Fernet.generate_key()
_cipher = Fernet(_encryption_key)


def _encrypt_password(password: str) -> str:
    """Encrypt a password for secure in-memory storage."""
    return _cipher.encrypt(password.encode()).decode()


def _decrypt_password(encrypted_password: str) -> str:
    """Decrypt a password from secure storage."""
    return _cipher.decrypt(encrypted_password.encode()).decode()


# Hostname validation constants
MAX_HOSTNAME_LENGTH = 253
MAX_LABEL_LENGTH = 63
MAX_USERNAME_LENGTH = 64
MAX_PORT = 65535


[docs] def set_current_session_id(session_id: str | None) -> None: """ Set the current session ID for credential isolation. This should be called by Streamlit before invoking any HPC tools. Args: session_id: Unique session identifier """ global _current_session_id # noqa: PLW0603 _current_session_id = session_id
[docs] def set_submit_budget(k: int | None) -> None: """Set the per-trial cap on full training-job submissions (``None`` disables). Called once by the evaluation harness for ``training_code_*`` runs; the cap is a cost guardrail enforced inside ``submit_slurm_job``. Dryrun submissions (the cluster ``dryrun_*`` job) are never counted. """ global _submit_budget # noqa: PLW0603 _submit_budget = k
[docs] def reset_submit_count(session_id: str) -> None: """Reset (and free) the submitted-jobs counter and job-id list for a session. The harness calls this at trial start and teardown so each trial gets a fresh budget and ``_submit_counts`` / ``_submit_job_ids`` do not grow across a long grid run. Call ``cancel_session_jobs`` *before* this if a dead trial's jobs need cancelling -- this clears the recorded ids. """ _submit_counts.pop(session_id, None) _submit_job_ids.pop(session_id, None)
[docs] def cancel_session_jobs(session_id: str, host_alias: str | None = None) -> list[str]: """Best-effort ``scancel`` of every full-run job submitted in ``session_id``. Called by the eval harness at trial teardown: a trial that died (e.g. hit the agent recursion limit) must not leave PENDING/RUNNING SLURM jobs running unwatched on the cluster, wasting GPU and clobbering the moving ``seed_N`` wandb alias. ``scancel`` on an already-finished job is a harmless no-op, so this is safe to call after *every* trial. Never raises -- a teardown must not be derailed by an SSH hiccup. Returns the list of job ids it attempted to cancel (for logging / tests). """ job_ids = _submit_job_ids.get(session_id, []) if not job_ids: return [] if host_alias is None: host_alias = config.hpc_host_alias try: hpc = create_hpc_connection(host_alias) hpc.cancel_jobs(job_ids) logger.info( "cancel_session_jobs: scancelled %d in-flight job(s) for session %s: %s", len(job_ids), session_id, ", ".join(job_ids), ) except Exception as e: # best-effort teardown, must never raise logger.warning( "cancel_session_jobs: failed to scancel %s for session %s: %s", job_ids, session_id, e, ) return job_ids
[docs] def set_hpc_progress_callback(callback: Any | None) -> None: """ Set the progress callback for HPC operations. This should be called by Streamlit before invoking HPC tools. Args: callback: Callback function(step: str, message: str) or None to clear """ global _progress_callback # noqa: PLW0603 _progress_callback = callback
[docs] def get_hpc_progress_callback() -> Any | None: """Get the current HPC progress callback.""" return _progress_callback
[docs] def validate_hostname(hostname: str) -> bool: """ Validate hostname format to prevent injection attacks. Args: hostname: The hostname to validate Returns: True if valid, False otherwise """ # Allow alphanumeric, dots, and hyphens only pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$" if not re.match(pattern, hostname): return False # Check length if len(hostname) > MAX_HOSTNAME_LENGTH: return False # Check each label labels = hostname.split(".") return all(len(label) <= MAX_LABEL_LENGTH for label in labels)
[docs] def validate_username(username: str) -> bool: """ Validate username format. Args: username: The username to validate Returns: True if valid, False otherwise """ # Allow alphanumeric, underscores, and hyphens pattern = r"^[a-zA-Z0-9_\-]+$" return bool(re.match(pattern, username)) and len(username) <= MAX_USERNAME_LENGTH
[docs] def set_ssh_credentials( host: str | None = None, user: str | None = None, password: str | None = None, port: int = 22, session_id: str | None = None, ) -> tuple[bool, str]: """ Set SSH credentials for a specific session. Args: host: Hostname (e.g., "hpc.example.com") user: Username password: Password port: SSH port (default: 22) session_id: Session ID for isolation (uses current if not provided) Returns: Tuple of (success, message) """ sid = session_id or _current_session_id if not sid: return False, "No session ID available" if not (host and user and password): # Clear credentials if sid in _session_credentials_store: del _session_credentials_store[sid] logger.info(f"Cleared SSH credentials for session {sid[:8]}...") return True, "Credentials cleared" # Validate inputs if not validate_hostname(host): return False, f"Invalid hostname format: {host}" if not validate_username(user): return False, f"Invalid username format: {user}" if not (1 <= port <= MAX_PORT): return False, f"Invalid port number: {port}" # Store credentials with expiration (password encrypted for security) _session_credentials_store[sid] = { "host": host, "user": user, "password": _encrypt_password(password), "port": port, "created_at": time.time(), "expires_at": time.time() + CREDENTIAL_EXPIRATION_SECONDS, } logger.info( f"SSH credentials set for session {sid[:8]}... (expires in {CREDENTIAL_EXPIRATION_SECONDS // 60} min)" ) return ( True, f"Credentials saved (expires in {CREDENTIAL_EXPIRATION_SECONDS // 60} minutes)", )
[docs] def clear_ssh_credentials(session_id: str | None = None) -> None: """ Clear stored SSH credentials for a session. Args: session_id: Session ID (uses current if not provided) """ sid = session_id or _current_session_id if sid and sid in _session_credentials_store: del _session_credentials_store[sid] logger.info(f"Cleared SSH credentials for session {sid[:8]}...")
[docs] def get_ssh_credentials(session_id: str | None = None) -> dict[str, Any] | None: """ Get SSH credentials for a session. Args: session_id: Session ID (uses current if not provided) Returns: Credentials dict or None if not set/expired """ sid = session_id or _current_session_id if not sid: return None creds = _session_credentials_store.get(sid) if not creds: return None # Check expiration if creds.get("expires_at", 0) < time.time(): del _session_credentials_store[sid] logger.info(f"SSH credentials expired for session {sid[:8]}...") return None # Return with decrypted password (without internal fields) return { "host": creds["host"], "user": creds["user"], "password": _decrypt_password(creds["password"]), "port": creds["port"], }
[docs] @contextmanager def get_ssh_credentials_secure( session_id: str | None = None, ) -> Generator[dict[str, Any] | None]: """ Securely get SSH credentials and clear from memory after use. This context manager ensures the decrypted password is cleared from the returned dictionary after the block exits. Args: session_id: Session ID (uses current if not provided) Yields: dict or None: Credentials dict or None if not set/expired Example:: with get_ssh_credentials_secure() as creds: if creds: connection = HPCConnection(..., password=creds["password"]) # Password is cleared from creds after this point """ creds = get_ssh_credentials(session_id) try: yield creds finally: # Clear the decrypted password from memory if creds and "password" in creds: # Overwrite with zeros before deletion creds["password"] = "\x00" * len(creds["password"]) del creds["password"]
[docs] def cleanup_expired_credentials() -> int: """ Remove all expired credentials from storage. Returns: Number of expired sessions cleaned up """ current_time = time.time() expired = [ sid for sid, creds in _session_credentials_store.items() if creds.get("expires_at", 0) < current_time ] for sid in expired: del _session_credentials_store[sid] if expired: logger.info(f"Cleaned up {len(expired)} expired credential sessions") return len(expired)
def _broker_token() -> str: """Read the broker capability from an environment value or mounted file.""" token_file = os.getenv("HPC_BROKER_TOKEN_FILE", "").strip() if token_file: try: return Path(token_file).read_text(encoding="utf-8").strip() except OSError as exc: raise RuntimeError(f"Cannot read HPC_BROKER_TOKEN_FILE: {exc}") from exc return os.getenv("HPC_BROKER_TOKEN", "").strip()
[docs] def create_hpc_connection(host_alias: str) -> HPCConnectionLike: """ Create an HPC connection using session credentials or SSH config. Args: host_alias: Host alias for SSH config fallback Returns: Direct SSH or managed-broker connection instance """ # Clean up expired credentials periodically cleanup_expired_credentials() # Get progress callback if set progress_callback = get_hpc_progress_callback() # Production deployments keep the technical account's private key in a # native host service. Broker mode deliberately takes precedence over # browser-supplied passwords so the shared UI never handles that credential. broker_url = os.getenv("HPC_BROKER_URL", "").strip() if broker_url: token = _broker_token() if not token: raise RuntimeError( "HPC_BROKER_URL is configured but no HPC_BROKER_TOKEN or " "HPC_BROKER_TOKEN_FILE is available" ) logger.info("Creating managed HPC broker connection for %s", host_alias) return HPCBrokerConnection( broker_url=broker_url, token=token, host_alias=host_alias, progress_callback=progress_callback, ) # Use secure context manager to ensure password is cleared after use with get_ssh_credentials_secure() as creds: if creds: # Use password authentication sid = _current_session_id or "unknown" logger.info( f"Creating HPC connection with password auth (session {sid[:8]}...)" ) return HPCConnection( host_alias=host_alias, host=creds["host"], user=creds["user"], password=creds["password"], port=creds["port"], progress_callback=progress_callback, ) else: # Use SSH config (original behavior) logger.info(f"Creating HPC connection with SSH config for {host_alias}") return HPCConnection( host_alias=host_alias, progress_callback=progress_callback )
@tool def test_hpc_connection(host_alias: str | None = None) -> dict[str, Any]: """ Test SSH connection to HPC cluster. This tool verifies that the SSH connection to the HPC cluster is working by running a simple 'pwd' command on the remote server. Args: host_alias: Host alias from ~/.ssh/config. If not provided, uses HPC_HOST_ALIAS from .env Returns: Dictionary with connection status and remote directory """ if host_alias is None: host_alias = config.hpc_host_alias try: hpc = create_hpc_connection(host_alias) output = hpc.get_remote_directory() except Exception as e: return { "status": "failed", "message": f"SSH connection failed: {e}", "error": str(e), } else: return { "status": "success", "message": (f"SSH connection successful to {host_alias} ({hpc.auth_mode})"), "remote_directory": output, } @tool def submit_slurm_job( slurm_file: str, host_alias: str | None = None, remote_dir: str = "~/slurm_jobs", ) -> dict[str, Any]: """ Submit a SLURM job to HPC cluster. This tool transfers a SLURM script to the HPC cluster and submits it using the sbatch command. Args: slurm_file: Path to local SLURM script file host_alias: Host alias from ~/.ssh/config. If not provided, uses HPC_HOST_ALIAS from .env remote_dir: Remote directory to store scripts (default: ~/slurm_jobs) Returns: Dictionary with submission status and job ID """ if host_alias is None: host_alias = config.hpc_host_alias slurm_path = Path(slurm_file) if not slurm_path.exists(): return { "status": "failed", "message": f"SLURM file not found: {slurm_file}", } # Per-trial submission budget (training_code_* cost guardrail). The cluster # dryrun_* job is excluded -- same definition the scorer's _full_run_job_ids # uses -- so only real full training runs count against the budget. budget = _submit_budget is_full_run = not slurm_path.name.startswith("dryrun_") sid = get_current_session_id() if (budget is not None and is_full_run) else "" if budget is not None and is_full_run and _submit_counts.get(sid, 0) >= budget: return { "status": "failed", "message": ( f"{SUBMISSION_BUDGET_EXHAUSTED_MARKER}: " f"{_submit_counts.get(sid, 0)}/{budget} full training jobs already " "submitted for this task. Do NOT submit again -- finalize by calling " "evaluate_model on your best completed job." ), } try: hpc = create_hpc_connection(host_alias) job_id = hpc.submit_job(slurm_file, remote_dir=remote_dir) except Exception as e: return { "status": "failed", "message": f"Failed to submit job: {e}", "error": str(e), } else: if budget is not None and is_full_run: _submit_counts[sid] = _submit_counts.get(sid, 0) + 1 if job_id: # Record for teardown cleanup (cancel_session_jobs) -- same keying # and full-run gating as the budget counter above. _submit_job_ids.setdefault(sid, []).append(job_id) return { "status": "success", "message": "Job submitted successfully", "job_id": job_id, "remote_file": f"{remote_dir}/{slurm_path.name}", } @tool def get_slurm_job_status( job_id: str, host_alias: str | None = None, ) -> dict[str, Any]: """ Get the status of a SLURM job. This tool checks the status of a submitted SLURM job using the squeue command. Args: job_id: SLURM job ID host_alias: Host alias from ~/.ssh/config. If not provided, uses HPC_HOST_ALIAS from .env Returns: Dictionary with job status information """ if host_alias is None: host_alias = config.hpc_host_alias try: hpc = create_hpc_connection(host_alias) status = hpc.get_job_status(job_id) except Exception as e: return { "status": "failed", "job_id": job_id, "message": f"Failed to get job status: {e}", "error": str(e), } else: return { "status": "success", "job_id": job_id, "status_output": status, } @tool def cancel_slurm_job( job_id: str, host_alias: str | None = None, ) -> dict[str, Any]: """ Cancel a running SLURM job. This tool cancels a submitted SLURM job using the scancel command. Args: job_id: SLURM job ID host_alias: Host alias from ~/.ssh/config. If not provided, uses HPC_HOST_ALIAS from .env Returns: Dictionary with cancellation status """ if host_alias is None: host_alias = config.hpc_host_alias try: hpc = create_hpc_connection(host_alias) hpc.cancel_job(job_id) except Exception as e: return { "status": "failed", "job_id": job_id, "message": f"Failed to cancel job: {e}", "error": str(e), } else: return { "status": "success", "message": f"Job {job_id} cancelled successfully", "job_id": job_id, } @tool def download_job_outputs( job_id: str, host_alias: str | None = None, remote_dir: str = "~/slurm_jobs", local_dir: str = "outputs", ) -> dict[str, Any]: """ Download job output files from HPC cluster. This tool downloads the .out and .err output files from a completed SLURM job to the local machine. Args: job_id: SLURM job ID host_alias: Host alias from ~/.ssh/config. If not provided, uses HPC_HOST_ALIAS from .env remote_dir: Remote directory where outputs are stored (default: ~/slurm_jobs) local_dir: Local directory to download to (default: outputs) Returns: Dictionary with download status and file locations """ if host_alias is None: host_alias = config.hpc_host_alias try: hpc = create_hpc_connection(host_alias) hpc.get_job_output(job_id, remote_dir=remote_dir, local_dir=local_dir) except Exception as e: return { "status": "failed", "job_id": job_id, "message": f"Failed to download outputs: {e}", "error": str(e), } else: # Build list of downloaded files output_dir = Path(local_dir) err_files = list(output_dir.glob(f"*{job_id}.err")) out_files = list(output_dir.glob(f"*{job_id}.out")) file_list = [str(f) for f in err_files + out_files] message = f"Job outputs downloaded successfully to {local_dir}/" if file_list: message += "\n\nDownloaded files:\n" + "\n".join( f"- {Path(f).name}" for f in file_list ) return { "status": "success", "message": message, "job_id": job_id, "local_directory": local_dir, "downloaded_files": file_list, }