413 lines
13 KiB
Python
Executable File
413 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Quick Binary Upload Workflow Script
|
|
|
|
Uploads release binaries to RunPod S3 for fast development iteration.
|
|
Automatically finds binaries, validates executability, tracks upload progress,
|
|
and generates timestamped filenames for versioning.
|
|
|
|
Usage:
|
|
python3 scripts/upload_binary.py --binary-name hyperopt_mamba2_demo
|
|
python3 scripts/upload_binary.py --binary-path ./target/release/examples/custom_binary --force
|
|
python3 scripts/upload_binary.py --binary-name hyperopt_tft_demo --no-timestamp
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import stat
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
# Check for .venv activation
|
|
if not hasattr(sys, 'real_prefix') and not (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix):
|
|
print("WARNING: Not running in a virtual environment (.venv)")
|
|
print(" Recommended: source .venv/bin/activate")
|
|
print()
|
|
|
|
# Get project root for later use
|
|
script_dir = Path(__file__).parent
|
|
project_root = script_dir.parent
|
|
|
|
try:
|
|
# Import from runpod module (clean architecture - no sys.path hacks)
|
|
from runpod import S3Client
|
|
from runpod.config import get_config
|
|
from runpod.errors import S3Error, ConfigurationError
|
|
from rich.console import Console
|
|
from rich.progress import (
|
|
Progress,
|
|
BarColumn,
|
|
DownloadColumn,
|
|
TransferSpeedColumn,
|
|
TimeRemainingColumn,
|
|
TextColumn,
|
|
)
|
|
|
|
HAS_DEPENDENCIES = True
|
|
except ImportError as e:
|
|
print(f"ERROR: Failed to import required modules: {e}")
|
|
print("\nRequired dependencies:")
|
|
print(" - runpod module")
|
|
print(" - rich (pip install rich)")
|
|
print("\nInstall with: pip install -r runpod/requirements.txt")
|
|
sys.exit(1)
|
|
|
|
console = Console()
|
|
|
|
# Default binary search path
|
|
DEFAULT_BINARY_DIR = project_root / "target" / "release" / "examples"
|
|
|
|
|
|
def find_binary(binary_name: str) -> Path | None:
|
|
"""
|
|
Find binary in target/release/examples/ directory.
|
|
|
|
Args:
|
|
binary_name: Name of binary (without path)
|
|
|
|
Returns:
|
|
Path to binary or None if not found
|
|
"""
|
|
# Try exact match first
|
|
binary_path = DEFAULT_BINARY_DIR / binary_name
|
|
if binary_path.exists():
|
|
return binary_path
|
|
|
|
# Try with common suffixes
|
|
for suffix in ["", "-*"]: # Try exact, then with build hash
|
|
pattern = f"{binary_name}{suffix}"
|
|
matches = list(DEFAULT_BINARY_DIR.glob(pattern))
|
|
|
|
# Filter out .d dependency files
|
|
matches = [m for m in matches if not m.name.endswith(".d")]
|
|
|
|
if matches:
|
|
# Return most recent if multiple matches
|
|
return max(matches, key=lambda p: p.stat().st_mtime)
|
|
|
|
return None
|
|
|
|
|
|
def validate_binary(binary_path: Path) -> tuple[bool, str]:
|
|
"""
|
|
Validate binary exists and is executable.
|
|
|
|
Args:
|
|
binary_path: Path to binary
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
if not binary_path.exists():
|
|
return False, f"Binary not found: {binary_path}"
|
|
|
|
if not binary_path.is_file():
|
|
return False, f"Not a file: {binary_path}"
|
|
|
|
# Check if executable (Unix permissions)
|
|
file_stat = binary_path.stat()
|
|
if not (file_stat.st_mode & stat.S_IXUSR):
|
|
return False, f"Binary is not executable: {binary_path}"
|
|
|
|
# Check file size (warn if suspiciously small)
|
|
file_size = file_stat.st_size
|
|
if file_size < 1024 * 100: # Less than 100KB
|
|
return False, f"Binary suspiciously small ({file_size} bytes): {binary_path}"
|
|
|
|
return True, ""
|
|
|
|
|
|
def generate_s3_key(binary_path: Path, use_timestamp: bool = True, cuda_variant: bool = True) -> str:
|
|
"""
|
|
Generate S3 key with optional timestamp.
|
|
|
|
Args:
|
|
binary_path: Path to binary
|
|
use_timestamp: Add timestamp to filename
|
|
cuda_variant: Add _cuda suffix
|
|
|
|
Returns:
|
|
S3 key (e.g., "binaries/hyperopt_mamba2_demo_cuda_20251030_120000")
|
|
"""
|
|
binary_name = binary_path.stem # Remove -hash suffix if present
|
|
|
|
# Clean up name (remove build hash like -875831286f8f5985)
|
|
if "-" in binary_name:
|
|
# Only remove if it looks like a hash (32 hex chars after dash)
|
|
parts = binary_name.rsplit("-", 1)
|
|
if len(parts) == 2 and len(parts[1]) == 16 and all(c in "0123456789abcdef" for c in parts[1]):
|
|
binary_name = parts[0]
|
|
|
|
# Build S3 key components
|
|
components = [binary_name]
|
|
|
|
if cuda_variant:
|
|
components.append("cuda")
|
|
|
|
if use_timestamp:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
components.append(timestamp)
|
|
|
|
filename = "_".join(components)
|
|
|
|
return f"binaries/{filename}"
|
|
|
|
|
|
def format_size(bytes_size: int) -> str:
|
|
"""Format byte size to human-readable string."""
|
|
for unit in ['B', 'KB', 'MB', 'GB']:
|
|
if bytes_size < 1024.0:
|
|
return f"{bytes_size:.1f} {unit}"
|
|
bytes_size /= 1024.0
|
|
return f"{bytes_size:.1f} TB"
|
|
|
|
|
|
def upload_binary_with_progress(
|
|
s3_client: S3Client,
|
|
binary_path: Path,
|
|
s3_key: str,
|
|
force: bool = False
|
|
) -> bool:
|
|
"""
|
|
Upload binary with rich progress bar.
|
|
|
|
Args:
|
|
s3_client: S3Client instance
|
|
binary_path: Path to binary
|
|
s3_key: Destination S3 key
|
|
force: Skip checksum check and force upload
|
|
|
|
Returns:
|
|
True if upload succeeded
|
|
|
|
Raises:
|
|
S3Error: On upload failure
|
|
"""
|
|
file_size = binary_path.stat().st_size
|
|
|
|
console.print(f"\n[cyan]Upload Plan:[/cyan]")
|
|
console.print(f" Source: {binary_path}")
|
|
console.print(f" Destination: s3://{s3_client.bucket}/{s3_key}")
|
|
console.print(f" Size: {format_size(file_size)} ({file_size:,} bytes)")
|
|
console.print(f" Force: {force}")
|
|
console.print()
|
|
|
|
# Check if upload needed (unless forced)
|
|
if not force:
|
|
needs_upload, reason = s3_client.needs_upload(binary_path, s3_key)
|
|
if not needs_upload:
|
|
console.print(f"[green]✓ Binary up-to-date[/green]: {reason}")
|
|
console.print(f" S3 URI: s3://{s3_client.bucket}/{s3_key}")
|
|
return True
|
|
console.print(f"[yellow]↻ Upload required[/yellow]: {reason}\n")
|
|
|
|
# Upload with progress bar
|
|
with Progress(
|
|
TextColumn("[bold blue]{task.description}"),
|
|
BarColumn(),
|
|
DownloadColumn(),
|
|
TransferSpeedColumn(),
|
|
TimeRemainingColumn(),
|
|
console=console,
|
|
) as progress:
|
|
task = progress.add_task(f"Uploading {binary_path.name}", total=file_size)
|
|
|
|
def callback(bytes_transferred):
|
|
progress.update(task, completed=bytes_transferred)
|
|
|
|
success = s3_client.upload_binary(
|
|
binary_path,
|
|
s3_key,
|
|
force=force,
|
|
progress_callback=callback
|
|
)
|
|
|
|
if success:
|
|
console.print(f"\n[green]✅ Upload complete![/green]")
|
|
console.print(f" S3 URI: s3://{s3_client.bucket}/{s3_key}")
|
|
console.print(f"\n[cyan]Next steps:[/cyan]")
|
|
console.print(f" 1. Binary available at: /runpod-volume/{s3_key}")
|
|
console.print(f" 2. Use in deployment: --command '/runpod-volume/{s3_key} --epochs 50'")
|
|
console.print(f" 3. Verify: aws s3 ls s3://{s3_client.bucket}/{s3_key} --profile runpod")
|
|
|
|
return success
|
|
|
|
|
|
def main():
|
|
"""Main execution function."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Upload Rust binaries to RunPod S3 for fast development iteration",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# Upload by name (auto-finds in target/release/examples/)
|
|
python3 scripts/upload_binary.py --binary-name hyperopt_mamba2_demo
|
|
|
|
# Upload custom binary path
|
|
python3 scripts/upload_binary.py --binary-path ./custom_binary --force
|
|
|
|
# Upload without timestamp (overwrites existing)
|
|
python3 scripts/upload_binary.py --binary-name hyperopt_tft_demo --no-timestamp --force
|
|
|
|
# Upload without CUDA suffix
|
|
python3 scripts/upload_binary.py --binary-name hyperopt_dqn_demo --no-cuda
|
|
|
|
# Dry run (validate only, don't upload)
|
|
python3 scripts/upload_binary.py --binary-name hyperopt_ppo_demo --dry-run
|
|
|
|
Features:
|
|
- Auto-finds binaries in target/release/examples/
|
|
- Validates binary exists and is executable
|
|
- Checks file size (warns if < 100KB)
|
|
- Shows progress bar with transfer speed
|
|
- MD5 checksum validation (skips upload if unchanged)
|
|
- Generates timestamped names for versioning
|
|
- Returns S3 path for deployment
|
|
|
|
S3 Organization:
|
|
s3://se3zdnb5o4/binaries/
|
|
├── hyperopt_mamba2_demo_cuda_20251030_120000
|
|
├── hyperopt_tft_demo_cuda_20251030_143000
|
|
└── hyperopt_dqn_demo_cuda_20251030_150000
|
|
|
|
Requirements:
|
|
- .venv activated: source .venv/bin/activate
|
|
- .env.runpod configured with S3 credentials
|
|
- foxhunt_runpod module installed
|
|
"""
|
|
)
|
|
|
|
# Binary selection (mutually exclusive)
|
|
binary_group = parser.add_mutually_exclusive_group(required=True)
|
|
binary_group.add_argument(
|
|
'--binary-name',
|
|
help='Binary name to find in target/release/examples/ (e.g., hyperopt_mamba2_demo)'
|
|
)
|
|
binary_group.add_argument(
|
|
'--binary-path',
|
|
type=Path,
|
|
help='Direct path to binary file'
|
|
)
|
|
|
|
# Upload options
|
|
parser.add_argument(
|
|
'--force',
|
|
action='store_true',
|
|
help='Force upload even if checksum matches (overwrite existing)'
|
|
)
|
|
parser.add_argument(
|
|
'--no-timestamp',
|
|
action='store_true',
|
|
help='Do not add timestamp to filename (overwrites existing binary)'
|
|
)
|
|
parser.add_argument(
|
|
'--no-cuda',
|
|
action='store_true',
|
|
help='Do not add _cuda suffix to filename'
|
|
)
|
|
parser.add_argument(
|
|
'--dry-run',
|
|
action='store_true',
|
|
help='Validate binary only, do not upload'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Step 1: Find binary
|
|
console.print("\n[bold]🔍 Step 1: Locating binary[/bold]")
|
|
|
|
if args.binary_name:
|
|
console.print(f" Searching for: {args.binary_name}")
|
|
console.print(f" Search path: {DEFAULT_BINARY_DIR}")
|
|
|
|
binary_path = find_binary(args.binary_name)
|
|
if not binary_path:
|
|
console.print(f"\n[red]❌ ERROR: Binary not found: {args.binary_name}[/red]")
|
|
console.print(f"\nSearched in: {DEFAULT_BINARY_DIR}")
|
|
console.print("\nTip: Build binary first with:")
|
|
console.print(f" cargo build --release --example {args.binary_name}")
|
|
sys.exit(1)
|
|
else:
|
|
binary_path = args.binary_path
|
|
|
|
console.print(f" [green]✓ Found: {binary_path}[/green]")
|
|
|
|
# Step 2: Validate binary
|
|
console.print("\n[bold]✅ Step 2: Validating binary[/bold]")
|
|
|
|
is_valid, error_msg = validate_binary(binary_path)
|
|
if not is_valid:
|
|
console.print(f"\n[red]❌ ERROR: {error_msg}[/red]")
|
|
sys.exit(1)
|
|
|
|
file_size = binary_path.stat().st_size
|
|
console.print(f" [green]✓ Valid executable[/green]")
|
|
console.print(f" Size: {format_size(file_size)} ({file_size:,} bytes)")
|
|
|
|
# Step 3: Generate S3 key
|
|
console.print("\n[bold]📝 Step 3: Generating S3 key[/bold]")
|
|
|
|
use_timestamp = not args.no_timestamp
|
|
use_cuda = not args.no_cuda
|
|
s3_key = generate_s3_key(binary_path, use_timestamp=use_timestamp, cuda_variant=use_cuda)
|
|
|
|
console.print(f" [green]✓ S3 key: {s3_key}[/green]")
|
|
|
|
if args.dry_run:
|
|
console.print("\n[yellow]🏃 DRY RUN: Skipping upload[/yellow]")
|
|
console.print(f"\nWould upload to: s3://{{bucket}}/{s3_key}")
|
|
console.print("\nRun without --dry-run to perform actual upload")
|
|
return
|
|
|
|
# Step 4: Initialize S3 client
|
|
console.print("\n[bold]🔧 Step 4: Initializing S3 client[/bold]")
|
|
|
|
try:
|
|
config = get_config()
|
|
console.print(f" [green]✓ Loaded config from: {project_root}/.env.runpod[/green]")
|
|
|
|
s3_client = S3Client(config)
|
|
console.print(f" [green]✓ Connected to bucket: {s3_client.bucket}[/green]")
|
|
console.print(f" Endpoint: {config.runpod_s3_endpoint}")
|
|
console.print(f" Region: {config.runpod_s3_region}")
|
|
|
|
except ConfigurationError as e:
|
|
console.print(f"\n[red]❌ Configuration error: {e}[/red]")
|
|
console.print("\nEnsure .env.runpod exists in project root with:")
|
|
console.print(" RUNPOD_S3_ACCESS_KEY=...")
|
|
console.print(" RUNPOD_S3_SECRET=...")
|
|
console.print(" RUNPOD_VOLUME_ID=...")
|
|
sys.exit(1)
|
|
|
|
# Step 5: Upload binary
|
|
console.print("\n[bold]📤 Step 5: Uploading binary[/bold]")
|
|
|
|
try:
|
|
success = upload_binary_with_progress(
|
|
s3_client,
|
|
binary_path,
|
|
s3_key,
|
|
force=args.force
|
|
)
|
|
|
|
if success:
|
|
console.print("\n[bold green]✅ SUCCESS: Binary uploaded successfully![/bold green]")
|
|
sys.exit(0)
|
|
else:
|
|
console.print("\n[bold red]❌ FAILED: Upload did not complete[/bold red]")
|
|
sys.exit(1)
|
|
|
|
except S3Error as e:
|
|
console.print(f"\n[red]❌ S3 Error: {e}[/red]")
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
console.print(f"\n[red]❌ Unexpected error: {e}[/red]")
|
|
import traceback
|
|
traceback.print_exc()
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|