orchestrator-lso 2.0.0__py3-none-any.whl → 2.1.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.0.0"
16
+ __version__ = "2.1.0"
17
17
 
18
18
  import logging
19
19
 
@@ -22,8 +22,11 @@ from fastapi.middleware.cors import CORSMiddleware
22
22
 
23
23
  from lso import environment
24
24
  from lso.routes.default import router as default_router
25
+ from lso.routes.execute import router as executable_router
25
26
  from lso.routes.playbook import router as playbook_router
26
27
 
28
+ logger = logging.getLogger(__name__)
29
+
27
30
 
28
31
  def create_app() -> FastAPI:
29
32
  """Initialise the :term:`LSO` app."""
@@ -35,9 +38,10 @@ def create_app() -> FastAPI:
35
38
 
36
39
  app.include_router(default_router, prefix="/api")
37
40
  app.include_router(playbook_router, prefix="/api/playbook")
41
+ app.include_router(executable_router, prefix="/api/execute")
38
42
 
39
43
  environment.setup_logging()
40
44
 
41
- logging.info("FastAPI app initialized")
45
+ logger.info("FastAPI app initialized")
42
46
 
43
47
  return app
lso/config.py CHANGED
@@ -34,15 +34,15 @@ class Config(BaseSettings):
34
34
 
35
35
  TESTING: bool = False
36
36
  ANSIBLE_PLAYBOOKS_ROOT_DIR: str = "/path/to/ansible/playbooks"
37
+ EXECUTABLES_ROOT_DIR: str = "/path/to/executables"
37
38
  EXECUTOR: ExecutorType = ExecutorType.THREADPOOL
38
39
  MAX_THREAD_POOL_WORKERS: int = min(32, (os.cpu_count() or 1) + 4)
39
40
  REQUEST_TIMEOUT_SEC: int = 10
40
41
  CELERY_BROKER_URL: str = "redis://localhost:6379/0"
41
42
  CELERY_RESULT_BACKEND: str = "redis://localhost:6379/0"
42
- CELERY_TIMEZONE: str = "Europe/Amsterdam"
43
- CELERY_ENABLE_UTC: bool = True
44
43
  CELERY_RESULT_EXPIRES: int = 3600
45
44
  WORKER_QUEUE_NAME: str | None = None
45
+ EXECUTABLE_TIMEOUT_SEC: int = 300
46
46
 
47
47
 
48
48
  settings = Config()
lso/execute.py ADDED
@@ -0,0 +1,31 @@
1
+ """Module for handling the execution of arbitrary executables."""
2
+
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ from pydantic import HttpUrl
7
+
8
+ from lso.config import ExecutorType, settings
9
+ from lso.tasks import run_executable_proc_task
10
+ from lso.utils import get_thread_pool
11
+
12
+
13
+ def get_executable_path(executable_name: Path) -> Path:
14
+ """Return the full path of an executable, based on the configured EXECUTABLES_ROOT_DIR."""
15
+ return Path(settings.EXECUTABLES_ROOT_DIR) / executable_name
16
+
17
+
18
+ def run_executable(executable_path: Path, args: list[str], callback: HttpUrl) -> uuid.UUID:
19
+ """Dispatch the task for executing an arbitrary executable remotely.
20
+
21
+ Uses a ThreadPoolExecutor (for local execution) or a Celery worker (for distributed tasks).
22
+ """
23
+ job_id = uuid.uuid4()
24
+ if settings.EXECUTOR == ExecutorType.THREADPOOL:
25
+ executor = get_thread_pool()
26
+ future = executor.submit(run_executable_proc_task, str(job_id), str(executable_path), args, str(callback))
27
+ if settings.TESTING:
28
+ future.result()
29
+ elif settings.EXECUTOR == ExecutorType.WORKER:
30
+ run_executable_proc_task.delay(str(job_id), str(executable_path), args, str(callback))
31
+ return job_id
lso/playbook.py CHANGED
@@ -14,7 +14,6 @@
14
14
  """Module that gathers common API responses and data models."""
15
15
 
16
16
  import uuid
17
- from concurrent.futures import ThreadPoolExecutor
18
17
  from pathlib import Path
19
18
  from typing import Any
20
19
 
