orchestrator-lso 2.0.0__tar.gz → 2.1.0__tar.gz

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.
Files changed (54) hide show
  1. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/.bumpversion.cfg +1 -1
  2. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/Dockerfile.example +1 -1
  3. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/PKG-INFO +9 -8
  4. orchestrator_lso-2.1.0/docs/source/index.rst +34 -0
  5. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/env.example +0 -2
  6. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/lso/__init__.py +6 -2
  7. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/lso/config.py +2 -2
  8. orchestrator_lso-2.1.0/lso/execute.py +31 -0
  9. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/lso/playbook.py +2 -18
  10. orchestrator_lso-2.1.0/lso/routes/execute.py +51 -0
  11. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/lso/routes/playbook.py +1 -1
  12. orchestrator_lso-2.1.0/lso/tasks.py +121 -0
  13. orchestrator_lso-2.1.0/lso/utils.py +16 -0
  14. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/lso/worker.py +3 -5
  15. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/pyproject.toml +6 -6
  16. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/test/conftest.py +9 -0
  17. orchestrator_lso-2.1.0/test/routes/test_execute.py +123 -0
  18. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/test/routes/test_playbook.py +4 -13
  19. orchestrator_lso-2.1.0/test/test_execute.py +27 -0
  20. orchestrator_lso-2.1.0/test/utils.py +27 -0
  21. orchestrator_lso-2.0.0/docs/source/index.rst +0 -10
  22. orchestrator_lso-2.0.0/lso/tasks.py +0 -64
  23. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/.github/dependabot.yml +0 -0
  24. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/.github/styles/config/vocabularies/Sphinx/accept.txt +0 -0
  25. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/.github/styles/config/vocabularies/jargon/accept.txt +0 -0
  26. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/.github/workflows/publish-package.yaml +0 -0
  27. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/.github/workflows/run-linting-tests.yaml +0 -0
  28. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/.github/workflows/run-unit-tests.yaml +0 -0
  29. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/.github/workflows/sphinx.yaml +0 -0
  30. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/.gitignore +0 -0
  31. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/.vale.ini +0 -0
  32. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/LICENSE +0 -0
  33. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/README.md +0 -0
  34. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/docs/LSO_banner.jpg +0 -0
  35. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/docs/Makefile +0 -0
  36. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/docs/source/_static/custom.css +0 -0
  37. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/docs/source/_static/lso_logo.png +0 -0
  38. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/docs/source/conf.py +0 -0
  39. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/docs/source/module/config.rst +0 -0
  40. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/docs/source/module/playbook.rst +0 -0
  41. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/docs/source/module/routes/default.rst +0 -0
  42. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/docs/source/module/routes/index.rst +0 -0
  43. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/docs/source/module/routes/playbook.rst +0 -0
  44. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/docs/source/modules.rst +0 -0
  45. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/lso/app.py +0 -0
  46. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/lso/environment.py +0 -0
  47. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/lso/routes/__init__.py +0 -0
  48. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/lso/routes/default.py +0 -0
  49. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/setup.py +0 -0
  50. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/test/__init__.py +0 -0
  51. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/test/routes/__init__.py +0 -0
  52. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/test/routes/test_default.py +0 -0
  53. {orchestrator_lso-2.0.0 → orchestrator_lso-2.1.0}/test/test-playbook.yaml +0 -0
  54. /orchestrator_lso-2.0.0/test/test_ansible.py → /orchestrator_lso-2.1.0/test/test_playbook.py +0 -0
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 2.0.0
2
+ current_version = 2.1.0
3
3
  commit = False
4
4
  tag = False
5
5
  parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(rc(?P<build>\d+))?
@@ -11,7 +11,7 @@ COPY ./ansible-galaxy-requirements.yaml ./ansible-galaxy-requirements.yaml
11
11
  RUN apk add --update --no-cache gcc libc-dev libffi-dev openssh
12
12
 
13
13
  # Install the LSO python package, and additional requirements
14
- RUN pip install orchestrator-lso=="2.0.0"
14
+ RUN pip install orchestrator-lso=="2.1.0"
15
15
  RUN pip install -r requirements.txt
16
16
 
17
17
  # Install required Ansible Galaxy roles and collections
