| import asyncio |
| import base64 |
| import os |
| import re |
| import shlex |
| import shutil |
| import signal |
| import socket |
| import subprocess |
| import tempfile |
| import time |
| import uuid |
| from pathlib import Path |
| from typing import Any, Dict, Optional |
|
|
| import httpx |
| from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile, status |
| from fastapi.responses import FileResponse |
| from pydantic import BaseModel |
|
|
|
|
| |
| |
| |
|
|
| CLUSTER_LOGIN_HOST = os.environ.get("CLUSTER_LOGIN_HOST", "kraken") |
| CLUSTER_USER = os.environ.get("CLUSTER_USER", "manolo") |
| CLUSTER_SSH_PORT = int(os.environ.get("CLUSTER_SSH_PORT", "22")) |
| CLUSTER_MODEL_DIR = os.environ.get("CLUSTER_MODEL_DIR", "/home/manolo/GeoGlyphFinal/model") |
| WORKER_INFO_DIR = os.environ.get("WORKER_INFO_DIR", "/home/manolo/geoglyph_workers") |
| SBATCH_SCRIPT = os.environ.get("SBATCH_SCRIPT", "run_geoglyph_worker_env.sbatch") |
| WORKER_JOB_NAME = os.environ.get("WORKER_JOB_NAME", "geoglyph-worker") |
| WORKER_PORT = int(os.environ.get("WORKER_PORT", "8000")) |
| GATEWAY_LOCAL_PORT = int(os.environ.get("GATEWAY_LOCAL_PORT", "18001")) |
| GATEWAY_API_TOKEN = os.environ.get("GATEWAY_API_TOKEN", "") |
|
|
| SSH_PRIVATE_KEY = os.environ.get("SSH_PRIVATE_KEY", "") |
| SSH_PRIVATE_KEY_BASE64 = os.environ.get("SSH_PRIVATE_KEY_BASE64", "") |
| SSH_PASSWORD = os.environ.get("SSH_PASSWORD", "") |
| SSH_KNOWN_HOSTS = os.environ.get("SSH_KNOWN_HOSTS", "") |
|
|
| RESULTS_DIR = Path(os.environ.get("RESULTS_DIR", "/tmp/geoglyph_gateway_results")) |
| RESULTS_DIR.mkdir(parents=True, exist_ok=True) |
|
|
| REQUEST_TIMEOUT = float(os.environ.get("REQUEST_TIMEOUT", "30")) |
| SEGMENT_TIMEOUT_SECONDS = int(os.environ.get("SEGMENT_TIMEOUT_SECONDS", "1800")) |
| STATUS_POLL_SECONDS = float(os.environ.get("STATUS_POLL_SECONDS", "2")) |
| WORKER_START_TIMEOUT_SECONDS = int(os.environ.get("WORKER_START_TIMEOUT_SECONDS", "300")) |
|
|
| SSH_TUNNEL_RETRIES = int(os.environ.get("SSH_TUNNEL_RETRIES", "5")) |
| SSH_TUNNEL_RETRY_SLEEP_SECONDS = float(os.environ.get("SSH_TUNNEL_RETRY_SLEEP_SECONDS", "4")) |
|
|
| ENSURE_WORKER_RETRY_SECONDS = int(os.environ.get("ENSURE_WORKER_RETRY_SECONDS", "900")) |
| ENSURE_WORKER_RETRY_SLEEP_SECONDS = float(os.environ.get("ENSURE_WORKER_RETRY_SLEEP_SECONDS", "10")) |
|
|
| CLUSTER_SSH_REFUSED_COOLDOWN_SECONDS = int( |
| os.environ.get("CLUSTER_SSH_REFUSED_COOLDOWN_SECONDS", "620") |
| ) |
|
|
| |
| |
| |
|
|
| app = FastAPI( |
| title="GeoGlyph Gateway API", |
| description="Gateway API that tunnels to a Slurm-hosted GeoGlyph SAM2 worker.", |
| version="0.1.0", |
| ) |
|
|
| STATE: Dict[str, Any] = { |
| "worker": { |
| "status": "not_started", |
| "slurm_job_id": None, |
| "node": None, |
| "worker_port": WORKER_PORT, |
| "local_port": GATEWAY_LOCAL_PORT, |
| "tunnel_pid": None, |
| "last_error": None, |
| "started_at": None, |
| }, |
| "tunnel_proc": None, |
| } |
|
|
| TASKS: Dict[str, Dict[str, Any]] = {} |
| TASK_LOCK = asyncio.Lock() |
| WORKER_ENSURE_LOCK = asyncio.Lock() |
|
|
|
|
| |
| |
| |
|
|
| def require_token(authorization: Optional[str] = Header(None)): |
| """Protect the public gateway if GATEWAY_API_TOKEN is configured.""" |
| if not GATEWAY_API_TOKEN: |
| return |
|
|
| expected = f"Bearer {GATEWAY_API_TOKEN}" |
| if authorization != expected: |
| raise HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail="Invalid or missing bearer token.", |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def parse_remote_worker_info(output: str) -> dict: |
| data = {} |
|
|
| for line in output.strip().splitlines(): |
| line = line.strip() |
| if "=" in line: |
| key, value = line.split("=", 1) |
| data[key.strip()] = value.strip() |
|
|
| if "JOB_ID" not in data or "NODE" not in data or "PORT" not in data: |
| raise RuntimeError(f"Could not parse worker info from remote output:\n{output}") |
|
|
| return { |
| "slurm_job_id": data["JOB_ID"], |
| "node": data["NODE"], |
| "worker_port": int(data["PORT"]), |
| "local_port": GATEWAY_LOCAL_PORT, |
| } |
|
|
|
|
| async def ensure_or_start_worker_job_remote() -> dict: |
| """ |
| Use ONE ssh connection to: |
| 1. Check for a running worker job. |
| 2. If absent, submit sbatch. |
| 3. Wait on the cluster side for node/port. |
| 4. Return JOB_ID/NODE/PORT. |
| """ |
|
|
| remote_script = f""" |
| set -u |
| |
| MODEL_DIR={shlex.quote(CLUSTER_MODEL_DIR)} |
| INFO_DIR={shlex.quote(WORKER_INFO_DIR)} |
| JOB_NAME={shlex.quote(WORKER_JOB_NAME)} |
| USER_NAME={shlex.quote(CLUSTER_USER)} |
| SBATCH_SCRIPT={shlex.quote(SBATCH_SCRIPT)} |
| DEFAULT_PORT={WORKER_PORT} |
| TIMEOUT={WORKER_START_TIMEOUT_SECONDS} |
| |
| existing="$(squeue -u "$USER_NAME" -n "$JOB_NAME" -t PENDING,RUNNING -h -o "%A|%T|%N|%R" | head -n 1 || true)" |
| |
| if [ -n "$existing" ]; then |
| JOB_ID="$(printf '%s\n' "$existing" | cut -d'|' -f1)" |
| EXISTING_STATE="$(printf '%s\n' "$existing" | cut -d'|' -f2)" |
| NODE="$(printf '%s\n' "$existing" | cut -d'|' -f3)" |
| PORT="$DEFAULT_PORT" |
| |
| if [ "$EXISTING_STATE" = "RUNNING" ] && [ -n "$NODE" ] && [ "$NODE" != "(null)" ]; then |
| if [ -f "$INFO_DIR/$JOB_ID/port.txt" ]; then |
| PORT="$(cat "$INFO_DIR/$JOB_ID/port.txt" | tr -d '\r\n')" |
| fi |
| |
| echo "JOB_ID=$JOB_ID" |
| echo "NODE=$NODE" |
| echo "PORT=$PORT" |
| exit 0 |
| fi |
| |
| # Existing job is pending or has no usable node yet. |
| # Do not submit a duplicate. Continue below and wait for this JOB_ID. |
| else |
| sbatch_output="$(cd "$MODEL_DIR" && sbatch "$SBATCH_SCRIPT")" |
| JOB_ID="$(printf '%s\n' "$sbatch_output" | sed -n 's/^Submitted batch job //p' | head -n 1)" |
| |
| if [ -z "$JOB_ID" ]; then |
| echo "ERROR=Could not parse sbatch output: $sbatch_output" |
| exit 2 |
| fi |
| fi |
| |
| deadline=$((SECONDS + TIMEOUT)) |
| NODE="" |
| PORT="$DEFAULT_PORT" |
| |
| while [ "$SECONDS" -lt "$deadline" ]; do |
| if [ -f "$INFO_DIR/$JOB_ID/node.txt" ]; then |
| NODE="$(cat "$INFO_DIR/$JOB_ID/node.txt" | tr -d '\\r\\n')" |
| fi |
| |
| if [ -f "$INFO_DIR/$JOB_ID/port.txt" ]; then |
| PORT="$(cat "$INFO_DIR/$JOB_ID/port.txt" | tr -d '\\r\\n')" |
| fi |
| |
| if [ -z "$NODE" ]; then |
| sline="$(squeue -j "$JOB_ID" -h -o "%T|%N|%R" | head -n 1 || true)" |
| state="$(printf '%s' "$sline" | cut -d'|' -f1)" |
| snode="$(printf '%s' "$sline" | cut -d'|' -f2)" |
| |
| if [ "$state" = "RUNNING" ] && [ -n "$snode" ] && [ "$snode" != "(null)" ]; then |
| NODE="$snode" |
| fi |
| |
| if [ "$state" = "FAILED" ] || [ "$state" = "CANCELLED" ] || [ "$state" = "TIMEOUT" ] || [ "$state" = "OUT_OF_MEMORY" ]; then |
| echo "ERROR=Slurm job $JOB_ID ended with state $state" |
| exit 3 |
| fi |
| fi |
| |
| if [ -n "$NODE" ]; then |
| echo "JOB_ID=$JOB_ID" |
| echo "NODE=$NODE" |
| echo "PORT=$PORT" |
| exit 0 |
| fi |
| |
| sleep 5 |
| done |
| |
| echo "ERROR=Timed out waiting for worker node for job $JOB_ID" |
| exit 4 |
| """ |
|
|
| remote_command = "bash -lc " + shlex.quote(remote_script) |
|
|
| output = await asyncio.to_thread( |
| ssh_run, |
| remote_command, |
| WORKER_START_TIMEOUT_SECONDS + 90, |
| ) |
|
|
| if "ERROR=" in output and "JOB_ID=" not in output: |
| raise RuntimeError(f"Remote worker startup failed:\n{output}") |
|
|
| return parse_remote_worker_info(output) |
|
|
| def is_retryable_cluster_ssh_error(error_text: str) -> bool: |
| text = (error_text or "").lower() |
| return any( |
| needle in text |
| for needle in [ |
| "connection refused", |
| "connection timed out", |
| "connection reset", |
| "connection closed", |
| "temporary failure", |
| "no route to host", |
| "timed out after", |
| "timeoutexpired", |
| ] |
| ) |
|
|
| async def ensure_worker_running_with_retries() -> dict: |
| deadline = time.time() + ENSURE_WORKER_RETRY_SECONDS |
| attempt = 0 |
| last_error = None |
|
|
| while time.time() < deadline: |
| attempt += 1 |
|
|
| try: |
| STATE["worker"].update( |
| { |
| "status": "ensuring_worker", |
| "last_error": last_error, |
| "ensure_attempt": attempt, |
| } |
| ) |
| return await ensure_worker_running() |
|
|
| except Exception as exc: |
| last_error = str(exc) |
|
|
| STATE["worker"].update( |
| { |
| "status": "waiting_for_cluster_ssh", |
| "last_error": last_error, |
| "ensure_attempt": attempt, |
| } |
| ) |
|
|
| if not is_retryable_cluster_ssh_error(last_error): |
| raise |
|
|
| lower_error = (last_error or "").lower() |
|
|
| if "connection refused" in lower_error: |
| sleep_seconds = CLUSTER_SSH_REFUSED_COOLDOWN_SECONDS |
| else: |
| sleep_seconds = ENSURE_WORKER_RETRY_SLEEP_SECONDS |
|
|
| STATE["worker"]["retry_sleep_seconds"] = sleep_seconds |
|
|
| await asyncio.sleep(sleep_seconds) |
|
|
| raise RuntimeError( |
| f"Could not ensure worker after {ENSURE_WORKER_RETRY_SECONDS} seconds. " |
| f"Last error: {last_error}" |
| ) |
|
|
| def _write_ssh_files() -> tuple[Optional[str], Optional[str]]: |
| """Write SSH private key and known_hosts from env secrets to /tmp.""" |
| key_path = None |
| known_hosts_path = None |
|
|
| key_text = "" |
| if SSH_PRIVATE_KEY_BASE64: |
| try: |
| key_text = base64.b64decode(SSH_PRIVATE_KEY_BASE64).decode("utf-8") |
| except Exception as exc: |
| raise RuntimeError(f"Could not decode SSH_PRIVATE_KEY_BASE64: {exc}") |
| elif SSH_PRIVATE_KEY: |
| key_text = SSH_PRIVATE_KEY.replace("\\n", "\n") |
|
|
| if key_text.strip(): |
| key_path = "/tmp/geoglyph_gateway_ssh_key" |
| Path(key_path).write_text(key_text.strip() + "\n", encoding="utf-8") |
| os.chmod(key_path, 0o600) |
|
|
| if SSH_KNOWN_HOSTS: |
| known_hosts_path = "/tmp/geoglyph_gateway_known_hosts" |
| known_hosts_text = SSH_KNOWN_HOSTS.replace("\\n", "\n").strip() + "\n" |
| Path(known_hosts_path).write_text(known_hosts_text, encoding="utf-8") |
| os.chmod(known_hosts_path, 0o644) |
|
|
| return key_path, known_hosts_path |
|
|
|
|
| def _ssh_env() -> Optional[dict]: |
| """Use sshpass via SSHPASS when password auth is enabled.""" |
| if not SSH_PASSWORD: |
| return None |
| env = os.environ.copy() |
| env["SSHPASS"] = SSH_PASSWORD |
| return env |
|
|
|
|
| def _ssh_base_cmd() -> list[str]: |
| key_path, known_hosts_path = _write_ssh_files() |
| use_password = bool(SSH_PASSWORD and not key_path) |
|
|
| cmd = [] |
| if use_password: |
| cmd.extend(["sshpass", "-e"]) |
|
|
| cmd.extend([ |
| "ssh", |
| "-p", |
| str(CLUSTER_SSH_PORT), |
| "-o", |
| "ServerAliveInterval=30", |
| "-o", |
| "ServerAliveCountMax=3", |
| ]) |
|
|
| if use_password: |
| cmd.extend([ |
| "-o", |
| "BatchMode=no", |
| "-o", |
| "PreferredAuthentications=password,keyboard-interactive", |
| "-o", |
| "PubkeyAuthentication=no", |
| ]) |
| else: |
| cmd.extend(["-o", "BatchMode=yes"]) |
|
|
| if key_path: |
| cmd.extend(["-i", key_path]) |
|
|
| if known_hosts_path: |
| cmd.extend([ |
| "-o", |
| "StrictHostKeyChecking=yes", |
| "-o", |
| f"UserKnownHostsFile={known_hosts_path}", |
| ]) |
| else: |
| |
| cmd.extend([ |
| "-o", |
| "StrictHostKeyChecking=no", |
| "-o", |
| "UserKnownHostsFile=/tmp/geoglyph_gateway_known_hosts_auto", |
| ]) |
|
|
| return cmd |
|
|
|
|
| def ssh_run(remote_command: str, timeout: int = 60) -> str: |
| cmd = _ssh_base_cmd() + [f"{CLUSTER_USER}@{CLUSTER_LOGIN_HOST}", remote_command] |
| proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=_ssh_env()) |
| if proc.returncode != 0: |
| raise RuntimeError( |
| f"SSH command failed with code {proc.returncode}.\n" |
| f"STDOUT: {proc.stdout}\nSTDERR: {proc.stderr}\nCOMMAND: {remote_command}" |
| ) |
| return proc.stdout.strip() |
|
|
|
|
| def _open_tunnel_once(node: str, worker_port: int, local_port: int) -> int: |
| """Open one SSH tunnel attempt. Raise with stderr if ssh exits immediately.""" |
| close_tunnel() |
|
|
| cmd = _ssh_base_cmd() + [ |
| "-N", |
| "-L", |
| f"127.0.0.1:{local_port}:{node}:{worker_port}", |
| f"{CLUSTER_USER}@{CLUSTER_LOGIN_HOST}", |
| ] |
|
|
| proc = subprocess.Popen( |
| cmd, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| text=True, |
| env=_ssh_env(), |
| preexec_fn=os.setsid if hasattr(os, "setsid") else None, |
| ) |
|
|
| STATE["tunnel_proc"] = proc |
| STATE["worker"]["tunnel_pid"] = proc.pid |
| STATE["worker"]["local_port"] = local_port |
|
|
| time.sleep(1.0) |
|
|
| if proc.poll() is not None: |
| stdout = "" |
| stderr = "" |
|
|
| try: |
| stdout = proc.stdout.read() if proc.stdout else "" |
| except Exception: |
| pass |
|
|
| try: |
| stderr = proc.stderr.read() if proc.stderr else "" |
| except Exception: |
| pass |
|
|
| STATE["tunnel_proc"] = None |
| STATE["worker"]["tunnel_pid"] = None |
|
|
| raise RuntimeError( |
| "SSH tunnel process exited immediately.\n" |
| f"local_port={local_port}, target={node}:{worker_port}\n" |
| f"exit_code={proc.returncode}\n" |
| f"STDOUT: {stdout}\n" |
| f"STDERR: {stderr}" |
| ) |
|
|
| return proc.pid |
|
|
|
|
| def open_tunnel(node: str, worker_port: int = WORKER_PORT, local_port: int = GATEWAY_LOCAL_PORT) -> int: |
| """Open local_port and forward it to node:worker_port through the login node.""" |
| last_error = None |
|
|
| for attempt in range(1, SSH_TUNNEL_RETRIES + 1): |
| try: |
| STATE["worker"]["last_error"] = ( |
| f"Opening SSH tunnel attempt {attempt}/{SSH_TUNNEL_RETRIES} " |
| f"via {CLUSTER_LOGIN_HOST}:{CLUSTER_SSH_PORT}, " |
| f"local_port={local_port}, target={node}:{worker_port}" |
| ) |
| return _open_tunnel_once(node, worker_port, local_port) |
|
|
| except Exception as exc: |
| last_error = str(exc) |
| STATE["worker"]["last_error"] = last_error |
| close_tunnel() |
|
|
| |
| |
| if is_local_port_bind_error(last_error): |
| raise |
|
|
| |
| if is_retryable_ssh_connection_error(last_error) and attempt < SSH_TUNNEL_RETRIES: |
| time.sleep(SSH_TUNNEL_RETRY_SLEEP_SECONDS) |
| continue |
|
|
| raise |
|
|
| raise RuntimeError(f"Could not open SSH tunnel. Last error: {last_error}") |
|
|
|
|
| def close_tunnel(): |
| proc = STATE.get("tunnel_proc") |
| if proc is None: |
| return |
|
|
| if proc.poll() is None: |
| try: |
| if hasattr(os, "killpg"): |
| os.killpg(os.getpgid(proc.pid), signal.SIGTERM) |
| else: |
| proc.terminate() |
| proc.wait(timeout=5) |
| except Exception: |
| try: |
| proc.kill() |
| except Exception: |
| pass |
|
|
| STATE["tunnel_proc"] = None |
| STATE["worker"]["tunnel_pid"] = None |
|
|
|
|
| def parse_sbatch_job_id(output: str) -> str: |
| match = re.search(r"Submitted batch job\s+(\d+)", output) |
| if not match: |
| match = re.search(r"\b(\d+)\b", output) |
| if not match: |
| raise RuntimeError(f"Could not parse Slurm job id from sbatch output: {output!r}") |
| return match.group(1) |
|
|
|
|
| async def worker_base_url() -> str: |
| local_port = STATE["worker"].get("local_port") or GATEWAY_LOCAL_PORT |
| return f"http://127.0.0.1:{local_port}" |
|
|
|
|
| async def wait_for_worker_health(timeout_seconds: int = 120) -> dict: |
| deadline = time.time() + timeout_seconds |
| url = f"{await worker_base_url()}/health" |
| last_error = None |
|
|
| async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: |
| while time.time() < deadline: |
| try: |
| response = await client.get(url) |
| if response.status_code == 200: |
| return response.json() |
| last_error = f"HTTP {response.status_code}: {response.text[:300]}" |
| except Exception as exc: |
| last_error = str(exc) |
|
|
| await asyncio.sleep(2) |
|
|
| raise RuntimeError(f"Worker did not become healthy in time. Last error: {last_error}") |
|
|
|
|
| async def get_worker_health_once(timeout_seconds: float = 5) -> Optional[dict]: |
| """Return worker /health if the current tunnel works; otherwise return None.""" |
| tunnel_proc = STATE.get("tunnel_proc") |
| if tunnel_proc is None or tunnel_proc.poll() is not None: |
| return None |
|
|
| url = f"{await worker_base_url()}/health" |
| try: |
| async with httpx.AsyncClient(timeout=timeout_seconds) as client: |
| response = await client.get(url) |
| if response.status_code == 200: |
| return response.json() |
| except Exception: |
| return None |
| return None |
|
|
| async def get_health_on_local_port(local_port: int, timeout_seconds: float = 5) -> Optional[dict]: |
| """Return worker /health through a specific local port, independent of STATE.""" |
| url = f"http://127.0.0.1:{local_port}/health" |
|
|
| try: |
| async with httpx.AsyncClient(timeout=timeout_seconds) as client: |
| response = await client.get(url) |
|
|
| if response.status_code == 200: |
| return response.json() |
| except Exception: |
| return None |
|
|
| return None |
|
|
|
|
| def candidate_local_ports(preferred_port: int, allow_fallback: bool = True) -> list[int]: |
| """Try preferred port first; optionally try a few nearby ports if it is stale/busy.""" |
| if not allow_fallback: |
| return [preferred_port] |
|
|
| ports = [preferred_port] |
| for port in range(preferred_port + 1, preferred_port + 6): |
| ports.append(port) |
|
|
| return ports |
|
|
| def is_local_port_bind_error(error_text: str) -> bool: |
| text = (error_text or "").lower() |
| return any( |
| needle in text |
| for needle in [ |
| "address already in use", |
| "bind", |
| "cannot listen", |
| "port is already allocated", |
| ] |
| ) |
|
|
|
|
| def is_retryable_ssh_connection_error(error_text: str) -> bool: |
| text = (error_text or "").lower() |
| return any( |
| needle in text |
| for needle in [ |
| "connection refused", |
| "connection timed out", |
| "connection reset", |
| "connection closed", |
| "temporary failure", |
| "no route to host", |
| ] |
| ) |
|
|
|
|
| def parse_squeue_worker(output: str) -> Optional[dict]: |
| """Parse one RUNNING Slurm worker line: job_id|node.""" |
| line = output.strip().splitlines()[0].strip() if output.strip() else "" |
| if not line: |
| return None |
|
|
| parts = line.split("|", 1) |
| if len(parts) != 2: |
| raise RuntimeError(f"Unexpected squeue output for worker discovery: {output!r}") |
|
|
| job_id, node = parts[0].strip(), parts[1].strip() |
| if not job_id or not node or node in {"(null)", "None", "n/a"}: |
| return None |
|
|
| return { |
| "slurm_job_id": job_id, |
| "node": node, |
| "worker_port": WORKER_PORT, |
| "local_port": GATEWAY_LOCAL_PORT, |
| } |
|
|
|
|
| async def discover_running_worker_job() -> Optional[dict]: |
| """Find one RUNNING Slurm job named WORKER_JOB_NAME, if it exists.""" |
| job_name = shlex.quote(WORKER_JOB_NAME) |
| user = shlex.quote(CLUSTER_USER) |
| |
| cmd = f'squeue -u {user} -n {job_name} -t RUNNING -h -o "%A|%N" | head -n 1' |
| output = await asyncio.to_thread(ssh_run, cmd, 30) |
| return parse_squeue_worker(output) |
|
|
|
|
| async def connect_to_worker( |
| worker: dict, |
| health_timeout_seconds: int = 120, |
| allow_port_fallback: bool = True, |
| ) -> dict: |
| """Open tunnel to the given worker node and verify /health.""" |
| node = worker["node"] |
| worker_port = int(worker.get("worker_port") or WORKER_PORT) |
| preferred_local_port = int(worker.get("local_port") or GATEWAY_LOCAL_PORT) |
| job_id = worker.get("slurm_job_id") |
|
|
| last_error = None |
|
|
| for local_port in candidate_local_ports(preferred_local_port, allow_fallback=allow_port_fallback): |
| STATE["worker"].update( |
| { |
| "status": "checking_existing_tunnel", |
| "slurm_job_id": job_id, |
| "node": node, |
| "worker_port": worker_port, |
| "local_port": local_port, |
| "last_error": None, |
| } |
| ) |
|
|
| |
| existing_health = await get_health_on_local_port(local_port, timeout_seconds=5) |
| if existing_health is not None: |
| STATE["worker"].update( |
| { |
| "status": "running", |
| "slurm_job_id": job_id, |
| "node": node, |
| "worker_port": worker_port, |
| "local_port": local_port, |
| "last_error": None, |
| "started_at": time.time(), |
| } |
| ) |
| return {**STATE["worker"], "worker_health": existing_health} |
|
|
| try: |
| STATE["worker"].update( |
| { |
| "status": "opening_tunnel", |
| "slurm_job_id": job_id, |
| "node": node, |
| "worker_port": worker_port, |
| "local_port": local_port, |
| "last_error": None, |
| } |
| ) |
|
|
| pid = await asyncio.to_thread(open_tunnel, node, worker_port, local_port) |
|
|
| STATE["worker"]["tunnel_pid"] = pid |
| STATE["worker"].update( |
| { |
| "status": "waiting_for_health", |
| "started_at": time.time(), |
| } |
| ) |
|
|
| health_payload = await wait_for_worker_health(timeout_seconds=health_timeout_seconds) |
|
|
| STATE["worker"].update( |
| { |
| "status": "running", |
| "last_error": None, |
| "local_port": local_port, |
| "worker_port": worker_port, |
| "node": node, |
| "slurm_job_id": job_id, |
| } |
| ) |
|
|
| return {**STATE["worker"], "worker_health": health_payload} |
|
|
| except Exception as exc: |
| last_error = str(exc) |
| STATE["worker"].update( |
| { |
| "status": "failed", |
| "last_error": last_error, |
| "local_port": local_port, |
| } |
| ) |
|
|
| close_tunnel() |
|
|
| |
| |
| if allow_port_fallback and is_local_port_bind_error(last_error): |
| continue |
|
|
| |
| break |
|
|
| raise RuntimeError( |
| f"Could not connect to worker {node}:{worker_port}. " |
| f"Preferred local port was {preferred_local_port}. " |
| f"Last error: {last_error}" |
| ) |
|
|
|
|
| async def start_worker_job_and_connect() -> dict: |
| """Submit the Slurm worker job, get its node, open tunnel, and verify /health.""" |
| STATE["worker"].update( |
| { |
| "status": "submitting", |
| "last_error": None, |
| "node": None, |
| "tunnel_pid": None, |
| } |
| ) |
|
|
| sbatch_cmd = f"cd {shlex.quote(CLUSTER_MODEL_DIR)} && sbatch {shlex.quote(SBATCH_SCRIPT)}" |
| sbatch_output = await asyncio.to_thread(ssh_run, sbatch_cmd, 60) |
| job_id = parse_sbatch_job_id(sbatch_output) |
|
|
| STATE["worker"].update( |
| { |
| "status": "waiting_for_slurm", |
| "slurm_job_id": job_id, |
| "node": None, |
| "worker_port": WORKER_PORT, |
| "local_port": GATEWAY_LOCAL_PORT, |
| "last_error": None, |
| } |
| ) |
|
|
| deadline = time.time() + WORKER_START_TIMEOUT_SECONDS |
| node = None |
| worker_port = WORKER_PORT |
| last_error = None |
|
|
| while time.time() < deadline: |
| try: |
| |
| |
| squeue_cmd = f'squeue -j {shlex.quote(job_id)} -h -o "%T|%N|%R"' |
| squeue_output = await asyncio.to_thread(ssh_run, squeue_cmd, 20) |
|
|
| if not squeue_output.strip(): |
| |
| sacct_cmd = ( |
| f"sacct -j {shlex.quote(job_id)} " |
| "--format=JobID,JobName,State,ExitCode,Elapsed,NodeList,Reason " |
| "--noheader" |
| ) |
| try: |
| sacct_output = await asyncio.to_thread(ssh_run, sacct_cmd, 20) |
| except Exception as sacct_exc: |
| sacct_output = f"sacct failed: {sacct_exc}" |
|
|
| raise RuntimeError( |
| f"Slurm job {job_id} is no longer visible in squeue. " |
| f"sacct output: {sacct_output}" |
| ) |
|
|
| first_line = squeue_output.strip().splitlines()[0] |
| parts = first_line.split("|") |
| slurm_state = parts[0].strip() if len(parts) > 0 else "" |
| slurm_node = parts[1].strip() if len(parts) > 1 else "" |
| slurm_reason = parts[2].strip() if len(parts) > 2 else "" |
|
|
| STATE["worker"].update( |
| { |
| "status": "waiting_for_slurm", |
| "slurm_job_id": job_id, |
| "node": slurm_node if slurm_node not in {"", "(null)", "None", "n/a"} else None, |
| "last_error": f"Slurm state={slurm_state}, reason={slurm_reason}", |
| } |
| ) |
|
|
| if slurm_state in {"FAILED", "CANCELLED", "TIMEOUT", "OUT_OF_MEMORY", "NODE_FAIL"}: |
| raise RuntimeError( |
| f"Slurm worker job {job_id} ended with state={slurm_state}, reason={slurm_reason}" |
| ) |
|
|
| if slurm_state != "RUNNING": |
| await asyncio.sleep(3) |
| continue |
|
|
| |
| if slurm_node and slurm_node not in {"(null)", "None", "n/a"}: |
| node = slurm_node |
|
|
| |
| try: |
| node_cmd = ( |
| f"test -f {shlex.quote(WORKER_INFO_DIR)}/{shlex.quote(job_id)}/node.txt " |
| f"&& cat {shlex.quote(WORKER_INFO_DIR)}/{shlex.quote(job_id)}/node.txt " |
| f"|| true" |
| ) |
| node_file = await asyncio.to_thread(ssh_run, node_cmd, 20) |
| if node_file.strip(): |
| node = node_file.strip() |
| except Exception as exc: |
| last_error = f"Could not read node.txt, using squeue node if available: {exc}" |
|
|
| |
| try: |
| port_cmd = ( |
| f"test -f {shlex.quote(WORKER_INFO_DIR)}/{shlex.quote(job_id)}/port.txt " |
| f"&& cat {shlex.quote(WORKER_INFO_DIR)}/{shlex.quote(job_id)}/port.txt " |
| f"|| true" |
| ) |
| port_text = await asyncio.to_thread(ssh_run, port_cmd, 20) |
| if port_text.strip(): |
| worker_port = int(port_text.strip()) |
| except Exception as exc: |
| last_error = f"Could not read port.txt, using WORKER_PORT={WORKER_PORT}: {exc}" |
| worker_port = WORKER_PORT |
|
|
| if node: |
| break |
|
|
| last_error = ( |
| f"Job {job_id} is RUNNING but no node was available. " |
| f"squeue output: {squeue_output!r}" |
| ) |
| STATE["worker"]["last_error"] = last_error |
| await asyncio.sleep(3) |
|
|
| except Exception as exc: |
| last_error = str(exc) |
| STATE["worker"].update( |
| { |
| "status": "waiting_for_slurm", |
| "slurm_job_id": job_id, |
| "last_error": last_error, |
| } |
| ) |
| await asyncio.sleep(3) |
|
|
| if not node: |
| raise RuntimeError( |
| f"Worker node was not discovered for job {job_id}. " |
| f"Last error: {last_error}" |
| ) |
|
|
| STATE["worker"].update( |
| { |
| "status": "connecting", |
| "slurm_job_id": job_id, |
| "node": node, |
| "worker_port": worker_port, |
| "local_port": GATEWAY_LOCAL_PORT, |
| "last_error": None, |
| } |
| ) |
|
|
| return await connect_to_worker( |
| { |
| "slurm_job_id": job_id, |
| "node": node, |
| "worker_port": worker_port, |
| "local_port": GATEWAY_LOCAL_PORT, |
| }, |
| health_timeout_seconds=120, |
| ) |
|
|
|
|
| async def ensure_worker_running() -> dict: |
| """ |
| Ensure that /process can forward to a healthy worker. |
| |
| Cold start uses one remote SSH command to avoid triggering cluster SSH bans. |
| """ |
| async with WORKER_ENSURE_LOCK: |
| health_payload = await get_worker_health_once(timeout_seconds=5) |
| if health_payload is not None: |
| STATE["worker"].update({"status": "running", "last_error": None}) |
| return {**STATE["worker"], "worker_health": health_payload} |
|
|
| try: |
| STATE["worker"].update( |
| { |
| "status": "ensuring_slurm_worker", |
| "last_error": None, |
| } |
| ) |
|
|
| worker = await ensure_or_start_worker_job_remote() |
|
|
| STATE["worker"].update( |
| { |
| "status": "slurm_worker_ready", |
| "slurm_job_id": worker["slurm_job_id"], |
| "node": worker["node"], |
| "worker_port": worker["worker_port"], |
| "local_port": worker["local_port"], |
| "last_error": None, |
| } |
| ) |
|
|
| return await connect_to_worker(worker, health_timeout_seconds=120) |
|
|
| except Exception as exc: |
| STATE["worker"].update({"status": "failed", "last_error": str(exc)}) |
| raise |
|
|
| @app.get("/cluster/ping", dependencies=[Depends(require_token)]) |
| async def cluster_ping(): |
| """ |
| Minimal diagnostic endpoint. |
| |
| It only checks whether the Hugging Face Gateway can SSH into the cluster login node. |
| It does not use Slurm, does not open a tunnel, and does not contact the SAM2 worker. |
| """ |
| started = time.time() |
|
|
| remote_command = ( |
| "echo GEOGLYPH_SSH_OK && " |
| "hostname && " |
| "whoami && " |
| "date" |
| ) |
|
|
| try: |
| output = await asyncio.to_thread(ssh_run, remote_command, 20) |
|
|
| return { |
| "status": "ok", |
| "cluster_login_host": CLUSTER_LOGIN_HOST, |
| "cluster_user": CLUSTER_USER, |
| "cluster_ssh_port": CLUSTER_SSH_PORT, |
| "elapsed_seconds": round(time.time() - started, 3), |
| "output": output, |
| } |
|
|
| except Exception as exc: |
| return { |
| "status": "failed", |
| "cluster_login_host": CLUSTER_LOGIN_HOST, |
| "cluster_user": CLUSTER_USER, |
| "cluster_ssh_port": CLUSTER_SSH_PORT, |
| "elapsed_seconds": round(time.time() - started, 3), |
| "error": str(exc), |
| } |
|
|
| |
| |
| |
|
|
| class WorkerStartResponse(BaseModel): |
| status: str |
| slurm_job_id: Optional[str] |
| node: Optional[str] |
| worker_port: int |
| local_port: int |
| tunnel_pid: Optional[int] |
| worker_health: Optional[dict] = None |
|
|
|
|
| class WorkerConnectRequest(BaseModel): |
| node: str |
| worker_port: int = WORKER_PORT |
| local_port: int = GATEWAY_LOCAL_PORT |
|
|
|
|
| |
| |
| |
|
|
| @app.on_event("shutdown") |
| def shutdown_event(): |
| close_tunnel() |
|
|
|
|
| @app.get("/health") |
| async def health(): |
| tunnel_proc = STATE.get("tunnel_proc") |
| tunnel_alive = bool(tunnel_proc and tunnel_proc.poll() is None) |
| return { |
| "status": "ok", |
| "gateway": "running", |
| "worker_job_name": WORKER_JOB_NAME, |
| "worker": STATE["worker"], |
| "tunnel_alive": tunnel_alive, |
| "tasks": len(TASKS), |
| } |
|
|
|
|
| @app.post("/worker/connect", response_model=WorkerStartResponse, dependencies=[Depends(require_token)]) |
| async def worker_connect(req: WorkerConnectRequest): |
| """Connect the gateway to an already-running Slurm worker node.""" |
| try: |
| async with WORKER_ENSURE_LOCK: |
| worker_payload = await connect_to_worker( |
| { |
| "slurm_job_id": None, |
| "node": req.node, |
| "worker_port": req.worker_port, |
| "local_port": req.local_port, |
| }, |
| health_timeout_seconds=60, |
| allow_port_fallback=False, |
| ) |
|
|
| return WorkerStartResponse( |
| **STATE["worker"], |
| worker_health=worker_payload.get("worker_health"), |
| ) |
|
|
| except Exception as exc: |
| STATE["worker"].update({"status": "failed", "last_error": str(exc)}) |
| raise HTTPException(status_code=500, detail=str(exc)) |
| |
|
|
| @app.post("/worker/start", response_model=WorkerStartResponse, dependencies=[Depends(require_token)]) |
| async def worker_start(): |
| """Explicit admin endpoint: reuse a healthy worker, connect to an existing job, or start one.""" |
| try: |
| worker_payload = await ensure_worker_running_with_retries() |
| return WorkerStartResponse(**STATE["worker"], worker_health=worker_payload.get("worker_health")) |
| except Exception as exc: |
| STATE["worker"].update({"status": "failed", "last_error": str(exc)}) |
| raise HTTPException(status_code=500, detail=str(exc)) |
|
|
|
|
| @app.post("/worker/stop", dependencies=[Depends(require_token)]) |
| async def worker_stop(): |
| """Close the tunnel. Optionally cancel the Slurm job if there is a known job id.""" |
| job_id = STATE["worker"].get("slurm_job_id") |
| close_tunnel() |
|
|
| cancel_error = None |
| if job_id: |
| try: |
| await asyncio.to_thread(ssh_run, f"scancel {job_id}", 30) |
| except Exception as exc: |
| cancel_error = str(exc) |
|
|
| STATE["worker"].update( |
| { |
| "status": "stopped", |
| "tunnel_pid": None, |
| "last_error": cancel_error, |
| } |
| ) |
| return {"status": "stopped", "slurm_job_id": job_id, "cancel_error": cancel_error} |
|
|
|
|
| @app.get("/worker/health", dependencies=[Depends(require_token)]) |
| async def worker_health(): |
| try: |
| health_payload = await wait_for_worker_health(timeout_seconds=10) |
| return {"status": "ok", "worker_health": health_payload, "worker": STATE["worker"]} |
| except Exception as exc: |
| raise HTTPException(status_code=503, detail=str(exc)) |
| |
| async def ensure_worker_running_for_process_task(gateway_task_id: str) -> dict: |
| """ |
| Robust worker ensure for /process. |
| |
| Temporary SSH failures to kraken should not immediately fail the task. |
| Instead, the task stays alive and retries. |
| """ |
| deadline = time.time() + ENSURE_WORKER_RETRY_SECONDS |
| attempt = 0 |
| last_error = None |
|
|
| while time.time() < deadline: |
| attempt += 1 |
|
|
| try: |
| async with TASK_LOCK: |
| TASKS[gateway_task_id].update( |
| { |
| "status": "ensuring_worker", |
| "ensure_attempt": attempt, |
| "worker_gateway_error": last_error, |
| } |
| ) |
|
|
| return await ensure_worker_running() |
|
|
| except Exception as exc: |
| last_error = str(exc) |
|
|
| async with TASK_LOCK: |
| TASKS[gateway_task_id].update( |
| { |
| "status": "waiting_for_cluster_ssh", |
| "ensure_attempt": attempt, |
| "worker_gateway_error": last_error, |
| "error": None, |
| } |
| ) |
|
|
| STATE["worker"].update( |
| { |
| "status": "waiting_for_cluster_ssh", |
| "last_error": last_error, |
| } |
| ) |
|
|
| |
| if not is_retryable_cluster_ssh_error(last_error): |
| raise |
|
|
| lower_error = (last_error or "").lower() |
|
|
| if "connection refused" in lower_error: |
| sleep_seconds = CLUSTER_SSH_REFUSED_COOLDOWN_SECONDS |
| else: |
| sleep_seconds = ENSURE_WORKER_RETRY_SLEEP_SECONDS |
|
|
| async with TASK_LOCK: |
| TASKS[gateway_task_id].update( |
| { |
| "status": "waiting_for_cluster_ssh", |
| "retry_sleep_seconds": sleep_seconds, |
| "worker_gateway_error": last_error, |
| "error": None, |
| } |
| ) |
|
|
| await asyncio.sleep(sleep_seconds) |
|
|
| raise RuntimeError( |
| f"Could not connect to worker after {ENSURE_WORKER_RETRY_SECONDS} seconds. " |
| f"Last error: {last_error}" |
| ) |
|
|
|
|
| async def forward_task_to_worker(gateway_task_id: str, crop_path: Path): |
| """Forward crop to the worker API, poll until completion, then download GPKG.""" |
| output_path = RESULTS_DIR / f"{gateway_task_id}.gpkg" |
| worker_task_id = None |
|
|
| try: |
| async with TASK_LOCK: |
| TASKS[gateway_task_id]["status"] = "forwarding" |
|
|
| async with TASK_LOCK: |
| TASKS[gateway_task_id]["status"] = "ensuring_worker" |
|
|
| worker_payload = await ensure_worker_running_for_process_task(gateway_task_id) |
|
|
| async with TASK_LOCK: |
| TASKS[gateway_task_id]["worker_gateway"] = worker_payload |
| TASKS[gateway_task_id]["status"] = "forwarding" |
|
|
| base = await worker_base_url() |
|
|
| async with httpx.AsyncClient(timeout=None) as client: |
| with crop_path.open("rb") as f: |
| files = {"crop": (crop_path.name, f, "image/tiff")} |
| data = {"device": "cuda"} |
| response = await client.post(f"{base}/process", files=files, data=data, timeout=REQUEST_TIMEOUT) |
|
|
| response.raise_for_status() |
| process_payload = response.json() |
| worker_task_id = process_payload["task_id"] |
|
|
| async with TASK_LOCK: |
| TASKS[gateway_task_id].update( |
| { |
| "status": "processing", |
| "worker_task_id": worker_task_id, |
| "worker_process_response": process_payload, |
| } |
| ) |
|
|
| deadline = time.time() + SEGMENT_TIMEOUT_SECONDS |
| last_status = None |
|
|
| while time.time() < deadline: |
| status_response = await client.get(f"{base}/status/{worker_task_id}", timeout=REQUEST_TIMEOUT) |
| status_response.raise_for_status() |
| status_payload = status_response.json() |
| last_status = status_payload |
|
|
| async with TASK_LOCK: |
| TASKS[gateway_task_id]["worker_status"] = status_payload |
|
|
| if status_payload.get("status") == "completed": |
| if not status_payload.get("output_exists"): |
| async with TASK_LOCK: |
| TASKS[gateway_task_id].update( |
| { |
| "status": "completed_no_output", |
| "finished_at": time.time(), |
| "n_masks": status_payload.get("n_masks"), |
| "output_exists": False, |
| "result": status_payload.get("result"), |
| } |
| ) |
| return |
|
|
| download_response = await client.get(f"{base}/download/{worker_task_id}", timeout=None) |
| download_response.raise_for_status() |
| output_path.write_bytes(download_response.content) |
|
|
| async with TASK_LOCK: |
| TASKS[gateway_task_id].update( |
| { |
| "status": "completed", |
| "finished_at": time.time(), |
| "n_masks": status_payload.get("n_masks"), |
| "output_exists": True, |
| "download_url": f"/download/{gateway_task_id}", |
| "result_path": str(output_path), |
| "result": status_payload.get("result"), |
| } |
| ) |
| return |
|
|
| if status_payload.get("status") == "failed": |
| raise RuntimeError(f"Worker task failed: {status_payload.get('error')}") |
|
|
| await asyncio.sleep(STATUS_POLL_SECONDS) |
|
|
| raise TimeoutError(f"Worker task timeout. Last status: {last_status}") |
|
|
| except Exception as exc: |
| async with TASK_LOCK: |
| TASKS[gateway_task_id].update( |
| { |
| "status": "failed", |
| "finished_at": time.time(), |
| "error": str(exc), |
| "worker_task_id": worker_task_id, |
| } |
| ) |
| finally: |
| try: |
| crop_path.unlink(missing_ok=True) |
| except Exception: |
| pass |
|
|
|
|
| @app.post("/process", status_code=status.HTTP_202_ACCEPTED, dependencies=[Depends(require_token)]) |
| async def process(crop: UploadFile = File(...)): |
| """Submit a crop. The gateway auto-discovers/starts/connects the Slurm worker in the background.""" |
|
|
| gateway_task_id = uuid.uuid4().hex[:12] |
| crop_path = RESULTS_DIR / f"{gateway_task_id}_crop.tif" |
|
|
| try: |
| with crop_path.open("wb") as f: |
| shutil.copyfileobj(crop.file, f) |
| finally: |
| await crop.close() |
|
|
| if not crop_path.is_file() or crop_path.stat().st_size == 0: |
| raise HTTPException(status_code=400, detail="Uploaded crop is empty.") |
|
|
| async with TASK_LOCK: |
| TASKS[gateway_task_id] = { |
| "task_id": gateway_task_id, |
| "status": "pending", |
| "created_at": time.time(), |
| "original_filename": crop.filename, |
| "worker_task_id": None, |
| "worker_status": None, |
| "error": None, |
| "output_exists": None, |
| "download_url": None, |
| } |
|
|
| asyncio.create_task(forward_task_to_worker(gateway_task_id, crop_path)) |
|
|
| return {"task_id": gateway_task_id, "status": "pending", "queue_size": len(TASKS)} |
|
|
|
|
| @app.get("/status/{task_id}", dependencies=[Depends(require_token)]) |
| async def get_status(task_id: str): |
| if task_id not in TASKS: |
| raise HTTPException(status_code=404, detail="Task not found") |
| return TASKS[task_id] |
|
|
|
|
| @app.get("/download/{task_id}", dependencies=[Depends(require_token)]) |
| async def download(task_id: str): |
| if task_id not in TASKS: |
| raise HTTPException(status_code=404, detail="Task not found") |
|
|
| task = TASKS[task_id] |
| if task.get("status") != "completed": |
| raise HTTPException(status_code=400, detail=f"Task is not completed. Current status: {task.get('status')}") |
|
|
| result_path = Path(task["result_path"]) |
| if not result_path.is_file(): |
| raise HTTPException(status_code=404, detail="Result file is missing.") |
|
|
| return FileResponse( |
| path=str(result_path), |
| media_type="application/geopackage+sqlite3", |
| filename=f"sam2_result_{task_id}.gpkg", |
| ) |
|
|