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"
 
 
 

Reply via email to