orchestrator-lso 2.1.1__py3-none-any.whl → 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lso/__init__.py CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  """LSO, an API for remotely running Ansible playbooks."""
15
15
 
16
- __version__ = "2.1.1"
16
+ __version__ = "2.2.0"
17
17
 
18
18
  import logging
19
19
 
lso/execute.py CHANGED
@@ -1,11 +1,13 @@
1
1
  """Module for handling the execution of arbitrary executables."""
2
2
 
3
+ import subprocess # noqa: S404
3
4
  import uuid
4
5
  from pathlib import Path
5
6
 
6
7
  from pydantic import HttpUrl
7
8
 
8
9
  from lso.config import ExecutorType, settings
10
+ from lso.schema import ExecutionResult
9
11
  from lso.tasks import run_executable_proc_task
10
12
  from lso.utils import get_thread_pool
11
13
 
@@ -15,17 +17,43 @@ def get_executable_path(executable_name: Path) -> Path:
15
17
  return Path(settings.EXECUTABLES_ROOT_DIR) / executable_name
16
18
 
17
19
 
18
- def run_executable(executable_path: Path, args: list[str], callback: HttpUrl) -> uuid.UUID:
20
+ def run_executable_async(executable_path: Path, args: list[str], callback: HttpUrl | None) -> uuid.UUID:
19
21
  """Dispatch the task for executing an arbitrary executable remotely.
20
22
 
21
23
  Uses a ThreadPoolExecutor (for local execution) or a Celery worker (for distributed tasks).
22
24
  """
23
25
  job_id = uuid.uuid4()
26
+ callback_url = str(callback) if callback else None
24
27
  if settings.EXECUTOR == ExecutorType.THREADPOOL:
25
28
  executor = get_thread_pool()
26
- future = executor.submit(run_executable_proc_task, str(job_id), str(executable_path), args, str(callback))
29
+ future = executor.submit(run_executable_proc_task, str(job_id), str(executable_path), args, callback_url)
27
30
  if settings.TESTING:
28
31
  future.result()
29
32
  elif settings.EXECUTOR == ExecutorType.WORKER:
30
- run_executable_proc_task.delay(str(job_id), str(executable_path), args, str(callback))
33
+ run_executable_proc_task.delay(str(job_id), str(executable_path), args, callback_url)
31
34
  return job_id
35
+
36
+
37
+ def run_executable_sync(executable_path: str, args: list[str]) -> ExecutionResult:
38
+ """Run the given executable synchronously and return the result."""
39
+ try:
40
+ result = subprocess.run( # noqa: S603
41
+ [executable_path, *args],
42
+ text=True,
43
+ capture_output=True,
44
+ timeout=settings.EXECUTABLE_TIMEOUT_SEC,
45
+ check=False,
46
+ )
47
+ output = result.stdout + result.stderr
48
+ return_code = result.returncode
49
+ except subprocess.TimeoutExpired:
50
+ output = "Execution timed out."
51
+ return_code = -1
52
+ except Exception as e: # noqa: BLE001
53
+ output = str(e)
54
+ return_code = -1
55
+
56
+ return ExecutionResult( # type: ignore[call-arg]
57
+ output=output,
58
+ return_code=return_code,
59
+ )
lso/routes/execute.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """FastAPI route for running arbitrary executables."""
2
2
 
3
+ import asyncio
3
4
  import uuid
4
5
  from pathlib import Path
5
6
  from typing import Annotated
@@ -7,7 +8,8 @@ from typing import Annotated
7
8
  from fastapi import APIRouter, HTTPException, status
8
9
  from pydantic import AfterValidator, BaseModel, HttpUrl
9
10
 
10
- from lso.execute import get_executable_path, run_executable
11
+ from lso.execute import get_executable_path, run_executable_async, run_executable_sync
12
+ from lso.schema import ExecutableRunResponse
11
13
 
12
14
  router = APIRouter()
13
15
 
@@ -35,17 +37,17 @@ class ExecutableRunParams(BaseModel):
35
37
 
36
38
  executable_name: ExecutableName
37
39
  args: list[str] = []
38
- callback: HttpUrl
39
-
40
-
41
- class ExecutableRunResponse(BaseModel):
42
- """Response for running an arbitrary executable."""
43
-
44
- job_id: uuid.UUID
40
+ callback: HttpUrl | None = None
41
+ is_async: bool = True
45
42
 
46
43
 
47
44
  @router.post("/", response_model=ExecutableRunResponse, status_code=status.HTTP_201_CREATED)