@@ -22,22 +21,7 @@ from pydantic import HttpUrl
22
21
 
23
22
  from lso.config import ExecutorType, settings
24
23
  from lso.tasks import run_playbook_proc_task
25
-
26
- _executor = None
27
-
28
-
29
- def get_thread_pool() -> ThreadPoolExecutor:
30
- """Get and optionally initialise a ThreadPoolExecutor.
31
-
32
- Returns:
33
- ThreadPoolExecutor
34
-
35
- """
36
- global _executor # noqa: PLW0603
37
- if _executor is None:
38
- _executor = ThreadPoolExecutor(max_workers=settings.MAX_THREAD_POOL_WORKERS)
39
-
40
- return _executor
24
+ from lso.utils import get_thread_pool
41
25
 
42
26
 
43
27
  def get_playbook_path(playbook_name: Path) -> Path:
@@ -53,7 +37,7 @@ def run_playbook(
53
37
  ) -> uuid.UUID:
54
38
  """Run an Ansible playbook against a specified inventory.
55
39
 
56
- :param Path playbook_path: playbook to be executed.
40
+ :param Path playbook_path: Playbook to be executed.
57
41
  :param dict[str, Any] extra_vars: Any extra vars needed for the playbook to run.
58
42
  :param dict[str, Any] | str inventory: The inventory that the playbook is executed against.
59
43
  :param HttpUrl callback: Callback URL where the playbook should send a status update when execution is completed.
lso/routes/execute.py ADDED
@@ -0,0 +1,51 @@
1
+ """FastAPI route for running arbitrary executables."""
2
+
3
+ import uuid
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, HTTPException, status
8
+ from pydantic import AfterValidator, BaseModel, HttpUrl
9
+
10
+ from lso.execute import get_executable_path, run_executable
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ def _executable_path_validator(executable_name: Path) -> Path:
16
+ """Validate that the executable exists, is a file, and is marked as executable."""
17
+ executable_path = get_executable_path(executable_name)
18
+ if not executable_path.exists():
19
+ msg = f"Executable '{executable_path}' does not exist."
20
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=msg)
21
+ if not executable_path.is_file():
22
+ msg = f"Executable '{executable_name}' is not a valid file."
23
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=msg)
24
+ if not executable_path.stat().st_mode & 0o111:
25
+ msg = f"Executable '{executable_name}' is not marked as executable."
26
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=msg)
27
+ return executable_path
28
+
29
+
30
+ ExecutableName = Annotated[Path, AfterValidator(_executable_path_validator)]
31
+
32
+
33
+ class ExecutableRunParams(BaseModel):
34
+ """Request parameters for running an arbitrary executable."""
35
+
36
+ executable_name: ExecutableName
37
+ 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
45
+
46
+
47
+ @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)
lso/routes/playbook.py CHANGED
@@ -36,7 +36,7 @@ def _inventory_validator(inventory: dict[str, Any] | str) -> dict[str, Any] | st
36
36
  """Validate the provided inventory format.
37
37
 
38
38
  Attempts to parse the inventory to verify its validity. If the inventory cannot be parsed or the inventory
39
- format is incorrect an HTTP 422 error is raised.
39
+ format is incorrect, an HTTP 422 error is raised.
40
40
 
41
41
  :param inventory: The inventory to validate, can be a dictionary or a string.
42
42
  :return: The validated inventory if no errors are found.
lso/tasks.py CHANGED
@@ -18,6 +18,8 @@ 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
21
23
  from typing import Any
22
24
 
23
25
  import ansible_runner
@@ -25,7 +27,7 @@ import requests
25
27
  from starlette import status
26
28
 
27
29
  from lso.config import settings
28
- from lso.worker import RUN_PLAYBOOK, celery
30
+ from lso.worker import RUN_EXECUTABLE, RUN_PLAYBOOK, celery
29
31
 
30
32
  logger = logging.getLogger(__name__)
31
33
 
@@ -34,6 +36,13 @@ class CallbackFailedError(Exception):
34
36
  """Exception raised when a callback url can't be reached."""
35
37
 
36
38
 
39
+ class JobStatus(StrEnum):
40
+ """Enumeration of possible job statuses."""
41
+
42
+ SUCCESSFUL = "successful"
43
+ FAILED = "failed"
44
+
45
+
37
46
  @celery.task(name=RUN_PLAYBOOK) # type: ignore[misc]