@@ -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,34 @@
1
+ Lightweight Service Orchestrator (LSO) Documentation
2
+ =====================================================
3
+
4
+ Introduction
5
+ ------------
6
+
7
+ The Lightweight Service Orchestrator (LSO) is a simple tool designed to run Ansible playbooks remotely.
8
+ It provides a straightforward way to send instructions, like inventory and variables, to Ansible through
9
+ a REST API, making automation easier and more flexible.
10
+
11
+ Why LSO?
12
+ --------
13
+
14
+ LSO was built to solve a common problem: running Ansible playbooks from a remote machine without setting
15
+ up a complicated system. Many tools, like AWX, are powerful but require complex setups, like Kubernetes,
16
+ and are tied to specific ecosystems.
17
+
18
+ We wanted a lightweight, easy-to-use solution that works without extra layers. That’s why we created LSO.
19
+
20
+ What LSO Does
21
+ -------------
22
+
23
+ LSO is a small FastAPI server that receives requests from remote services and uses `ansible-runner` to execute playbooks.
24
+
25
+ It:
26
+ - Accepts the playbook name, inventory details, and extra variables as input.
27
+ - Runs the playbook on Ansible using this information.
28
+ - Sends the results back, including the output and execution status.
29
+
30
+ .. toctree::
31
+ :maxdepth: 1
32
+ :caption: Contents:
33
+
34
+ modules
@@ -14,8 +14,6 @@ REQUEST_TIMEOUT_SEC=10
14
14
  # Celery configuration
15
15
  CELERY_BROKER_URL="redis://localhost:6379/0"
16
16
  CELERY_RESULT_BACKEND="redis://localhost:6379/0"
17
- CELERY_TIMEZONE="Europe/Amsterdam"
18
- CELERY_ENABLE_UTC=True
19
17
  CELERY_RESULT_EXPIRES=3600
20
18
  WORKER_QUEUE_NAME="lso-worker-queue"
21
19
 
@@ -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
@@ -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()
@@ -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
@@ -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.
@@ -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)
@@ -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.
@@ -0,0 +1,121 @@
1
+ # Copyright 2023-2024 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 defines tasks for executing Ansible playbooks asynchronously using Celery.
15
+
16
+ The primary task, `run_playbook_proc_task`, runs an Ansible playbook and sends a POST request with
17
+ the results to a specified callback URL.
18
+ """
19
+
20
+ import logging
21
+ import subprocess # noqa: S404
22
+ from enum import StrEnum
23
+ from typing import Any
24
+
25
+ import ansible_runner
26
+ import requests
27
+ from starlette import status
28
+
29
+ from lso.config import settings
30
+ from lso.worker import RUN_EXECUTABLE, RUN_PLAYBOOK, celery
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ class CallbackFailedError(Exception):
36
+ """Exception raised when a callback url can't be reached."""
37
+
38
+
39
+ class JobStatus(StrEnum):
40
+ """Enumeration of possible job statuses."""
41
+
42
+ SUCCESSFUL = "successful"
43
+ FAILED = "failed"
44
+
45
+
46
+ @celery.task(name=RUN_PLAYBOOK) # type: ignore[misc]
47
+ def run_playbook_proc_task(
48
+ job_id: str, playbook_path: str, extra_vars: dict[str, Any], inventory: dict[str, Any] | str, callback: str
49
+ ) -> None:
50
+ """Celery task to run a playbook.
51
+
52
+ :param str job_id: Identifier of the job being executed.
53
+ :param str playbook_path: Path to the playbook to be executed.
54
+ :param dict[str, Any] extra_vars: Extra variables to pass to the playbook.
55
+ :param dict[str, Any] | str inventory: Inventory to run the playbook against.
56
+ :param HttpUrl callback: Callback URL for status updates.
57
+ :return: None
58
+ """
59
+ msg = f"playbook_path: {playbook_path}, callback: {callback}"
60
+ logger.info(msg)
61
+ ansible_playbook_run = ansible_runner.run(playbook=playbook_path, inventory=inventory, extravars=extra_vars)
62
+
63
+ payload = {
64
+ "status": ansible_playbook_run.status,
65
+ "job_id": job_id,
66
+ "output": ansible_playbook_run.stdout.readlines(),
67
+ "return_code": int(ansible_playbook_run.rc),
68
+ }
69
+
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}"
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)
@@ -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
@@ -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
 
@@ -30,14 +30,14 @@ classifiers = [
30
30
  ]
