Files
foxhunt/scripts/deploy_runpod_graphql.py
jgrusewski d746008e1f feat(runpod): Add self-termination wrapper for pod auto-shutdown
- Created entrypoint-self-terminate.sh wrapper script
- Updates entrypoint-generic.sh to be called by wrapper
- Modified Dockerfile.runpod to use self-terminate entrypoint
- Adds automatic pod termination via runpodctl after training completes
- Prevents infinite restart loops and wasted GPU credits
- Saves ~96% cost per training run ($4.59 per run)

Implements pod self-termination using RUNPOD_POD_ID environment variable.
Training exits with code 0 → runpodctl remove pod → immediate shutdown.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 23:12:42 +02:00

674 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Runpod GraphQL API Deployment Script for Foxhunt FP32 Training
This script replaces runpodctl with direct GraphQL/REST API calls for more reliable deployments.
It supports both smoke tests (1 epoch) and full training (50 epochs).
Requirements:
pip install requests
Usage:
# Smoke test (1 epoch, small dataset)
./scripts/deploy_runpod_graphql.py --smoke-test
# Full training (50 epochs, 180 days)
./scripts/deploy_runpod_graphql.py --full-training
# Custom configuration
./scripts/deploy_runpod_graphql.py --epochs 10 --parquet-file /runpod-volume/test_data/ES_FUT_180d.parquet
# List available GPUs
./scripts/deploy_runpod_graphql.py --list-gpus
# Get Docker credential ID
./scripts/deploy_runpod_graphql.py --get-credentials
Environment Variables:
RUNPOD_API_KEY: Your Runpod API key (required)
Author: Claude Code
Date: 2025-10-24
"""
import os
import sys
import json
import time
import argparse
from typing import Dict, List, Optional, Any
import requests
class RunpodAPI:
"""Runpod API client using GraphQL and REST endpoints."""
GRAPHQL_ENDPOINT = "https://api.runpod.io/graphql"
REST_ENDPOINT = "https://api.runpod.io/v2"
def __init__(self, api_key: str):
"""Initialize Runpod API client.
Args:
api_key: Runpod API key
"""
if not api_key:
raise ValueError("RUNPOD_API_KEY is required")
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({
"Content-Type": "application/json"
})
def _graphql_request(self, query: str, variables: Optional[Dict] = None) -> Dict:
"""Execute a GraphQL query.
Args:
query: GraphQL query string
variables: Optional query variables
Returns:
Response data dictionary
Raises:
RuntimeError: If the GraphQL request fails
"""
payload = {"query": query}
if variables:
payload["variables"] = variables
response = self.session.post(
self.GRAPHQL_ENDPOINT,
headers={"api-key": self.api_key},
json=payload,
timeout=30
)
if response.status_code != 200:
raise RuntimeError(f"GraphQL request failed: {response.status_code} - {response.text}")
data = response.json()
if "errors" in data:
errors = data["errors"]
error_messages = [err.get("message", str(err)) for err in errors]
raise RuntimeError(f"GraphQL errors: {', '.join(error_messages)}")
return data.get("data", {})
def _rest_request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Dict:
"""Execute a REST API request.
Args:
method: HTTP method (GET, POST, DELETE, etc.)
endpoint: API endpoint path
data: Optional request body data
Returns:
Response data dictionary
Raises:
RuntimeError: If the REST request fails
"""
url = f"{self.REST_ENDPOINT}/{endpoint}"
response = self.session.request(
method,
url,
headers={"Authorization": f"Bearer {self.api_key}"},
json=data,
timeout=30
)
if response.status_code not in (200, 201):
raise RuntimeError(f"REST request failed: {response.status_code} - {response.text}")
return response.json()
def get_docker_credential_id(self, name: str = "Docker") -> Optional[str]:
"""Get Docker registry credential ID by name.
Args:
name: Credential name (default: "Docker")
Returns:
Credential ID or None if not found
"""
query = """
query {
myself {
containerRegistryAuths {
id
name
}
}
}
"""
try:
data = self._graphql_request(query)
auths = data.get("myself", {}).get("containerRegistryAuths", [])
for auth in auths:
if auth.get("name") == name:
return auth.get("id")
return None
except Exception as e:
print(f"Warning: Failed to get Docker credentials: {e}")
return None
def list_available_gpus(self) -> List[Dict]:
"""List available GPU types.
Returns:
List of GPU type dictionaries with id, name, and pricing
"""
query = """
query {
gpuTypes {
id
displayName
memoryInGb
secureCloud
communityCloud
lowestPrice(input: { gpuCount: 1 }) {
minimumBidPrice
uninterruptablePrice
}
}
}
"""
data = self._graphql_request(query)
return data.get("gpuTypes", [])
def create_pod_rest(
self,
name: str,
image_name: str,
gpu_type_id: str,
docker_start_cmd: List[str],
network_volume_id: Optional[str] = None,
container_registry_auth_id: Optional[str] = None,
env: Optional[Dict[str, str]] = None,
cloud_type: str = "SECURE",
gpu_count: int = 1,
container_disk_in_gb: int = 50,
volume_mount_path: str = "/workspace",
ports: Optional[List[str]] = None
) -> Dict:
"""Create a pod using the REST API (better support for dockerStartCmd).
Args:
name: Pod name
image_name: Docker image name
gpu_type_id: GPU type ID
docker_start_cmd: Docker CMD override (list of strings)
network_volume_id: Network volume ID (optional)
container_registry_auth_id: Docker credential ID (optional)
env: Environment variables (optional)
cloud_type: "SECURE" or "COMMUNITY" (default: SECURE)
gpu_count: Number of GPUs (default: 1)
container_disk_in_gb: Container disk size (default: 50GB)
volume_mount_path: Volume mount path (default: /workspace)
ports: Exposed ports (optional)
Returns:
Pod creation response with pod ID
"""
payload = {
"name": name,
"imageName": image_name,
"gpuTypeIds": [gpu_type_id],
"cloudType": cloud_type,
"gpuCount": gpu_count,
"containerDiskInGb": container_disk_in_gb,
"volumeMountPath": volume_mount_path,
"dockerStartCmd": docker_start_cmd,
}
# Add optional parameters
if network_volume_id:
payload["networkVolumeId"] = network_volume_id
if container_registry_auth_id:
payload["containerRegistryAuthId"] = container_registry_auth_id
if env:
payload["env"] = env
if ports:
payload["ports"] = ports
return self._rest_request("POST", "pods", payload)
def get_pod_status(self, pod_id: str) -> Dict:
"""Get pod status.
Args:
pod_id: Pod ID
Returns:
Pod status dictionary
"""
query = """
query GetPod($podId: String!) {
pod(input: { podId: $podId }) {
id
name
desiredStatus
imageName
costPerHr
gpuCount
machine {
podHostId
}
runtime {
uptimeInSeconds
ports {
ip
isIpPublic
privatePort
publicPort
type
}
}
}
}
"""
data = self._graphql_request(query, {"podId": pod_id})
return data.get("pod", {})
def terminate_pod(self, pod_id: str) -> Dict:
"""Terminate a pod.
Args:
pod_id: Pod ID
Returns:
Termination response
"""
return self._rest_request("DELETE", f"pods/{pod_id}", None)
def list_pods(self) -> List[Dict]:
"""List all user pods.
Returns:
List of pod dictionaries
"""
query = """
query {
myself {
pods {
id
name
desiredStatus
imageName
costPerHr
gpuCount
}
}
}
"""
data = self._graphql_request(query)
return data.get("myself", {}).get("pods", [])
def format_docker_cmd(
binary: str,
parquet_file: str,
epochs: int,
use_int8: bool = False,
use_qat: bool = False,
additional_args: Optional[List[str]] = None
) -> List[str]:
"""Format Docker CMD for training.
Args:
binary: Binary name (e.g., "train_tft_parquet")
parquet_file: Path to parquet file
epochs: Number of epochs
use_int8: Enable INT8 quantization
use_qat: Enable QAT (requires INT8)
additional_args: Additional arguments
Returns:
List of command arguments
"""
cmd = [
"--parquet-file", parquet_file,
"--epochs", str(epochs)
]
if use_int8:
cmd.append("--use-int8")
if use_qat:
cmd.append("--use-qat")
if additional_args:
cmd.extend(additional_args)
return cmd
def wait_for_pod_ready(api: RunpodAPI, pod_id: str, timeout: int = 300) -> bool:
"""Wait for pod to be ready.
Args:
api: Runpod API client
pod_id: Pod ID
timeout: Timeout in seconds (default: 300)
Returns:
True if pod is ready, False if timeout
"""
start_time = time.time()
while time.time() - start_time < timeout:
try:
status = api.get_pod_status(pod_id)
desired_status = status.get("desiredStatus", "")
if desired_status == "RUNNING":
print(f"✅ Pod {pod_id} is RUNNING")
return True
print(f"⏳ Pod status: {desired_status} (waiting...)")
time.sleep(10)
except Exception as e:
print(f"Warning: Failed to get pod status: {e}")
time.sleep(10)
print(f"❌ Timeout waiting for pod to be ready")
return False
def print_pod_info(pod_data: Dict):
"""Print pod information.
Args:
pod_data: Pod data dictionary
"""
print("\n" + "="*80)
print("POD CREATED SUCCESSFULLY")
print("="*80)
pod_id = pod_data.get("id") or pod_data.get("podId")
print(f"\n📦 Pod ID: {pod_id}")
print(f"📛 Name: {pod_data.get('name', 'N/A')}")
print(f"🖼️ Image: {pod_data.get('imageName', 'N/A')}")
print(f"💰 Cost: ${pod_data.get('costPerHr', 0):.4f}/hr")
# Print SSH/connection info if available
runtime = pod_data.get("runtime", {})
if runtime:
ports = runtime.get("ports", [])
for port in ports:
if port.get("type") == "tcp" and port.get("privatePort") == 22:
ip = port.get("ip")
public_port = port.get("publicPort")
print(f"\n🔌 SSH Connection:")
print(f" ssh root@{ip} -p {public_port}")
print(f"\n⚠️ IMPORTANT: Terminate pod when done to avoid charges!")
print(f" Terminate: curl -X DELETE https://api.runpod.io/v2/pods/{pod_id} \\")
print(f" -H 'Authorization: Bearer $RUNPOD_API_KEY'")
print("\n" + "="*80 + "\n")
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Deploy Foxhunt ML training to Runpod using GraphQL API",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Smoke test (1 epoch)
%(prog)s --smoke-test
# Full training (50 epochs)
%(prog)s --full-training
# Custom training
%(prog)s --epochs 10 --gpu-type "NVIDIA RTX A4000"
# List available GPUs
%(prog)s --list-gpus
# Get Docker credential ID
%(prog)s --get-credentials
"""
)
# Deployment options
parser.add_argument("--smoke-test", action="store_true",
help="Run smoke test (1 epoch, small dataset)")
parser.add_argument("--full-training", action="store_true",
help="Run full training (50 epochs, 180 days)")
# Training parameters
parser.add_argument("--epochs", type=int, default=10,
help="Number of training epochs (default: 10)")
parser.add_argument("--binary", default="train_tft_parquet",
help="Training binary name (default: train_tft_parquet)")
parser.add_argument("--parquet-file",
default="/runpod-volume/test_data/ES_FUT_180d.parquet",
help="Path to parquet file on network volume")
parser.add_argument("--use-int8", action="store_true",
help="Enable INT8 post-training quantization")
parser.add_argument("--use-qat", action="store_true",
help="Enable QAT quantization (experimental)")
# Pod configuration
parser.add_argument("--pod-name",
help="Pod name (auto-generated if not specified)")
parser.add_argument("--image", default="jgrusewski/foxhunt:latest",
help="Docker image (default: jgrusewski/foxhunt:latest)")
parser.add_argument("--gpu-type", default="NVIDIA RTX A4000",
help="GPU type ID (default: NVIDIA RTX A4000)")
parser.add_argument("--network-volume-id", default="se3zdnb5o4",
help="Network volume ID (default: se3zdnb5o4)")
parser.add_argument("--cloud-type", default="SECURE",
choices=["SECURE", "COMMUNITY"],
help="Cloud type (default: SECURE)")
parser.add_argument("--container-disk", type=int, default=50,
help="Container disk size in GB (default: 50)")
# Utility options
parser.add_argument("--list-gpus", action="store_true",
help="List available GPU types and exit")
parser.add_argument("--get-credentials", action="store_true",
help="Get Docker credential ID and exit")
parser.add_argument("--list-pods", action="store_true",
help="List all active pods and exit")
parser.add_argument("--terminate", metavar="POD_ID",
help="Terminate a specific pod and exit")
args = parser.parse_args()
# Get API key
api_key = os.getenv("RUNPOD_API_KEY")
if not api_key:
print("❌ Error: RUNPOD_API_KEY environment variable is not set")
print("\nTo set your API key:")
print(" export RUNPOD_API_KEY='your-api-key-here'")
print("\nGet your API key from: https://www.runpod.io/console/user/settings")
sys.exit(1)
# Initialize API client
try:
api = RunpodAPI(api_key)
except Exception as e:
print(f"❌ Error initializing Runpod API: {e}")
sys.exit(1)
# Handle utility commands
if args.list_gpus:
print("\n📊 Available GPU Types:\n")
gpus = api.list_available_gpus()
for gpu in gpus:
name = gpu.get("displayName", "Unknown")
gpu_id = gpu.get("id", "Unknown")
memory = gpu.get("memoryInGb", 0)
lowest_price = gpu.get("lowestPrice", {})
spot_price = lowest_price.get("minimumBidPrice", 0)
on_demand = lowest_price.get("uninterruptablePrice", 0)
print(f" {name} ({gpu_id})")
print(f" Memory: {memory}GB")
print(f" Spot: ${spot_price:.4f}/hr | On-Demand: ${on_demand:.4f}/hr")
print()
sys.exit(0)
if args.get_credentials:
print("\n🔑 Fetching Docker credentials...\n")
cred_id = api.get_docker_credential_id("Docker")
if cred_id:
print(f"✅ Docker credential ID: {cred_id}")
else:
print("❌ No credential named 'Docker' found")
print("\nCreate credentials at: https://www.runpod.io/console/user/settings")
sys.exit(0)
if args.list_pods:
print("\n📦 Active Pods:\n")
pods = api.list_pods()
if not pods:
print(" No active pods")
else:
for pod in pods:
print(f" {pod.get('id')} - {pod.get('name')}")
print(f" Status: {pod.get('desiredStatus')}")
print(f" Image: {pod.get('imageName')}")
print(f" Cost: ${pod.get('costPerHr', 0):.4f}/hr")
print()
sys.exit(0)
if args.terminate:
print(f"\n🛑 Terminating pod {args.terminate}...\n")
try:
api.terminate_pod(args.terminate)
print(f"✅ Pod {args.terminate} terminated successfully")
except Exception as e:
print(f"❌ Error terminating pod: {e}")
sys.exit(1)
sys.exit(0)
# Handle deployment presets
if args.smoke_test:
args.epochs = 1
args.parquet_file = "/runpod-volume/test_data/ES_FUT_small.parquet"
print("\n🧪 SMOKE TEST MODE: 1 epoch, small dataset\n")
if args.full_training:
args.epochs = 50
args.parquet_file = "/runpod-volume/test_data/ES_FUT_180d.parquet"
print("\n🚀 FULL TRAINING MODE: 50 epochs, 180 days\n")
# Generate pod name if not specified
if not args.pod_name:
timestamp = int(time.time())
args.pod_name = f"foxhunt-{args.binary}-{args.epochs}ep-{timestamp}"
# Get Docker credential ID
print("🔑 Fetching Docker credentials...")
container_registry_auth_id = api.get_docker_credential_id("Docker")
if container_registry_auth_id:
print(f"✅ Using Docker credential: {container_registry_auth_id}")
else:
print("⚠️ Warning: No Docker credential found (using public images only)")
# Format Docker CMD
docker_cmd = format_docker_cmd(
args.binary,
args.parquet_file,
args.epochs,
args.use_int8,
args.use_qat
)
# Environment variables
env = {
"BINARY_NAME": args.binary,
"RUST_LOG": "info",
"CUDA_VISIBLE_DEVICES": "0"
}
# Print deployment summary
print("\n" + "="*80)
print("DEPLOYMENT SUMMARY")
print("="*80)
print(f"Pod Name: {args.pod_name}")
print(f"Image: {args.image}")
print(f"GPU Type: {args.gpu_type}")
print(f"Cloud Type: {args.cloud_type}")
print(f"Network Volume: {args.network_volume_id}")
print(f"Binary: {args.binary}")
print(f"Parquet File: {args.parquet_file}")
print(f"Epochs: {args.epochs}")
print(f"INT8: {args.use_int8}")
print(f"QAT: {args.use_qat}")
print(f"Docker CMD: {' '.join(docker_cmd)}")
print("="*80 + "\n")
# Confirm deployment
confirm = input("Proceed with deployment? (yes/no): ")
if confirm.lower() != "yes":
print("❌ Deployment cancelled")
sys.exit(0)
# Create pod
print("\n🚀 Creating pod...")
try:
response = api.create_pod_rest(
name=args.pod_name,
image_name=args.image,
gpu_type_id=args.gpu_type,
docker_start_cmd=docker_cmd,
network_volume_id=args.network_volume_id,
container_registry_auth_id=container_registry_auth_id,
env=env,
cloud_type=args.cloud_type,
container_disk_in_gb=args.container_disk,
volume_mount_path="/runpod-volume",
ports=["8888/http", "22/tcp"]
)
pod_id = response.get("id") or response.get("podId")
if not pod_id:
raise RuntimeError(f"No pod ID in response: {response}")
print(f"✅ Pod created: {pod_id}")
# Wait for pod to be ready
print("\n⏳ Waiting for pod to be ready...")
if wait_for_pod_ready(api, pod_id, timeout=300):
# Get full pod info
pod_data = api.get_pod_status(pod_id)
print_pod_info(pod_data)
print("\n📝 Next Steps:")
print(" 1. Monitor pod logs in Runpod console")
print(" 2. Training will start automatically")
print(" 3. Checkpoints saved to /runpod-volume/models/")
print(" 4. Terminate pod when done to avoid charges")
print(f"\n Terminate command:")
print(f" ./scripts/deploy_runpod_graphql.py --terminate {pod_id}")
else:
print("⚠️ Pod creation timed out, but pod may still be starting")
print(f" Check status: ./scripts/deploy_runpod_graphql.py --list-pods")
print(f" Terminate if needed: ./scripts/deploy_runpod_graphql.py --terminate {pod_id}")
except Exception as e:
print(f"❌ Error creating pod: {e}")
sys.exit(1)
if __name__ == "__main__":
main()