38
47
  def run_playbook_proc_task(
39
48
  job_id: str, playbook_path: str, extra_vars: dict[str, Any], inventory: dict[str, Any] | str, callback: str
@@ -58,7 +67,55 @@ def run_playbook_proc_task(
58
67
  "return_code": int(ansible_playbook_run.rc),
59
68
  }
60
69
 
61
- request_result = requests.post(str(callback), json=payload, timeout=settings.REQUEST_TIMEOUT_SEC)
62
- if not status.HTTP_200_OK <= request_result.status_code < status.HTTP_300_MULTIPLE_CHOICES:
63
- msg = f"Callback failed: {request_result.text}, url: {callback}"
70
+ response = requests.post(str(callback), json=payload, timeout=settings.REQUEST_TIMEOUT_SEC)
71
+ if not (status.HTTP_200_OK <= response.status_code < status.HTTP_300_MULTIPLE_CHOICES):
72
+ msg = f"Callback failed: {response.text}, url: {callback}"
64
73
  raise CallbackFailedError(msg)
74
+
75
+
76
+ @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:
78
+ """Celery task to run an arbitrary executable and notify via callback.
79
+
80
+ Executes the executable with the provided arguments and posts back the output and status.
81
+ """
82
+ msg = f"Executing executable: {executable_path} with args: {args}, callback: {callback}"
83
+ 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)
lso/utils.py ADDED
@@ -0,0 +1,16 @@
1
+ """Utility functions for the LSO package."""
2
+
3
+ from concurrent.futures import ThreadPoolExecutor
4
+
5
+ from lso.config import settings
6
+
7
+ _executor = None
8
+
9
+
10
+ def get_thread_pool() -> ThreadPoolExecutor:
11
+ """Initialize or return a cached ThreadPoolExecutor for local asynchronous execution."""
12
+ global _executor # noqa: PLW0603
13
+ if _executor is None:
14
+ _executor = ThreadPoolExecutor(max_workers=settings.MAX_THREAD_POOL_WORKERS)
15
+
16
+ return _executor
lso/worker.py CHANGED
@@ -19,6 +19,7 @@ from celery.signals import worker_shutting_down
19
19
  from lso.config import settings
20
20
 
21
21
  RUN_PLAYBOOK = "lso.tasks.run_playbook_proc_task"
22
+ RUN_EXECUTABLE = "lso.tasks.run_executable_proc_task"
22
23
 
