This is an automated email from the ASF dual-hosted git repository. yasithdev pushed a commit to branch feat/generic-experiment-launcher in repository https://gitbox.apache.org/repos/asf/airavata-portals.git
commit 4a675e2496dd22ab7ba7f229c0d276dd1b2704a4 Author: yasithdev <[email protected]> AuthorDate: Fri Apr 24 20:12:03 2026 -0400 feat(launcher): launcher_client stub + settings flags Adds FEATURE_GENERIC_LAUNCHER and LAUNCHER_CLIENT_STUB settings flags, implements _StubClient returning fixture data and _RealClient raising NotImplementedError, wired through get_client(). 4 TDD tests pass. --- .../django_airavata/apps/api/launcher_client.py | 180 +++++++++++++++++++++ .../apps/api/tests/test_launcher_client.py | 59 +++++++ airavata-django-portal/django_airavata/settings.py | 4 +- 3 files changed, 242 insertions(+), 1 deletion(-) diff --git a/airavata-django-portal/django_airavata/apps/api/launcher_client.py b/airavata-django-portal/django_airavata/apps/api/launcher_client.py new file mode 100644 index 000000000..b25ca5e60 --- /dev/null +++ b/airavata-django-portal/django_airavata/apps/api/launcher_client.py @@ -0,0 +1,180 @@ +"""Abstraction layer over the airavata server's launcher RPCs. + +The real RPCs (new-model applications + dry-run preview) are landing in the +Java server separately. To unblock portal development and keep CI green, +this module ships with a stub implementation that returns plausible +fixtures. The settings flag ``LAUNCHER_CLIENT_STUB`` selects between the +stub and the real client. Tests should mock at the ``get_client`` boundary. +""" + +from __future__ import annotations + +from typing import Any, Protocol + +from django.conf import settings + + +class LauncherClient(Protocol): + def list_applications(self, *, category: str | None, search: str | None) -> list[dict[str, Any]]: ... + def get_application(self, *, app_id: str) -> dict[str, Any]: ... + def get_project_resource_profile(self, *, project_id: str) -> dict[str, Any]: ... + def list_user_storages(self) -> list[dict[str, Any]]: ... + def list_projects(self) -> list[dict[str, Any]]: ... + def generate_preview(self, draft: dict[str, Any]) -> dict[str, Any]: ... + def launch_experiment(self, draft: dict[str, Any]) -> dict[str, Any]: ... + + +_STUB_APPS: list[dict[str, Any]] = [ + { + "app_id": "namd", + "name": "NAMD", + "category": "Molecular Dynamics", + "content": {"kind": "github", "url": "github.com/Illinois/[email protected]"}, + "interfaces": [ + { + "name": "compile", + "inputs": [], + "outputs": [{"name": "binary", "type": "file"}], + }, + { + "name": "run", + "inputs": [ + {"name": "sim_dir", "type": "dir", "required": True}, + {"name": "force_field", "type": "file", "required": True}, + {"name": "steps", "type": "int", "required": True}, + ], + "outputs": [{"name": "trajectory", "type": "file"}], + }, + ], + } +] + + +class _StubClient: + def __init__(self, user_token: str) -> None: + self.user_token = user_token + + def list_applications(self, *, category: str | None, search: str | None) -> list[dict[str, Any]]: + results = list(_STUB_APPS) + if category: + results = [a for a in results if a["category"] == category] + if search: + needle = search.lower() + results = [a for a in results if needle in a["name"].lower()] + return results + + def get_application(self, *, app_id: str) -> dict[str, Any]: + for a in _STUB_APPS: + if a["app_id"] == app_id: + return a + raise LookupError(f"unknown app_id {app_id!r}") + + def get_project_resource_profile(self, *, project_id: str) -> dict[str, Any]: + return { + "project_id": project_id, + "allocation_id": "NSF-CS240042", + "compute_resources": [ + { + "compute_resource_id": "bridges-2", + "name": "Bridges-2", + "mapped_storage": {"storage_id": "bridges2-scratch", "scratch_path": f"/scratch/{project_id}"}, + "partitions": [ + {"name": "RM", "max_walltime": "48:00:00", "max_nodes": 64, "cpus_per_node": 128}, + {"name": "RM-shared", "max_walltime": "48:00:00", "max_nodes": 1, "cpus_per_node": 128}, + {"name": "GPU", "max_walltime": "48:00:00", "max_nodes": 16, "cpus_per_node": 64}, + ], + } + ], + } + + def list_user_storages(self) -> list[dict[str, Any]]: + return [ + {"storage_id": "my-home", "name": "My Home", "is_primary": True}, + {"storage_id": "bridges2-scratch", "name": "Bridges-2 Scratch", "is_primary": False}, + ] + + def list_projects(self) -> list[dict[str, Any]]: + return [ + {"project_id": "proj-stub", "name": "stub-lab-2026"}, + ] + + def generate_preview(self, draft: dict[str, Any]) -> dict[str, Any]: + compute_id = draft["runtime"]["compute_resource_id"] + is_slurm = "bridges" in compute_id or "expanse" in compute_id or "anvil" in compute_id + partition = draft["runtime"]["partition"] + walltime = draft["runtime"]["walltime"] + nodes = draft["runtime"]["nodes"] + cpus = draft["runtime"]["cpus_per_node"] + if is_slurm: + invocation = f"sbatch /tmp/{draft['name']}/run.sh" + script_lines = [ + "#!/bin/bash", + f"#SBATCH --job-name={draft['name']}", + "#SBATCH -A NSF-CS240042", + f"#SBATCH -p {partition}", + f"#SBATCH -N {nodes}", + f"#SBATCH --ntasks-per-node={cpus}", + f"#SBATCH -t {walltime}", + "", + "# compute-service", + "module load openmpi/4.1.2", + "", + "# agent-service", + "airavata-agent --exp-id $EXP_ID &", + "", + "# research-framework", + f"srun {draft.get('app_id', 'app')} {draft.get('interface_name', 'run')}", + ] + else: + invocation = f"bash /tmp/{draft['name']}/run.sh" + script_lines = [ + "#!/bin/bash", + "# compute-service", + "module load openmpi/4.1.2", + "", + "# agent-service", + "airavata-agent --exp-id $EXP_ID &", + "", + "# research-framework", + f"{draft.get('app_id', 'app')} {draft.get('interface_name', 'run')}", + ] + return { + "invocation_command": invocation, + "script_contents": "\n".join(script_lines) + "\n", + "warnings": [], + } + + def launch_experiment(self, draft: dict[str, Any]) -> dict[str, Any]: + return {"experiment_id": f"exp-{draft['name']}-stub"} + + +class _RealClient: + def __init__(self, user_token: str) -> None: + self.user_token = user_token + + def list_applications(self, *, category, search): + raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)") + + def get_application(self, *, app_id): + raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)") + + def get_project_resource_profile(self, *, project_id): + raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)") + + def list_user_storages(self): + raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)") + + def list_projects(self): + raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)") + + def generate_preview(self, draft): + raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)") + + def launch_experiment(self, draft): + raise NotImplementedError("real client requires airavata server new-model RPCs (Task 28)") + + +def get_client(user_token: str) -> LauncherClient: + if getattr(settings, "LAUNCHER_CLIENT_STUB", True): + return _StubClient(user_token) + return _RealClient(user_token) diff --git a/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_client.py b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_client.py new file mode 100644 index 000000000..2ba428000 --- /dev/null +++ b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_client.py @@ -0,0 +1,59 @@ +from unittest import TestCase + +from django.test import override_settings + +from django_airavata.apps.api import launcher_client + + +class StubLauncherClientTest(TestCase): + @override_settings(LAUNCHER_CLIENT_STUB=True) + def test_list_applications_returns_at_least_one_app(self): + client = launcher_client.get_client(user_token="ignored") + apps = client.list_applications(category=None, search=None) + self.assertTrue(len(apps) >= 1) + first = apps[0] + self.assertIn("app_id", first) + self.assertIn("name", first) + self.assertIn("content", first) + self.assertIn("interfaces", first) + self.assertIsInstance(first["interfaces"], list) + + @override_settings(LAUNCHER_CLIENT_STUB=True) + def test_get_resource_profile_returns_partitions(self): + client = launcher_client.get_client(user_token="ignored") + profile = client.get_project_resource_profile(project_id="proj-stub") + self.assertIn("compute_resources", profile) + self.assertIn("allocation_id", profile) + self.assertTrue(len(profile["compute_resources"]) >= 1) + self.assertIn("partitions", profile["compute_resources"][0]) + + @override_settings(LAUNCHER_CLIENT_STUB=True) + def test_generate_preview_returns_script_and_command(self): + client = launcher_client.get_client(user_token="ignored") + preview = client.generate_preview({ + "name": "x", + "project_id": "proj-stub", + "app_id": "app-stub", + "interface_name": "run", + "inputs": {}, + "outputs": {}, + "runtime": { + "compute_resource_id": "cr-stub", + "partition": "RM", + "walltime": "01:00:00", + "nodes": 1, + "cpus_per_node": 8, + }, + }) + self.assertIn("invocation_command", preview) + self.assertIn("script_contents", preview) + self.assertIn("warnings", preview) + self.assertIsInstance(preview["warnings"], list) + + @override_settings(LAUNCHER_CLIENT_STUB=True) + def test_list_projects_returns_at_least_one(self): + client = launcher_client.get_client(user_token="ignored") + projects = client.list_projects() + self.assertTrue(len(projects) >= 1) + self.assertIn("project_id", projects[0]) + self.assertIn("name", projects[0]) diff --git a/airavata-django-portal/django_airavata/settings.py b/airavata-django-portal/django_airavata/settings.py index 8450f9af9..493b1875f 100644 --- a/airavata-django-portal/django_airavata/settings.py +++ b/airavata-django-portal/django_airavata/settings.py @@ -624,7 +624,9 @@ if 'GATEWAY_ID' not in dir(): GATEWAY_ID = os.environ.get('GATEWAY_ID', 'default') - +# Generic experiment launcher feature flags +FEATURE_GENERIC_LAUNCHER = os.environ.get("FEATURE_GENERIC_LAUNCHER", "False").lower() == "true" +LAUNCHER_CLIENT_STUB = os.environ.get("LAUNCHER_CLIENT_STUB", "True").lower() == "true"
