orchestrator-lso 2.0.1__tar.gz → 2.1.1__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.
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/.bumpversion.cfg +1 -1
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/.github/workflows/run-unit-tests.yaml +0 -6
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/Dockerfile.example +1 -1
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/PKG-INFO +11 -10
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/env.example +0 -2
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/lso/__init__.py +3 -1
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/lso/config.py +2 -2
- orchestrator_lso-2.1.1/lso/execute.py +31 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/lso/playbook.py +2 -18
- orchestrator_lso-2.1.1/lso/routes/execute.py +51 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/lso/routes/playbook.py +1 -1
- orchestrator_lso-2.1.1/lso/tasks.py +121 -0
- orchestrator_lso-2.1.1/lso/utils.py +16 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/lso/worker.py +3 -5
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/pyproject.toml +8 -8
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/test/conftest.py +9 -0
- orchestrator_lso-2.1.1/test/routes/test_execute.py +123 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/test/routes/test_playbook.py +3 -12
- orchestrator_lso-2.1.1/test/test_execute.py +27 -0
- orchestrator_lso-2.1.1/test/utils.py +27 -0
- orchestrator_lso-2.0.1/lso/tasks.py +0 -64
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/.github/dependabot.yml +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/.github/styles/config/vocabularies/Sphinx/accept.txt +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/.github/styles/config/vocabularies/jargon/accept.txt +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/.github/workflows/publish-package.yaml +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/.github/workflows/run-linting-tests.yaml +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/.github/workflows/sphinx.yaml +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/.gitignore +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/.vale.ini +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/LICENSE +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/README.md +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/docs/LSO_banner.jpg +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/docs/Makefile +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/docs/source/_static/custom.css +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/docs/source/_static/lso_logo.png +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/docs/source/conf.py +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/docs/source/index.rst +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/docs/source/module/config.rst +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/docs/source/module/playbook.rst +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/docs/source/module/routes/default.rst +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/docs/source/module/routes/index.rst +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/docs/source/module/routes/playbook.rst +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/docs/source/modules.rst +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/lso/app.py +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/lso/environment.py +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/lso/routes/__init__.py +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/lso/routes/default.py +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/setup.py +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/test/__init__.py +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/test/routes/__init__.py +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/test/routes/test_default.py +0 -0
- {orchestrator_lso-2.0.1 → orchestrator_lso-2.1.1}/test/test-playbook.yaml +0 -0
- /orchestrator_lso-2.0.1/test/test_ansible.py → /orchestrator_lso-2.1.1/test/test_playbook.py +0 -0
|
@@ -26,9 +26,3 @@ jobs:
|
|
|
26
26
|
FLIT_ROOT_INSTALL: 1
|
|
27
27
|
- name: Run Unit tests
|
|
28
28
|
run: pytest --cov-branch --cov=lso --cov-report=xml
|
|
29
|
-
- name: "Upload coverage to Codecov"
|
|
30
|
-
uses: codecov/codecov-action@v3
|
|
31
|
-
with:
|
|
32
|
-
token: ${{ secrets.CODECOV_TOKEN }}
|
|
33
|
-
fail_ci_if_error: true
|
|
34
|
-
files: ./coverage.xml
|
|
@@ -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.
|
|
14
|
+
RUN pip install orchestrator-lso=="2.1.1"
|
|
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.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: orchestrator-lso
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.1
|
|
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
|
-
|
|
29
|
-
Requires-Dist: ansible==
|
|
30
|
-
Requires-Dist:
|
|
31
|
-
Requires-Dist:
|
|
32
|
-
Requires-Dist:
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: ansible-runner==2.4.1
|
|
30
|
+
Requires-Dist: ansible==10.7.0
|
|
31
|
+
Requires-Dist: fastapi==0.115.12
|
|
32
|
+
Requires-Dist: httpx==0.28.1
|
|
33
|
+
Requires-Dist: uvicorn[standard]==0.34.2
|
|
33
34
|
Requires-Dist: requests==2.32.3
|
|
34
|
-
Requires-Dist: pydantic-settings==2.
|
|
35
|
-
Requires-Dist: celery==5.
|
|
36
|
-
Requires-Dist: redis==5.2.
|
|
35
|
+
Requires-Dist: pydantic-settings==2.9.1
|
|
36
|
+
Requires-Dist: celery==5.5.2
|
|
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"
|
|
@@ -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.
|
|
16
|
+
__version__ = "2.1.1"
|
|
17
17
|
|
|
18
18
|
import logging
|
|
19
19
|
|
|
@@ -22,6 +22,7 @@ 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
|
|
|
27
28
|
logger = logging.getLogger(__name__)
|
|
@@ -37,6 +38,7 @@ def create_app() -> FastAPI:
|
|
|
37
38
|
|
|
38
39
|
app.include_router(default_router, prefix="/api")
|
|
39
40
|
app.include_router(playbook_router, prefix="/api/playbook")
|
|
41
|
+
app.include_router(executable_router, prefix="/api/execute")
|
|
40
42
|
|
|
41
43
|
environment.setup_logging()
|
|
42
44
|
|
|
@@ -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:
|
|
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
|
|
|
@@ -29,15 +29,15 @@ classifiers = [
|
|
|
29
29
|
"Programming Language :: Python :: 3.12",
|
|
30
30
|
]
|
|
31
31
|
dependencies = [
|
|
32
|
-
"ansible-runner==2.4.
|
|
33
|
-
"ansible==10.
|
|
34
|
-
"fastapi==0.115.
|
|
35
|
-
"httpx==0.28.
|
|
36
|
-
"uvicorn[standard]==0.
|
|
32
|
+
"ansible-runner==2.4.1",
|
|
33
|
+
"ansible==10.7.0",
|
|
34
|
+
"fastapi==0.115.12",
|
|
35
|
+
"httpx==0.28.1",
|
|
36
|
+
"uvicorn[standard]==0.34.2",
|
|
37
37
|
"requests==2.32.3",
|
|
38
|
-
"pydantic-settings==2.
|
|
39
|
-
"celery==5.
|
|
40
|
-
"redis==5.2.
|
|
38
|
+
"pydantic-settings==2.9.1",
|
|
39
|
+
"celery==5.5.2",
|
|
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
|
|
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)
|
|
@@ -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,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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/orchestrator_lso-2.0.1/test/test_ansible.py → /orchestrator_lso-2.1.1/test/test_playbook.py
RENAMED
|
File without changes
|