31
31
  dependencies = [
32
32
  "ansible-runner==2.4.0",
33
- "ansible==10.6.0",
34
- "fastapi==0.115.5",
35
- "httpx==0.27.2",
36
- "uvicorn[standard]==0.32.0",
33
+ "ansible==10.7.0",
34
+ "fastapi==0.115.8",
35
+ "httpx==0.28.1",
36
+ "uvicorn[standard]==0.34.0",
37
37
  "requests==2.32.3",
38
- "pydantic-settings==2.5.2",
38
+ "pydantic-settings==2.7.1",
39
39
  "celery==5.4.0",
40
- "redis==5.2.0",
40
+ "redis==5.2.1",
41
41
  ]
42
42
 
43
43
  readme = "README.md"
@@ -68,3 +68,12 @@ def client() -> TestClient:
68
68
  @pytest.fixture(scope="session")
69
69
  def faker() -> Faker:
70
70
  return Faker(locale="en_GB")
71
+
72
+
73
+ @pytest.fixture
74
+ def temp_executable(tmp_path: Path) -> Path:
75
+ # Create a temporary executable that echoes a message.
76
+ exe_file = tmp_path / "test_executable.sh"
77
+ exe_file.write_text("#!/bin/sh\necho 'Executable Test'\n")
78
+ exe_file.chmod(0o755)
79
+ return exe_file
@@ -0,0 +1,123 @@
1
+ import uuid
2
+ from pathlib import Path
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import responses
6
+ from fastapi import status
7
+ from fastapi.testclient import TestClient
8
+
9
+ from lso.config import ExecutorType
10
+ from test.utils import temp_executable_env
11
+
12
+ TEST_CALLBACK_URL = "http://localhost/callback"
13
+
14
+
15
+ @responses.activate
16
+ def test_execute_endpoint_threadpool_success(client: TestClient, temp_executable: Path):
17
+ with temp_executable_env(ExecutorType.THREADPOOL) as exec_dir:
18
+ target_exe = exec_dir / temp_executable.name
19
+ target_exe.write_text(temp_executable.read_text())
20
+ target_exe.chmod(0o755)
21
+
22
+ # Simulate a successful callback.
23
+ responses.add(responses.POST, TEST_CALLBACK_URL, status=200)
24
+
25
+ params = {
26
+ "executable_name": temp_executable.name,
27
+ "args": [],
28
+ "callback": TEST_CALLBACK_URL,
29
+ }
30
+ with (
31
+ patch("lso.execute.run_executable_proc_task") as mock_run_executable_proc_task,
32
+ patch("lso.execute.get_thread_pool") as mock_get_thread_pool,
33
+ ):
34
+ mock_executor = MagicMock()
35
+ mock_get_thread_pool.return_value = mock_executor
36
+ rv = client.post("/api/execute/", json=params)
37
+
38
+ assert rv.status_code == status.HTTP_201_CREATED
39
+ response = rv.json()
40
+
41
+ assert isinstance(response, dict)
42
+ uuid.UUID(response["job_id"]) # Validate job_id format.
43
+ mock_executor.submit.assert_called_once()
44
+ assert mock_run_executable_proc_task.call_count == 0
45
+
46
+
47
+ @responses.activate
48
+ def test_execute_endpoint_worker_success(client: TestClient, temp_executable: Path):
49
+ with temp_executable_env(ExecutorType.WORKER) as exec_dir:
50
+ target_exe = exec_dir / temp_executable.name
51
+ target_exe.write_text(temp_executable.read_text())
52
+ target_exe.chmod(0o755)
53
+
54
+ # Simulate a successful callback.
55
+ responses.add(responses.POST, TEST_CALLBACK_URL, status=200)
56
+
57
+ params = {
58
+ "executable_name": temp_executable.name,
59
+ "args": [],
60
+ "callback": TEST_CALLBACK_URL,
61
+ }
62
+ with patch("lso.tasks.run_executable_proc_task.delay") as mock_celery_delay:
63
+ rv = client.post("/api/execute/", json=params)
64
+ assert rv.status_code == status.HTTP_201_CREATED
65
+ response = rv.json()
66
+
67
+ assert isinstance(response, dict)
68
+ uuid.UUID(response["job_id"]) # Validate job_id format.
69
+ mock_celery_delay.assert_called_once()
70
+
71
+
72
+ @responses.activate
73
+ def test_execute_endpoint_not_found(client: TestClient):
74
+ # Request with a non-existent executable should return 404.
75
+ params = {
76
+ "executable_name": "nonexistent.sh",
77
+ "args": [],
78
+ "callback": TEST_CALLBACK_URL,
79
+ }
80
+ response = client.post("/api/execute/", json=params)
81
+ assert response.status_code == status.HTTP_404_NOT_FOUND
82
+
83
+
84
+ @responses.activate
85
+ def test_execute_endpoint_not_executable(client: TestClient, tmp_path: Path):
86
+ # Create a file that exists but is not executable.
87
+ non_exe = tmp_path / "non_executable.sh"
88
+ non_exe.write_text("echo 'Hello'")
89
+ non_exe.chmod(0o644)
90
+ with temp_executable_env(ExecutorType.THREADPOOL) as exec_dir:
91
+ target_file = exec_dir / non_exe.name
92
+ target_file.write_text(non_exe.read_text())
93
+ target_file.chmod(0o644)
94
+ params = {
95
+ "executable_name": non_exe.name,
96
+ "args": [],
97
+ "callback": TEST_CALLBACK_URL,
98
+ }
99
+ response = client.post("/api/execute/", json=params)
100
+ assert response.status_code == status.HTTP_403_FORBIDDEN
101
+ assert response.json() == {"detail": f"Executable '{non_exe.name}' is not marked as executable."}
102
+
103
+
104
+ @responses.activate
105
+ def test_execute_endpoint_not_a_file(client: TestClient):
106
+ """
107
+ Test that if the provided executable path is a directory (exists but is not a file),
108
+ the endpoint returns a 404 with an appropriate error message.
109
+ """
110
+ with temp_executable_env(ExecutorType.THREADPOOL) as exec_dir:
111
+ # Create a directory within the temporary executables' environment.
112
+ target_dir = exec_dir / "not_a_file"
113
+ target_dir.mkdir()
114
+
115
+ params = {
116
+ "executable_name": "not_a_file",
117
+ "args": [],
118
+ "callback": TEST_CALLBACK_URL,
119
+ }
120
+
121
+ response = client.post("/api/execute/", json=params)
122
+ assert response.status_code == status.HTTP_404_NOT_FOUND
123
+ assert response.json() == {"detail": "Executable 'not_a_file' is not a valid file."}
@@ -12,7 +12,6 @@
12
12
  # limitations under the License.