48
- def run_executable_endpoint(params: ExecutableRunParams) -> ExecutableRunResponse:
49
- """Dispatch an asynchronous task to run an arbitrary executable."""
50
- job_id = run_executable(params.executable_name, params.args, params.callback)
51
- return ExecutableRunResponse(job_id=job_id)
45
+ async def run_executable_endpoint(params: ExecutableRunParams) -> ExecutableRunResponse:
46
+ """Dispatch a task to run an arbitrary executable."""
47
+ if params.is_async:
48
+ job_id = run_executable_async(params.executable_name, params.args, params.callback)
49
+ return ExecutableRunResponse(job_id=job_id)
50
+
51
+ job_id = uuid.uuid4()
52
+ result = await asyncio.to_thread(run_executable_sync, str(params.executable_name), params.args)
53
+ return ExecutableRunResponse(job_id=job_id, result=result)
lso/schema.py ADDED
@@ -0,0 +1,50 @@
1
+ # Copyright 2024-2025 GÉANT Vereniging.
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
14
+ """Module for defining the schema for running arbitrary executables."""
15
+
16
+ import uuid
17
+ from enum import StrEnum
18
+
19
+ from pydantic import BaseModel, model_validator
20
+
21
+
22
+ class JobStatus(StrEnum):
23
+ """Enumeration of possible job statuses."""
24
+
25
+ SUCCESSFUL = "successful"
26
+ FAILED = "failed"
27
+
28
+
29
+ class ExecutionResult(BaseModel):
30
+ """Model for capturing the result of an executable run."""
31
+
32
+ output: str
33
+ return_code: int
34
+ status: JobStatus
35
+
36
+ @model_validator(mode="before")
37
+ def populate_status(cls, values: dict) -> dict:
38
+ """Set the status based on the return code."""
39
+ rc = values.get("return_code")
40
+ if rc is not None:
41
+ values["status"] = JobStatus.SUCCESSFUL if rc == 0 else JobStatus.FAILED
42
+
43
+ return values
44
+
45
+
46
+ class ExecutableRunResponse(BaseModel):
47
+ """Response for running an arbitrary executable."""
48
+
49
+ job_id: uuid.UUID
50
+ result: ExecutionResult | None = None
lso/tasks.py CHANGED
@@ -1,4 +1,4 @@
1
- # Copyright 2023-2024 GÉANT Vereniging.
1
+ # Copyright 2024-2025 GÉANT Vereniging.
2
2
  # Licensed under the Apache License, Version 2.0 (the "License");
3
3
  # you may not use this file except in compliance with the License.
4
4
  # You may obtain a copy of the License at
@@ -18,15 +18,15 @@ the results to a specified callback URL.
18
18
  """
19
19
 
20
20
  import logging
21
- import subprocess # noqa: S404
22
- from enum import StrEnum
23
21
  from typing import Any
22
+ from uuid import UUID
24
23
 
25
24
  import ansible_runner
26
25
  import requests
27
26
  from starlette import status
28
27
 
29
28
  from lso.config import settings
29
+ from lso.schema import ExecutableRunResponse
30
30
  from lso.worker import RUN_EXECUTABLE, RUN_PLAYBOOK, celery
31
31
 
32
32
  logger = logging.getLogger(__name__)
@@ -36,13 +36,6 @@ class CallbackFailedError(Exception):
36
36
  """Exception raised when a callback url can't be reached."""
37
37
 
38
38
 
39
- class JobStatus(StrEnum):
40
- """Enumeration of possible job statuses."""
41
-
42
- SUCCESSFUL = "successful"
43
- FAILED = "failed"
44
-
45
-
46
39
  @celery.task(name=RUN_PLAYBOOK) # type: ignore[misc]