23
24
  celery = Celery(
24
25
  "lso-worker",
@@ -26,11 +27,6 @@ celery = Celery(
26
27
  backend=settings.CELERY_RESULT_BACKEND,
27
28
  )
28
29
 
29
- if settings.TESTING:
30
- celery.conf.update(backend=settings.CELERY_RESULT_BACKEND, task_ignore_result=False)
31
- else:
32
- celery.conf.update(task_ignore_result=True)
33
-
34
30
  celery.conf.update(
35
31
  result_expires=settings.CELERY_RESULT_EXPIRES,
36
32
  worker_prefetch_multiplier=1,
@@ -38,11 +34,13 @@ celery.conf.update(
38
34
  task_send_sent_event=True,
39
35
  redbeat_redis_url=settings.CELERY_BROKER_URL,
40
36
  broker_connection_retry_on_startup=True,
37
+ task_ignore_result=not settings.TESTING,
41
38
  )
42
39
 
43
40
  if settings.WORKER_QUEUE_NAME:
44
41
  celery.conf.task_routes = {
45
42
  RUN_PLAYBOOK: {"queue": settings.WORKER_QUEUE_NAME},
43
+ RUN_EXECUTABLE: {"queue": settings.WORKER_QUEUE_NAME},
46
44
  }
47
45
 
48
46
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: orchestrator-lso
3
- Version: 2.0.0
3
+ Version: 2.1.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
@@ -25,15 +25,16 @@ Classifier: License :: OSI Approved :: Apache Software License
25
25
  Classifier: Programming Language :: Python :: 3 :: Only
26
26
  Classifier: Programming Language :: Python :: 3.11
27
27
  Classifier: Programming Language :: Python :: 3.12
28
+ License-File: LICENSE
28
29
  Requires-Dist: ansible-runner==2.4.0
29
- Requires-Dist: ansible==10.6.0
30
- Requires-Dist: fastapi==0.115.5
31
- Requires-Dist: httpx==0.27.2
32
- Requires-Dist: uvicorn[standard]==0.32.0
30
+ Requires-Dist: ansible==10.7.0
31
+ Requires-Dist: fastapi==0.115.8
32
+ Requires-Dist: httpx==0.28.1
33
+ Requires-Dist: uvicorn[standard]==0.34.0
33
34
  Requires-Dist: requests==2.32.3
34
- Requires-Dist: pydantic-settings==2.5.2
35
+ Requires-Dist: pydantic-settings==2.7.1
35
36
  Requires-Dist: celery==5.4.0
36
- Requires-Dist: redis==5.2.0
37
+ Requires-Dist: redis==5.2.1
37
38
  Requires-Dist: types-setuptools ; extra == "dev"
38
39
  Requires-Dist: types-requests ; extra == "dev"
39
40
  Requires-Dist: toml ; extra == "dev"
@@ -0,0 +1,17 @@
1
+ lso/__init__.py,sha256=rvLpPFoeQhGsSfP4vjcdFBmHwJJhPVOzjwaMt-iNUfQ,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.0.dist-info/licenses/LICENSE,sha256=CgFXf7XbZXJADozQIw2uUmmvU-zwAwXo4u7cgDfx3rE,10744
15
+ orchestrator_lso-2.1.0.dist-info/WHEEL,sha256=_2ozNFCLWc93bK4WKHCO-eDUENDlo-dgc9cU3qokYO4,82
16
+ orchestrator_lso-2.1.0.dist-info/METADATA,sha256=TlUqzxd8VwnU0I7RgS4xFxi2XfqAM2yabT3a0Zo2Hcs,6351
17
+ orchestrator_lso-2.1.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: flit 3.10.1
2
+ Generator: flit 3.11.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,14 +0,0 @@
1
- lso/__init__.py,sha256=tas4EgOuJNAmoOOC8rdqeBjRS4o8ntucPZO13H4rETc,1428
2
- lso/app.py,sha256=WDtlmjELIeFA437j-WPfqBQf6QT_l35LEABbEosHlqY,775
3
- lso/config.py,sha256=BXUl8SgYdaXbrmmqBRgxobGboCn02qfp_wdvZHZr_Gk,1627
4
- lso/environment.py,sha256=iZ3DmsSKAC5a7VNL-HfJOJZ0sQwUMf7ZzNGC34B2CG0,1771
5
- lso/playbook.py,sha256=PRnZMa93FZ-3s1pn2B4p6PBii-VN-Ti8DyhuMd8AI0I,2766
6
- lso/tasks.py,sha256=d_JRESlfs2dw-KZMLu2F21VHOz7l-5FhrhTDX58Pn38,2484
7
- lso/worker.py,sha256=3Y32F2DknsVK3JY4M6hDk35lvn7xLHqJhAIIbvxYIFE,1730
8
- lso/routes/__init__.py,sha256=1kRrth9zkFgmj6LChujieYJq5cjIETeTGXa1G70pduk,639
9
- lso/routes/default.py,sha256=a7STN1BJyFVizXUzmqKuADO0fpE1SHun-PzaZ-jx1wU,1438
10
- lso/routes/playbook.py,sha256=Q0Q-9fLOYpahgC7WJ5SEFt9P2NJnAKdQg3onjVnd6js,4903
11
- orchestrator_lso-2.0.0.dist-info/LICENSE,sha256=CgFXf7XbZXJADozQIw2uUmmvU-zwAwXo4u7cgDfx3rE,10744
12
- orchestrator_lso-2.0.0.dist-info/WHEEL,sha256=CpUCUxeHQbRN5UGRQHYRJorO5Af-Qy_fHMctcQ8DSGI,82
13
- orchestrator_lso-2.0.0.dist-info/METADATA,sha256=w9gaI2z9NU-KD1QsmfFg2ofInWMrxp8xb569Og_Jqp8,6329
14
- orchestrator_lso-2.0.0.dist-info/RECORD,,