Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 69 additions & 5 deletions autosubmit_api/routers/v4/runners.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from enum import Enum
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, Depends, HTTPException
Expand Down Expand Up @@ -99,6 +100,13 @@ class SetJobStatusBody(RunnerEndpointBody):
command_params: SetJobStatusCmdParams


class RunnerEndpointsCategory(str, Enum):
SET_JOB_STATUS = "SET_JOB_STATUS"
CREATE_EXPERIMENT = "CREATE_EXPERIMENT"
RUNNER_RUN = "RUNNER_RUN"
UPDATE_EXPERIMENT_DETAILS = "UPDATE_EXPERIMENT_DETAILS"


def _endpoint_enabled(endpoint_name: str) -> bool:
config = read_config_file()
endpoints = config.get("RUNNER_CONFIGURATION", {}).get("ENDPOINTS", {})
Expand All @@ -114,7 +122,7 @@ async def set_job_status(
"""
Set the job status for an experiment using the specified runner profile.
"""
if not _endpoint_enabled("SET_JOB_STATUS"):
if not _endpoint_enabled(RunnerEndpointsCategory.SET_JOB_STATUS.value):
raise HTTPException(
status_code=403,
detail="The set-job-status endpoint is currently disabled.",
Expand Down Expand Up @@ -166,7 +174,7 @@ async def run_experiment(
"""
Run an experiment using the specified runner profile.
"""
if not _endpoint_enabled("RUNNER_RUN"):
if not _endpoint_enabled(RunnerEndpointsCategory.RUNNER_RUN.value):
raise HTTPException(
status_code=403,
detail="The run-experiment endpoint is currently disabled.",
Expand Down Expand Up @@ -218,7 +226,7 @@ async def get_runner_run_status(
"""
Get the status of the runner run for a given experiment ID.
"""
if not _endpoint_enabled("RUNNER_RUN"):
if not _endpoint_enabled(RunnerEndpointsCategory.RUNNER_RUN.value):
raise HTTPException(
status_code=403,
detail="The get-runner-run-status endpoint is currently disabled.",
Expand Down Expand Up @@ -264,7 +272,7 @@ async def stop_experiment(
"""
Stop an experiment using the specified runner profile.
"""
if not _endpoint_enabled("RUNNER_RUN"):
if not _endpoint_enabled(RunnerEndpointsCategory.RUNNER_RUN.value):
raise HTTPException(
status_code=403,
detail="The stop-experiment endpoint is currently disabled.",
Expand Down Expand Up @@ -310,7 +318,7 @@ async def create_experiment(
"""
Create an experiment using the specified runner profile.
"""
if not _endpoint_enabled("CREATE_EXPERIMENT"):
if not _endpoint_enabled(RunnerEndpointsCategory.CREATE_EXPERIMENT.value):
raise HTTPException(
status_code=403,
detail="The create-experiment endpoint is currently disabled.",
Expand Down Expand Up @@ -352,3 +360,59 @@ async def create_experiment(
"expid": expid,
"message": f"Experiment {expid} created successfully.",
}


class UpdateExperimentBody(RunnerEndpointBody):
expid: str
description: str


@router.post(
"/command/update-experiment-description", name="Update experiment description"
)
async def update_experiment_description(
body: UpdateExperimentBody,
user_id: Optional[str] = Depends(auth_token_dependency()),
) -> Dict[str, Any]:
"""
Update the description of an experiment using the specified runner profile.
"""
if not _endpoint_enabled(RunnerEndpointsCategory.UPDATE_EXPERIMENT_DETAILS.value):
raise HTTPException(
status_code=403,
detail="The update-experiment-description endpoint is currently disabled.",
)

expid = body.expid
description = body.description

logger.info(
f"Updating description for experiment {expid} using profile {body.profile_name}"
)

try:
profile = process_profile(body.profile_name, body.profile_params)
logger.debug(
f"Processing profile: {body.profile_name}. Profile data: {profile}"
)

runner_type, module_loader_type, modules = (
profile.get("RUNNER_TYPE"),
profile.get("MODULE_LOADER_TYPE"),
profile.get("MODULES"),
)

runner_extra_params = get_runner_extra_params(profile)

module_loader = get_module_loader(module_loader_type, modules)
runner = get_runner(runner_type, module_loader, **runner_extra_params)
await runner.update_description(expid, description)
except Exception as exc:
raise HTTPException(
status_code=500,
detail=f"Failed to update description for experiment {expid}: {exc}",
)

return {
"message": f"Description for experiment {expid} updated successfully.",
}
9 changes: 9 additions & 0 deletions autosubmit_api/runners/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,12 @@ async def set_job_status(
:param check_wrapper: Whether to check the wrapper script.
:param update_version: Whether to update the version.
"""

@abstractmethod
async def update_description(self, expid: str, description: str):
"""
Update the description of an experiment.

:param expid: The experiment ID.
Comment thread
LuiggiTenorioK marked this conversation as resolved.
:param description: The new description for the experiment.
"""
18 changes: 18 additions & 0 deletions autosubmit_api/runners/local_runner.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import asyncio.subprocess
import re
import shlex
import subprocess
from typing import Optional

Expand Down Expand Up @@ -373,3 +374,20 @@ async def set_job_status(
except Exception as exc:
logger.error(f"Command failed with error: {exc}")
raise exc

async def update_description(self, expid: str, description: str):
autosubmit_command = (
f"autosubmit updatedescrip {expid} {shlex.quote(description)}"
)
wrapped_command = self.module_loader.generate_command(autosubmit_command)
Comment thread
LuiggiTenorioK marked this conversation as resolved.

try:
logger.debug(f"Running command: {wrapped_command}")
output = subprocess.check_output(
wrapped_command, shell=True, text=True, executable="/bin/bash"
).strip()
logger.debug(f"Command output: {output}")
return output
except Exception as exc:
logger.error(f"Command failed with error: {exc}")
raise exc
21 changes: 21 additions & 0 deletions autosubmit_api/runners/ssh_runner.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import json
import re
from shlex import shlex
from typing import Optional

import paramiko
Expand Down Expand Up @@ -547,3 +548,23 @@ async def set_job_status(
except Exception as exc:
logger.error(f"Command failed with error: {exc}")
raise exc

async def update_description(self, expid: str, description: str):
autosubmit_command = (
f"autosubmit updatedescrip {expid} {shlex.quote(description)}"
)
prepared_command = self._prepare_command(autosubmit_command)
Comment thread
LuiggiTenorioK marked this conversation as resolved.

try:
logger.debug(f"Running update description command: {prepared_command}")
stdout, stderr, exit_code = self._execute_command(prepared_command)

if exit_code != 0:
logger.error(f"Command failed with exit code {exit_code}: {stderr}")
raise RuntimeError(f"Failed to update description: {stderr}")

logger.debug(f"Update description output: {stdout}")
return stdout
except Exception as exc:
logger.error(f"Command failed with error: {exc}")
raise exc
116 changes: 116 additions & 0 deletions tests/test_endpoints_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,122 @@ def test_no_description(self, fixture_fastapi_client: TestClient):
assert response.status_code != 200


class TestRunnerUpdateExperimentDescription:
endpoint = "/v4/runners/command/update-experiment-description"

def test_disabled_endpoint(self, fixture_fastapi_client: TestClient):
with patch(
"autosubmit_api.routers.v4.runners.read_config_file"
) as mock_read_config:
mock_read_config.return_value = {
"RUNNER_CONFIGURATION": {
"ENDPOINTS": {"UPDATE_EXPERIMENT_DETAILS": {"ENABLED": False}},
},
}

response = fixture_fastapi_client.post(
self.endpoint,
json={
"expid": "test_expid",
"description": "New description",
"profile_name": "ANY_PROFILE",
},
)

assert response.status_code == 403
assert "disabled" in response.json()["error_message"]

def test_valid_ssh_request(self, fixture_fastapi_client: TestClient):
with (
patch(
"autosubmit_api.runners.runner_config.read_config_file"
) as mock_read_config,
patch("autosubmit_api.routers.v4.runners.get_runner") as mock_get_runner,
):
mock_read_config.return_value = {
"RUNNER_CONFIGURATION": {
"PROFILES": {
"SSH_AUTOSUBMIT_DEV": {
"RUNNER_TYPE": "SSH",
"MODULE_LOADER_TYPE": "CONDA",
"MODULES": ["autosubmit"],
"SSH": {
"HOST": "bscesautosubmit03.bsc.es",
"PORT": 22,
},
}
}
}
}

mock_runner = MagicMock()
mock_runner.update_description = AsyncMock(return_value=None)
mock_get_runner.return_value = mock_runner

response = fixture_fastapi_client.post(
self.endpoint,
json={
"expid": "test_expid",
"description": "New description",
"profile_name": "SSH_AUTOSUBMIT_DEV",
"profile_params": {
"SSH": {
"USERNAME": "test_user",
}
},
},
)

assert response.status_code == 200

def test_invalid_profile(self, fixture_fastapi_client: TestClient):
response = fixture_fastapi_client.post(
self.endpoint,
json={
"expid": "test_expid",
"description": "New description",
"profile_name": "NON_EXISTENT_PROFILE",
},
)

assert response.status_code != 200

def test_runner_failure(self, fixture_fastapi_client: TestClient):
with (
patch(
"autosubmit_api.runners.runner_config.read_config_file"
) as mock_read_config,
patch("autosubmit_api.routers.v4.runners.get_runner") as mock_get_runner,
):
mock_read_config.return_value = {
"RUNNER_CONFIGURATION": {
"PROFILES": {
"LOCAL_AUTOSUBMIT_DEV": {
"RUNNER_TYPE": "LOCAL",
"MODULE_LOADER_TYPE": "NO_MODULE",
}
}
}
}

mock_runner = MagicMock()
mock_runner.update_description = AsyncMock(
side_effect=RuntimeError("Update failed")
)
mock_get_runner.return_value = mock_runner

response = fixture_fastapi_client.post(
self.endpoint,
json={
"expid": "test_expid",
"description": "New description",
"profile_name": "LOCAL_AUTOSUBMIT_DEV",
},
)

assert response.status_code == 500


class TestRunnerConfigurations:
@pytest.mark.parametrize(
"file_content, expected",
Expand Down