blob: 6a74d5d33432ae2946589321b5ddd2755d9a46b6 [file]
#!/usr/bin/env python3
"""
Airavata Load Client — unified CLI for experiment launching and load testing.
Modes:
single — create and launch one experiment
batch — launch N copies of a scenario from config
load — run all scenarios from YAML config (concurrent users, metrics)
"""
import argparse
import csv
import logging
import os
import sys
import time
from collections import deque
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Union
import yaml
from pydantic import BaseModel
from rich.console import Console
from rich.layout import Layout
from rich.live import Live
from rich.panel import Panel
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
# Default to dev environment. Override with env vars for local testing.
os.environ.setdefault('AUTH_SERVER_URL', "https://auth.dev.cybershuttle.org")
os.environ.setdefault('API_SERVER_HOSTNAME', "api.dev.cybershuttle.org")
os.environ.setdefault('GATEWAY_URL', "https://gateway.dev.cybershuttle.org")
os.environ.setdefault('STORAGE_RESOURCE_HOST', "gateway.dev.cybershuttle.org")
from airavata.auth.device_auth import AuthContext
from airavata.experiments.airavata import AiravataOperator
from airavata.model.status.ttypes import ExperimentState
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
)
logger = logging.getLogger(__name__)
console = Console()
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class ScenarioConfig(BaseModel):
name: str
experiment_name: str
project_name: str
application_name: str
computation_resource_name: str
queue_name: str
node_count: int
cpu_count: int
walltime: int
group_name: str = "Default"
input_storage_host: Optional[str] = None
output_storage_host: Optional[str] = None
input_files: Dict[str, Union[str, List[str]]] = {}
data_inputs: Dict[str, Union[str, int, float]] = {}
gateway_id: Optional[str] = None
auto_schedule: bool = False
# load-mode fields
concurrent_users: int = 1
iterations_per_user: int = 1
delay_between_submissions_ms: int = 0
class LoadConfig(BaseModel):
scenarios: List[ScenarioConfig]
# ---------------------------------------------------------------------------
# Job result dataclass
# ---------------------------------------------------------------------------
@dataclass
class JobResult:
job_index: int
scenario_name: str
experiment_id: Optional[str]
status: str
success: bool
submit_time: float = 0.0
finish_time: float = 0.0
error: Optional[str] = None
@property
def elapsed_seconds(self) -> float:
if self.finish_time and self.submit_time:
return self.finish_time - self.submit_time
return 0.0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_state_value(status) -> tuple:
"""Return (int_value, name_str, ExperimentState) from any status object."""
if isinstance(status, ExperimentState):
return status.value, status.name, status
if hasattr(status, 'state'):
state = status.state
if isinstance(state, ExperimentState):
return state.value, state.name, state
if hasattr(state, 'value'):
try:
enum = ExperimentState(state.value)
return state.value, state.name if hasattr(state, 'name') else str(state), enum
except (ValueError, TypeError):
pass
val = status.value if hasattr(status, 'value') else None
name = status.name if hasattr(status, 'name') else str(status)
if val is not None:
try:
return val, name, ExperimentState(val)
except (ValueError, TypeError):
pass
return None, name, ExperimentState.FAILED
_TERMINAL = frozenset([
ExperimentState.COMPLETED.value,
ExperimentState.CANCELED.value,
ExperimentState.FAILED.value,
])
def prepare_inputs(
input_files: Dict[str, Union[str, List[str]]],
data_inputs: Dict[str, Union[str, int, float]],
) -> dict:
"""Build the experiment_inputs dict expected by AiravataOperator.launch_experiment."""
inputs: dict = {}
for name, paths in (input_files or {}).items():
if isinstance(paths, list):
inputs[name] = {"type": "uri[]", "value": [str(Path(p).resolve()) for p in paths]}
else:
inputs[name] = {"type": "uri", "value": str(Path(paths).resolve())}
for name, value in (data_inputs or {}).items():
if isinstance(value, int):
inputs[name] = {"type": "int", "value": value}
elif isinstance(value, float):
inputs[name] = {"type": "float", "value": value}
else:
inputs[name] = {"type": "string", "value": str(value)}
if not inputs:
inputs = {"__no_inputs__": {"type": "string", "value": ""}}
return inputs
def monitor_experiment(operator: AiravataOperator, experiment_id: str, check_interval: int = 30) -> ExperimentState:
"""Poll until the experiment reaches a terminal state. Returns final ExperimentState."""
max_checks = 3600
count = 0
initial_interval = min(check_interval, 5)
while count < max_checks:
try:
status = operator.get_experiment_status(experiment_id)
val, name, enum = _get_state_value(status)
is_terminal = (val in _TERMINAL) if val is not None else (name in {'COMPLETED', 'CANCELED', 'FAILED'})
if is_terminal:
return enum
except Exception as exc:
logger.warning("Status check error for %s (attempt %d): %s", experiment_id, count, exc)
if count > 10:
logger.error("Too many failures monitoring %s; assuming FAILED", experiment_id)
return ExperimentState.FAILED
sleep = initial_interval if count < 6 else check_interval
time.sleep(sleep)
count += 1
logger.error("Monitoring timeout for %s; assuming FAILED", experiment_id)
return ExperimentState.FAILED
# ---------------------------------------------------------------------------
# Core launch function
# ---------------------------------------------------------------------------
def launch_experiment(
access_token: str,
experiment_name: str,
project_name: str,
application_name: str,
computation_resource_name: str,
queue_name: str,
node_count: int,
cpu_count: int,
walltime: int,
group_name: str = "Default",
input_storage_host: Optional[str] = None,
output_storage_host: Optional[str] = None,
input_files: Optional[Dict] = None,
data_inputs: Optional[Dict] = None,
gateway_id: Optional[str] = None,
auto_schedule: bool = False,
do_monitor: bool = True,
) -> dict:
"""Create and launch one experiment; return a result dict."""
operator = AiravataOperator(access_token=access_token)
inputs = prepare_inputs(input_files or {}, data_inputs or {})
launch_state = operator.launch_experiment(
experiment_name=experiment_name,
project=project_name,
app_name=application_name,
inputs=inputs,
computation_resource_name=computation_resource_name,
queue_name=queue_name,
node_count=node_count,
cpu_count=cpu_count,
walltime=walltime,
group=group_name,
gateway_id=gateway_id,
input_sr_host=input_storage_host,
output_sr_host=output_storage_host,
auto_schedule=auto_schedule,
)
result = {
"experiment_id": launch_state.experiment_id,
"process_id": launch_state.process_id,
"experiment_dir": launch_state.experiment_dir,
"storage_host": launch_state.sr_host,
"mount_point": str(launch_state.mount_point),
}
if do_monitor:
monitor_experiment(operator, launch_state.experiment_id)
return result
# ---------------------------------------------------------------------------
# Worker used by batch / load modes
# ---------------------------------------------------------------------------
def _run_job(
job_index: int,
scenario: ScenarioConfig,
access_token: str,
name_suffix: str = "",
) -> JobResult:
submit_time = time.time()
exp_name = f"{scenario.experiment_name}{name_suffix}"
try:
result = launch_experiment(
access_token=access_token,
experiment_name=exp_name,
project_name=scenario.project_name,
application_name=scenario.application_name,
computation_resource_name=scenario.computation_resource_name,
queue_name=scenario.queue_name,
node_count=scenario.node_count,
cpu_count=scenario.cpu_count,
walltime=scenario.walltime,
group_name=scenario.group_name,
input_storage_host=scenario.input_storage_host,
output_storage_host=scenario.output_storage_host,
input_files=scenario.input_files or None,
data_inputs=scenario.data_inputs or None,
gateway_id=scenario.gateway_id,
auto_schedule=scenario.auto_schedule,
do_monitor=True,
)
# Determine final state
operator = AiravataOperator(access_token=access_token)
status = operator.get_experiment_status(result["experiment_id"])
val, name, _ = _get_state_value(status)
success = val == ExperimentState.COMPLETED.value
return JobResult(
job_index=job_index,
scenario_name=scenario.name,
experiment_id=result["experiment_id"],
status=name,
success=success,
submit_time=submit_time,
finish_time=time.time(),
)
except Exception as exc:
import traceback
return JobResult(
job_index=job_index,
scenario_name=scenario.name,
experiment_id=None,
status="ERROR",
success=False,
submit_time=submit_time,
finish_time=time.time(),
error=f"{exc}\n{traceback.format_exc()}",
)
def _run_with_progress(
jobs: List[tuple], # list of (job_index, scenario, suffix)
access_token: str,
max_workers: int,
description: str,
) -> List[JobResult]:
"""Submit jobs with a Rich progress bar. Returns results sorted by job_index."""
results: List[JobResult] = []
log_buf: deque = deque(maxlen=50)
class _BufHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
log_buf.append(self.format(record))
handler = _BufHandler()
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(message)s'))
logging.root.addHandler(handler)
progress = Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
TextColumn("•"),
TextColumn("{task.completed}/{task.total}"),
TimeElapsedColumn(),
console=console,
)
task_id = progress.add_task(description, total=len(jobs))
layout = Layout()
layout.split_column(Layout(name="logs"), Layout(progress, name="progress", size=3))
def _refresh():
lines = list(log_buf)[-20:] or ["No logs yet..."]
layout["logs"].update(Panel("\n".join(lines), title="Logs (latest)", border_style="blue"))
return layout
try:
with Live(_refresh(), console=console, refresh_per_second=4, screen=True) as live:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
pending: dict = {}
queue = list(jobs)
# seed initial batch
while queue and len(pending) < max_workers:
idx, scen, suffix = queue.pop(0)
f = executor.submit(_run_job, idx, scen, access_token, suffix)
pending[f] = idx
while pending or queue:
done = [f for f in pending if f.done()]
for f in done:
pending.pop(f)
try:
results.append(f.result())
except Exception as exc:
results.append(JobResult(
job_index=-1, scenario_name="?",
experiment_id=None, status="ERROR",
success=False, error=str(exc),
))
progress.update(task_id, advance=1)
while queue and len(pending) < max_workers:
idx, scen, suffix = queue.pop(0)
f = executor.submit(_run_job, idx, scen, access_token, suffix)
pending[f] = idx
live.update(_refresh())
if not done and pending:
time.sleep(1)
finally:
logging.root.removeHandler(handler)
results.sort(key=lambda r: r.job_index)
return results
def _write_csv(results: List[JobResult], path: str) -> None:
with open(path, "w", newline="") as fh:
w = csv.writer(fh)
w.writerow(["job_index", "scenario_name", "experiment_id", "status", "success", "elapsed_seconds", "error"])
for r in results:
w.writerow([r.job_index, r.scenario_name, r.experiment_id, r.status,
r.success, f"{r.elapsed_seconds:.2f}", r.error or ""])
console.print(f"[green]Metrics written to {path}[/green]")
def _print_summary(results: List[JobResult], label: str) -> None:
total = len(results)
ok = sum(1 for r in results if r.success)
console.print(f"\n{'='*60}")
console.print(f"{label}: {total} jobs")
console.print(f" Successful : {ok}/{total}")
console.print(f" Failed : {total - ok}/{total}")
for r in results:
sym = "[green]✓[/green]" if r.success else "[red]✗[/red]"
console.print(f" {sym} [{r.job_index}] {r.scenario_name} — {r.status}"
+ (f" ({r.experiment_id})" if r.experiment_id else ""))
console.print('='*60)
# ---------------------------------------------------------------------------
# CLI sub-commands
# ---------------------------------------------------------------------------
def cmd_single(args: argparse.Namespace) -> None:
access_token = AuthContext.get_access_token()
result = launch_experiment(
access_token=access_token,
experiment_name=args.experiment_name,
project_name=args.project,
application_name=args.application,
computation_resource_name=args.resource,
queue_name=args.queue,
node_count=args.node_count,
cpu_count=args.cpu_count,
walltime=args.walltime,
group_name=args.group,
input_storage_host=args.input_storage_host,
output_storage_host=args.output_storage_host,
do_monitor=not args.no_monitor,
)
console.print("\n[bold green]Experiment launched successfully![/bold green]")
for k, v in result.items():
console.print(f" {k}: {v}")
def cmd_batch(args: argparse.Namespace) -> None:
with open(args.config) as fh:
raw = yaml.safe_load(fh)
cfg = LoadConfig(**raw)
scenario_map = {s.name: s for s in cfg.scenarios}
if args.scenario not in scenario_map:
console.print(f"[red]Scenario '{args.scenario}' not found in config.[/red]")
sys.exit(1)
scenario = scenario_map[args.scenario]
access_token = AuthContext.get_access_token()
n = args.copies
jobs = [(i, scenario, f"-job{i}") for i in range(n)]
results = _run_with_progress(jobs, access_token, max_workers=args.max_concurrent,
description=f"Batch: {scenario.name}")
_print_summary(results, f"Batch '{scenario.name}'")
if args.csv:
_write_csv(results, args.csv)
def cmd_load(args: argparse.Namespace) -> None:
with open(args.config) as fh:
raw = yaml.safe_load(fh)
cfg = LoadConfig(**raw)
access_token = AuthContext.get_access_token()
all_jobs: List[tuple] = []
job_index = 0
for scenario in cfg.scenarios:
total = scenario.concurrent_users * scenario.iterations_per_user
for i in range(total):
all_jobs.append((job_index, scenario, f"-u{i // scenario.iterations_per_user}-iter{i % scenario.iterations_per_user}"))
job_index += 1
if scenario.delay_between_submissions_ms > 0 and i < total - 1:
time.sleep(scenario.delay_between_submissions_ms / 1000.0)
max_workers = max(s.concurrent_users for s in cfg.scenarios)
results = _run_with_progress(all_jobs, access_token, max_workers=max_workers,
description="Load test")
_print_summary(results, "Load test")
if args.csv:
_write_csv(results, args.csv)
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="load_client.py",
description="Airavata experiment launcher and load tester",
)
sub = parser.add_subparsers(dest="command", required=True)
# single
p_single = sub.add_parser("single", help="Create and launch one experiment")
p_single.add_argument("--experiment-name", required=True)
p_single.add_argument("--project", required=True)
p_single.add_argument("--application", required=True)
p_single.add_argument("--resource", required=True)
p_single.add_argument("--queue", required=True)
p_single.add_argument("--node-count", type=int, default=1)
p_single.add_argument("--cpu-count", type=int, default=1)
p_single.add_argument("--walltime", type=int, default=30)
p_single.add_argument("--group", default="Default")
p_single.add_argument("--input-storage-host")
p_single.add_argument("--output-storage-host")
p_single.add_argument("--no-monitor", action="store_true",
help="Submit without waiting for completion")
p_single.set_defaults(func=cmd_single)
# batch
p_batch = sub.add_parser("batch", help="Launch N copies of a scenario")
p_batch.add_argument("--config", required=True, help="Path to load-config.yml")
p_batch.add_argument("--scenario", required=True, help="Scenario name in config")
p_batch.add_argument("--copies", type=int, default=10)
p_batch.add_argument("--max-concurrent", type=int, default=5)
p_batch.add_argument("--csv", metavar="FILE", help="Write metrics CSV to FILE")
p_batch.set_defaults(func=cmd_batch)
# load
p_load = sub.add_parser("load", help="Run all scenarios from config")
p_load.add_argument("--config", required=True, help="Path to load-config.yml")
p_load.add_argument("--csv", metavar="FILE", help="Write metrics CSV to FILE")
p_load.set_defaults(func=cmd_load)
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()