47
40
  def run_playbook_proc_task(
48
41
  job_id: str, playbook_path: str, extra_vars: dict[str, Any], inventory: dict[str, Any] | str, callback: str
@@ -74,48 +67,34 @@ def run_playbook_proc_task(
74
67
 
75
68
 
76
69
  @celery.task(name=RUN_EXECUTABLE) # type: ignore[misc]
77
- def run_executable_proc_task(job_id: str, executable_path: str, args: list[str], callback: str) -> None:
70
+ def run_executable_proc_task(job_id: str, executable_path: str, args: list[str], callback: str | None) -> None:
78
71
  """Celery task to run an arbitrary executable and notify via callback.
79
72
 
80
- Executes the executable with the provided arguments and posts back the output and status.
73
+ Executes the executable with the provided arguments and posts back the result if a callback URL is provided.
81
74
  """
75
+ from lso.execute import run_executable_sync # noqa: PLC0415
76
+
82
77
  msg = f"Executing executable: {executable_path} with args: {args}, callback: {callback}"
83
78
  logger.info(msg)
84
- try:
85
- result = subprocess.run( # noqa: S603
86
- [executable_path, *args],
87
- text=True,
88
- capture_output=True,
89
- timeout=settings.EXECUTABLE_TIMEOUT_SEC,
90
- check=False,
91
- )
92
- output = result.stdout + result.stderr
93
- return_code = result.returncode
94
- except subprocess.TimeoutExpired:
95
- output = "Execution timed out."
96
- return_code = -1
97
- except Exception as e: # noqa: BLE001
98
- output = str(e)
99
- return_code = -1
100
-
101
- payload = {
102
- "job_id": job_id,
103
- "output": output,
104
- "return_code": return_code,
105
- "status": JobStatus.SUCCESSFUL if return_code == 0 else JobStatus.FAILED,
106
- }
107
-
108
- def _raise_callback_error(message: str, error: Exception | None = None) -> None:
109
- if error:
110
- raise CallbackFailedError(message) from error
111
- raise CallbackFailedError(message)
112
-
113
- try:
114
- response = requests.post(str(callback), json=payload, timeout=settings.REQUEST_TIMEOUT_SEC)
115
- if not (status.HTTP_200_OK <= response.status_code < status.HTTP_300_MULTIPLE_CHOICES):
116
- msg = f"Callback failed: {response.text}, url: {callback}"
117
- _raise_callback_error(msg)
118
- except Exception as e:
119
- error_msg = f"Callback error: {e}"
120
- logger.exception(error_msg)
121
- _raise_callback_error(error_msg, e)
79
+ result = run_executable_sync(executable_path, args)
80
+
81
+ if callback:
82
+ payload = ExecutableRunResponse(
83
+ job_id=UUID(job_id),
84
+ result=result,
85
+ ).model_dump(mode="json")
86
+
87
+ def _raise_callback_error(message: str, error: Exception | None = None) -> None:
88
+ if error:
89
+ raise CallbackFailedError(message) from error
90
+ raise CallbackFailedError(message)
91
+
92
+ try:
93
+ response = requests.post(str(callback), json=payload, timeout=settings.REQUEST_TIMEOUT_SEC)
94
+ if not (status.HTTP_200_OK <= response.status_code < status.HTTP_300_MULTIPLE_CHOICES):
95
+ msg = f"Callback failed: {response.text}, url: {callback}"
96
+ _raise_callback_error(msg)
97
+ except Exception as e:
98
+ error_msg = f"Callback error: {e}"
99
+ logger.exception(error_msg)
100
+ _raise_callback_error(error_msg, e)
lso/utils.py CHANGED
@@ -1,3 +1,16 @@
1
+ # Copyright 2024-2025 GÉANT Vereniging.
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
1
14
  """Utility functions for the LSO package."""
2
15
 
3
16
  from concurrent.futures import ThreadPoolExecutor
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orchestrator-lso
3
- Version: 2.1.1
3
+ Version: 2.2.0
4
4
  Summary: LSO, an API for remotely running Ansible playbooks.
5
5
  Author-email: GÉANT Orchestration and Automation Team <goat@geant.org>
6
6
  Requires-Python: >=3.11,<3.13
@@ -30,10 +30,10 @@ Requires-Dist: ansible-runner==2.4.1
30
30
  Requires-Dist: ansible==10.7.0
31
31
  Requires-Dist: fastapi==0.115.12
32
32
  Requires-Dist: httpx==0.28.1
33
- Requires-Dist: uvicorn[standard]==0.34.2
34
- Requires-Dist: requests==2.32.3
33
+ Requires-Dist: uvicorn[standard]==0.34.3
34
+ Requires-Dist: requests==2.32.4
35
35
  Requires-Dist: pydantic-settings==2.9.1
36
- Requires-Dist: celery==5.5.2
36
+ Requires-Dist: celery==5.5.3
37
37
  Requires-Dist: redis==5.2.1
38
38
  Requires-Dist: types-setuptools ; extra == "dev"
39
39
  Requires-Dist: types-requests ; extra == "dev"
@@ -0,0 +1,18 @@
1
+ lso/__init__.py,sha256=jBH_2-SwYCAcTSYY-AZXATzRzEMrZh2tQVHLM12XrQ0,1589
2
+ lso/app.py,sha256=WDtlmjELIeFA437j-WPfqBQf6QT_l35LEABbEosHlqY,775
3
+ lso/config.py,sha256=fceYs85UGN76GRyGs4x6TiKhktv-ykjc6-WocPRmFGY,1639
4
+ lso/environment.py,sha256=iZ3DmsSKAC5a7VNL-HfJOJZ0sQwUMf7ZzNGC34B2CG0,1771
5
+ lso/execute.py,sha256=G1AacmmN46mAzPWpnoI31RhXoWN6WEZUFr_P8Z_9Ngw,2139
6
+ lso/playbook.py,sha256=NHCeVttY5u1xGAdty954SZEYLcZEGBbBfjPuvBCgUwI,2409
7
+ lso/schema.py,sha256=-QCBaUcfMH1MaEEDaGzZoGJn-NcQLbH4dfrLxFYoXXs,1513
8
+ lso/tasks.py,sha256=tM26bAiEviaKAVwiHkKr6od3N00covBAAXTFVxIvdY8,4030
9
+ lso/utils.py,sha256=eVKyYRtdu_yPkbUQqDJlCKlCPAgc0dFxG1E1kY2Qsao,1043
10
+ lso/worker.py,sha256=61TXUefv8mGMq9LsD419r0O9Qxpa-WAtgSfu7SPEg44,1727
11
+ lso/routes/__init__.py,sha256=1kRrth9zkFgmj6LChujieYJq5cjIETeTGXa1G70pduk,639
12
+ lso/routes/default.py,sha256=a7STN1BJyFVizXUzmqKuADO0fpE1SHun-PzaZ-jx1wU,1438
13
+ lso/routes/execute.py,sha256=zwObIRB2aSlCikpmHQ6TJFcZFCjEPFC4R7oIu1UA7R8,2122
14
+ lso/routes/playbook.py,sha256=YNPZGuJXh5JkDWJ-sVRVE-cTg9eEm6BPjNThFLDqxeM,4904
15
+ orchestrator_lso-2.2.0.dist-info/licenses/LICENSE,sha256=CgFXf7XbZXJADozQIw2uUmmvU-zwAwXo4u7cgDfx3rE,10744
16
+ orchestrator_lso-2.2.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
17
+ orchestrator_lso-2.2.0.dist-info/METADATA,sha256=WU3uePo1qeayracQz2hlaj90m9ZXhPcjoinRCy5F15o,6352
18
+ orchestrator_lso-2.2.0.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- lso/__init__.py,sha256=3ECt41yxiWdlZJA_Ym-67uW4kABwCp8AE6gtcsH53_U,1589
2
- lso/app.py,sha256=WDtlmjELIeFA437j-WPfqBQf6QT_l35LEABbEosHlqY,775
3
- lso/config.py,sha256=fceYs85UGN76GRyGs4x6TiKhktv-ykjc6-WocPRmFGY,1639
4
- lso/environment.py,sha256=iZ3DmsSKAC5a7VNL-HfJOJZ0sQwUMf7ZzNGC34B2CG0,1771
5
- lso/execute.py,sha256=BZeR0NYLZaLE28K7IcQ6JA7opdJOZrVK7rsSZyArsBo,1208
6
- lso/playbook.py,sha256=NHCeVttY5u1xGAdty954SZEYLcZEGBbBfjPuvBCgUwI,2409
7
- lso/tasks.py,sha256=ToHEDEqGipMUYk7sY8qZzrYV5dWwM8nekl2OGiEVVko,4509
8
- lso/utils.py,sha256=8B3_p5nliD0KQcU2dLRdbEa7LvDqtNH0ZViMJ4yB4gE,458
9
- lso/worker.py,sha256=61TXUefv8mGMq9LsD419r0O9Qxpa-WAtgSfu7SPEg44,1727
10
- lso/routes/__init__.py,sha256=1kRrth9zkFgmj6LChujieYJq5cjIETeTGXa1G70pduk,639
11
- lso/routes/default.py,sha256=a7STN1BJyFVizXUzmqKuADO0fpE1SHun-PzaZ-jx1wU,1438
12
- lso/routes/execute.py,sha256=8WACuI8QN8piI4pkHNtxARB8l5QcBKWSZ3qE_LWqBfU,1896
13
- lso/routes/playbook.py,sha256=YNPZGuJXh5JkDWJ-sVRVE-cTg9eEm6BPjNThFLDqxeM,4904
14
- orchestrator_lso-2.1.1.dist-info/licenses/LICENSE,sha256=CgFXf7XbZXJADozQIw2uUmmvU-zwAwXo4u7cgDfx3rE,10744
15
- orchestrator_lso-2.1.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
16
- orchestrator_lso-2.1.1.dist-info/METADATA,sha256=W0RWPPi0wLpUE2mi6ZF696Axst7usH2njtV-txUh0bw,6352
17
- orchestrator_lso-2.1.1.dist-info/RECORD,,