13
13
  import re
14
14
  from collections.abc import Callable
15
- from contextlib import contextmanager
16
15
  from pathlib import Path
17
16
  from unittest.mock import MagicMock, patch
18
17
 
@@ -21,22 +20,13 @@ import responses
21
20
  from fastapi import status
22
21
  from fastapi.testclient import TestClient
23
22
 
24
- from lso.config import ExecutorType, settings
23
+ from lso.config import ExecutorType
25
24
  from lso.playbook import get_playbook_path
25
+ from test.utils import temporary_executor
26
26
 
27
27
  TEST_CALLBACK_URL = "https://fqdn.abc.xyz/api/resume"
28
28
 
29
29
 
30
- @contextmanager
31
- def temporary_executor(executor_type: ExecutorType):
32
- original_executor = settings.EXECUTOR
33
- settings.EXECUTOR = executor_type
34
- try:
35
- yield
36
- finally:
37
- settings.EXECUTOR = original_executor
38
-
39
-
40
30
  @responses.activate
41
31
  def test_playbook_endpoint_dict_inventory_success(client: TestClient, mocked_ansible_runner_run: Callable) -> None:
42
32
  responses.post(url=TEST_CALLBACK_URL, status=status.HTTP_200_OK)
@@ -122,7 +112,7 @@ def test_playbook_endpoint_invalid_hosts(client: TestClient, mocked_ansible_runn
122
112
  response = rv.json()
123
113
 
124
114
  assert isinstance(response, dict)
125
- assert 'Invalid "hosts" entry for "all" group' in re.sub("\n", " ", "".join(response["detail"]))
115
+ assert 'Invalid "hosts" entry for "all" group' in re.sub(r"\n", " ", "".join(response["detail"]))
126
116
  responses.assert_call_count(TEST_CALLBACK_URL, 0)
127
117
 
128
118
 
@@ -130,6 +120,7 @@ def test_playbook_endpoint_invalid_hosts(client: TestClient, mocked_ansible_runn
130
120
  def test_run_playbook_threadpool_execution(client: TestClient, mocked_ansible_runner_run: Callable) -> None:
131
121
  """Test that the playbook runs with ThreadPoolExecutor when ExecutorType is THREADPOOL."""
132
122
  with temporary_executor(ExecutorType.THREADPOOL):
123
+ # Simulate a successful callback.
133
124
  responses.post(url=TEST_CALLBACK_URL, status=status.HTTP_200_OK)
134
125
 
135
126
  params = {
@@ -0,0 +1,27 @@
1
+ import uuid
2
+ from pathlib import Path
3
+
4
+ import responses
5
+
6
+ from lso.config import ExecutorType
7
+ from lso.execute import run_executable
8
+ from test.utils import temp_executable_env
9
+
10
+ TEST_CALLBACK_URL = "http://localhost/callback"
11
+
12
+
13
+ @responses.activate
14
+ def test_run_executable_threadpool(temp_executable: Path):
15
+ """
16
+ Test direct invocation of run_executable using the ThreadPool executor.
17
+ """
18
+ with temp_executable_env(ExecutorType.THREADPOOL) as exec_dir:
19
+ target_exe = exec_dir / temp_executable.name
20
+ target_exe.write_text(temp_executable.read_text())
21
+ target_exe.chmod(0o755)
22
+
23
+ # Simulate a successful callback.
24
+ responses.add(responses.POST, TEST_CALLBACK_URL, status=200)
25
+
26
+ job_id = run_executable(target_exe, ["--version"], TEST_CALLBACK_URL)
27
+ uuid.UUID(str(job_id)) # Validate job_id format.
@@ -0,0 +1,27 @@
1
+ import tempfile
2
+ from contextlib import contextmanager
3
+ from pathlib import Path
4
+
5
+ from lso.config import ExecutorType, settings
6
+
7
+
8
+ @contextmanager
9
+ def temporary_executor(executor_type: ExecutorType):
10
+ original_executor = settings.EXECUTOR
11
+ settings.EXECUTOR = executor_type
12
+ try:
13
+ yield
14
+ finally:
15
+ settings.EXECUTOR = original_executor
16
+
17
+
18
+ @contextmanager
19
+ def temp_executable_env(executor_type: ExecutorType) -> Path:
20
+ """Sets up a temporary executables environment and adjusts the executor type."""
21
+ original_executable_dir = settings.EXECUTABLES_ROOT_DIR
22
+ try:
23
+ with temporary_executor(executor_type), tempfile.TemporaryDirectory() as exec_dir:
24
+ settings.EXECUTABLES_ROOT_DIR = exec_dir
25
+ yield Path(exec_dir)
26
+ finally:
27
+ settings.EXECUTABLES_ROOT_DIR = original_executable_dir
@@ -1,10 +0,0 @@
1
- Lightweight Service Orchestrator
2
- ================================
3
-
4
- Code documentation for LSO (Lightweight Service Orchestrator).
5
-
6
- .. toctree::
7
- :maxdepth: 1
8
- :caption: Contents:
9
-
10
- modules
@@ -1,64 +0,0 @@
1
- # Copyright 2023-2024 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 defines tasks for executing Ansible playbooks asynchronously using Celery.
15
-
16
- The primary task, `run_playbook_proc_task`, runs an Ansible playbook and sends a POST request with
17
- the results to a specified callback URL.
18
- """
19
-
20
- import logging
21
- from typing import Any
22
-
23
- import ansible_runner
24
- import requests
25
- from starlette import status
26
-
27
- from lso.config import settings
28
- from lso.worker import RUN_PLAYBOOK, celery
29
-
30
- logger = logging.getLogger(__name__)
31
-
32
-
33
- class CallbackFailedError(Exception):
34
- """Exception raised when a callback url can't be reached."""
35
-
36
-
37
- @celery.task(name=RUN_PLAYBOOK) # type: ignore[misc]
38
- def run_playbook_proc_task(
39
- job_id: str, playbook_path: str, extra_vars: dict[str, Any], inventory: dict[str, Any] | str, callback: str
40
- ) -> None:
41
- """Celery task to run a playbook.
42
-
43
- :param str job_id: Identifier of the job being executed.
44
- :param str playbook_path: Path to the playbook to be executed.
45
- :param dict[str, Any] extra_vars: Extra variables to pass to the playbook.
46
- :param dict[str, Any] | str inventory: Inventory to run the playbook against.
47
- :param HttpUrl callback: Callback URL for status updates.
48
- :return: None
49
- """
50
- msg = f"playbook_path: {playbook_path}, callback: {callback}"
51
- logger.info(msg)
52
- ansible_playbook_run = ansible_runner.run(playbook=playbook_path, inventory=inventory, extravars=extra_vars)
53
-
54
- payload = {
55
- "status": ansible_playbook_run.status,
56
- "job_id": job_id,
57
- "output": ansible_playbook_run.stdout.readlines(),
58
- "return_code": int(ansible_playbook_run.rc),
59
- }
60
-
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}"
64
- raise CallbackFailedError(msg)