orchestrator-lso 2.3.1__tar.gz → 2.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orchestrator-lso
3
- Version: 2.3.1
3
+ Version: 2.4.1
4
4
  Summary: LSO, an API for remotely running Ansible playbooks.
5
5
  Author: GÉANT Orchestration and Automation Team
6
6
  Author-email: GÉANT Orchestration and Automation Team <goat@geant.org>
@@ -13,7 +13,7 @@
13
13
 
14
14
  """LSO, an API for remotely running Ansible playbooks."""
15
15
 
16
- __version__ = "2.3.1"
16
+ __version__ = "2.4.1"
17
17
 
18
18
  import logging
19
19
 
@@ -14,8 +14,8 @@
14
14
  """Module for handling the execution of arbitrary executables."""
15
15
 
16
16
  import subprocess # noqa: S404
17
- import uuid
18
17
  from pathlib import Path
18
+ from uuid import UUID, uuid4
19
19
 
20
20
  from pydantic import HttpUrl
21
21
 
@@ -30,12 +30,12 @@ def get_executable_path(executable_name: Path) -> Path:
30
30
  return Path(settings.EXECUTABLES_ROOT_DIR) / executable_name
31
31
 
32
32
 
33
- def run_executable_async(executable_path: Path, args: list[str], callback: HttpUrl | None) -> uuid.UUID:
33
+ def run_executable_async(executable_path: Path, args: list[str], callback: HttpUrl | None) -> UUID:
34
34
  """Dispatch the task for executing an arbitrary executable remotely.
35
35
 
36
36
  Uses a ThreadPoolExecutor (for local execution) or a Celery worker (for distributed tasks).
37
37
  """
38
- job_id = uuid.uuid4()
38
+ job_id = uuid4()
39
39
  callback_url = str(callback) if callback else None
40
40
  if settings.EXECUTOR == ExecutorType.THREADPOOL:
41
41
  executor = get_thread_pool()
@@ -13,9 +13,9 @@
13
13
 
14
14
  """Module that gathers common API responses and data models."""
15
15
 
16
- import uuid
17
16
  from pathlib import Path
18
17
  from typing import Any
18
+ from uuid import UUID, uuid4
19
19
 
20
20
  from pydantic import HttpUrl
21
21
 
@@ -33,8 +33,11 @@ def run_playbook(
33
33
  playbook_path: Path,
34
34
  extra_vars: dict[str, Any],
35
35
  inventory: dict[str, Any] | str,
36
- callback: HttpUrl,
37
- ) -> uuid.UUID:
36
+ callback: HttpUrl | None,
37
+ progress: HttpUrl | None,
38
+ *,
39
+ progress_is_incremental: bool,
40
+ ) -> UUID:
38
41
  """Run an Ansible playbook against a specified inventory.
39
42
 
40
43
  :param Path playbook_path: Playbook to be executed.
@@ -42,19 +45,40 @@ def run_playbook(
42
45
  :param dict[str, Any] | str inventory: The inventory that the playbook is executed against.
43
46
  :param HttpUrl callback: Callback URL where the playbook should send a status update when execution is completed.
44
47
  This is used for workflow-orchestrator to continue with the next step in a workflow.
45
- :return: Result of playbook launch, this could either be successful or unsuccessful.
46
- :rtype: :class:`fastapi.responses.JSONResponse`
48
+ :return UUID: Job ID of the launched playbook.
47
49
  """
48
- job_id = uuid.uuid4()
50
+ job_id = uuid4()
51
+ callback_str = None
52
+ progress_str = None
53
+ if callback:
54
+ callback_str = str(callback)
55
+ if progress:
56
+ progress_str = str(progress)
57
+
49
58
  if settings.EXECUTOR == ExecutorType.THREADPOOL:
50
59
  executor = get_thread_pool()
51
60
  executor_handle = executor.submit(
52
- run_playbook_proc_task, str(job_id), str(playbook_path), extra_vars, inventory, str(callback)
61
+ run_playbook_proc_task,
62
+ str(job_id),
63
+ str(playbook_path),
64
+ extra_vars,
65
+ inventory,
66
+ callback_str,
67
+ progress_str,
68
+ progress_is_incremental=progress_is_incremental,
53
69
  )
54
70
  if settings.TESTING:
55
71
  executor_handle.result()
56
72
 
57
73
  elif settings.EXECUTOR == ExecutorType.WORKER:
58
- run_playbook_proc_task.delay(str(job_id), str(playbook_path), extra_vars, inventory, str(callback))
74
+ run_playbook_proc_task.delay(
75
+ str(job_id),
76
+ str(playbook_path),
77
+ extra_vars,
78
+ inventory,
79
+ callback_str,
80
+ progress_str,
81
+ progress_is_incremental=progress_is_incremental,
82
+ )
59
83
 
60
84
  return job_id
@@ -14,9 +14,9 @@
14
14
  """FastAPI route for running arbitrary executables."""
15
15
 
16
16
  import asyncio
17
- import uuid
18
17
  from pathlib import Path
19
18
  from typing import Annotated
19
+ from uuid import uuid4
20
20
 
21
21
  from fastapi import APIRouter, HTTPException, status
22
22
  from pydantic import AfterValidator, BaseModel, HttpUrl
@@ -61,6 +61,6 @@ async def run_executable_endpoint(params: ExecutableRunParams) -> ExecutableRunR
61
61
  job_id = run_executable_async(params.executable_name, params.args, params.callback)
62
62
  return ExecutableRunResponse(job_id=job_id)
63
63
 
64
- job_id = uuid.uuid4()
64
+ job_id = uuid4()
65
65
  result = await asyncio.to_thread(run_executable_sync, str(params.executable_name), params.args)
66
66
  return ExecutableRunResponse(job_id=job_id, result=result)
@@ -15,11 +15,11 @@
15
15
 
16
16
  import json
17
17
  import tempfile
18
- import uuid
19
18
  from contextlib import redirect_stderr
20
19
  from io import StringIO
21
20
  from pathlib import Path
22
21
  from typing import Annotated, Any
22
+ from uuid import UUID
23
23
 
24
24
  import ansible_runner
25
25
  from ansible.inventory.manager import InventoryManager
@@ -44,7 +44,7 @@ def _inventory_validator(inventory: dict[str, Any] | str) -> dict[str, Any] | st
44
44
  """
45
45
  if not ansible_runner.utils.isinventory(inventory):
46
46
  detail = "Invalid inventory provided. Should be a string, or JSON object."
47
- raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
47
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=detail)
48
48
 
49
49
  loader = DataLoader()
50
50
  output = StringIO()
@@ -58,7 +58,7 @@ def _inventory_validator(inventory: dict[str, Any] | str) -> dict[str, Any] | st
58
58
  output.seek(0)
59
59
  error_messages = output.readlines()
60
60
  if error_messages:
61
- raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=error_messages)
61
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=error_messages)
62
62
 
63
63
  return inventory
64
64
 
@@ -79,7 +79,7 @@ PlaybookName = Annotated[Path, AfterValidator(_playbook_path_validator)]
79
79
  class PlaybookRunResponse(BaseModel):
80
80
  """PlaybookRunResponse domain model schema."""
81
81
 
82
- job_id: uuid.UUID
82
+ job_id: UUID
83
83
 
84
84
 
85
85
  class PlaybookRunParams(BaseModel):
@@ -89,7 +89,11 @@ class PlaybookRunParams(BaseModel):
89
89
  #: configuration option ``ANSIBLE_PLAYBOOKS_ROOT_DIR``.
90
90
  playbook_name: PlaybookName
91
91
  #: The address where LSO should call back to upon completion.
92
- callback: HttpUrl
92
+ callback: HttpUrl | None = None
93
+ #: Optionally, the address where LSO should send progress updates as the playbook executes.
94
+ progress: HttpUrl | None = None
95
+ #: Optionally, whether progress updates should be incremental or not.
96
+ progress_is_incremental: bool = True
93
97
  #: The inventory to run the playbook against. This inventory can also include any host vars, if needed. When
94
98
  #: including host vars, it should be a dictionary. Can be a simple string containing hostnames when no host vars are
95
99
  #: needed. In the latter case, multiple hosts should be separated with a ``\n`` newline character only.
@@ -114,6 +118,8 @@ def run_playbook_endpoint(params: PlaybookRunParams) -> PlaybookRunResponse:
114
118
  extra_vars=params.extra_vars,
115
119
  inventory=params.inventory,
116
120
  callback=params.callback,
121
+ progress=params.progress,
122
+ progress_is_incremental=params.progress_is_incremental,
117
123
  )
118
124
 
119
125
  return PlaybookRunResponse(job_id=job_id)
@@ -13,8 +13,8 @@
13
13
 
14
14
  """Module for defining the schema for running arbitrary executables."""
15
15
 
16
- import uuid
17
16
  from enum import StrEnum
17
+ from uuid import UUID
18
18
 
19
19
  from pydantic import BaseModel, model_validator
20
20
 
@@ -46,5 +46,5 @@ class ExecutionResult(BaseModel):
46
46
  class ExecutableRunResponse(BaseModel):
47
47
  """Response for running an arbitrary executable."""
48
48
 
49
- job_id: uuid.UUID
49
+ job_id: UUID
50
50
  result: ExecutionResult | None = None
@@ -18,11 +18,12 @@ the results to a specified callback URL.
18
18
  """
19
19
 
20
20
  import logging
21
+ from collections.abc import Callable
21
22
  from typing import Any
22
23
  from uuid import UUID
23
24
 
24
- import ansible_runner
25
25
  import requests
26
+ from ansible_runner import Runner, run
26
27
  from starlette import status
27
28
 
28
29
  from lso.config import settings
@@ -36,9 +37,72 @@ class CallbackFailedError(Exception):
36
37
  """Exception raised when a callback url can't be reached."""
37
38
 
38
39
 
40
+ def playbook_event_handler_factory(
41
+ progress: str | None, *, progress_is_incremental: bool
42
+ ) -> Callable[[dict], bool] | None:
43
+ """Create an event handler for Ansible playbook runs.
44
+
45
+ This is used to send incremental progress updates to the external system that called for this playbook to be run.
46
+
47
+ :param str progress: The progress URL where the external system expects to receive updates.
48
+ :param bool progress_is_incremental: Whether progress updates are sent incrementally, or only contain the latest
49
+ event data.
50
+ """
51
+ events_stdout = []
52
+
53
+ def _playbook_event_handler(event: dict) -> bool:
54
+ if progress_is_incremental:
55
+ emit_body = event["stdout"].strip()
56
+ else:
57
+ events_stdout.append(event["stdout"].strip())
58
+ emit_body = events_stdout
59
+
60
+ requests.post(str(progress), json={"progress": emit_body}, timeout=settings.REQUEST_TIMEOUT_SEC)
61
+ return True
62
+
63
+ if progress:
64
+ return _playbook_event_handler
65
+ return None
66
+
67
+
68
+ def playbook_finished_handler_factory(callback: str | None, job_id: str) -> Callable[[Runner], None] | None:
69
+ """Create an event handler for finished Ansible playbook runs.
70
+
71
+ Once Ansible runner is finished, it will call the handler method created by this factory before teardown.
72
+
73
+ :param str callback: The callback URL that ansible runner should report to.
74
+ :param str job_id: The job ID of this playbook run, used for reporting.
75
+ :return Callable: A handler method that sends one request to the callback URL.
76
+ """
77
+
78
+ def _playbook_finished_handler(runner: Runner) -> None:
79
+ payload = {
80
+ "status": runner.status,
81
+ "job_id": job_id,
82
+ "output": runner.stdout.readlines(),
83
+ "return_code": int(runner.rc),
84
+ }
85
+
86
+ response = requests.post(str(callback), json=payload, timeout=settings.REQUEST_TIMEOUT_SEC)
87
+ if not (status.HTTP_200_OK <= response.status_code < status.HTTP_300_MULTIPLE_CHOICES):
88
+ msg = f"Callback failed: {response.text}, url: {callback}"
89
+ raise CallbackFailedError(msg)
90
+
91
+ if callback:
92
+ return _playbook_finished_handler
93
+ return None
94
+
95
+
39
96
  @celery.task(name=RUN_PLAYBOOK) # type: ignore[misc]
40
97
  def run_playbook_proc_task(
41
- job_id: str, playbook_path: str, extra_vars: dict[str, Any], inventory: dict[str, Any] | str, callback: str
98
+ job_id: str,
99
+ playbook_path: str,
100
+ extra_vars: dict[str, Any],
101
+ inventory: dict[str, Any] | str,
102
+ callback: str | None,
103
+ progress: str | None,
104
+ *,
105
+ progress_is_incremental: bool,
42
106
  ) -> None:
43
107
  """Celery task to run a playbook.
44
108
 
@@ -46,24 +110,20 @@ def run_playbook_proc_task(
46
110
  :param str playbook_path: Path to the playbook to be executed.
47
111
  :param dict[str, Any] extra_vars: Extra variables to pass to the playbook.
48
112
  :param dict[str, Any] | str inventory: Inventory to run the playbook against.
49
- :param HttpUrl callback: Callback URL for status updates.
113
+ :param str callback: Callback URL for status updates.
114
+ :param str progress: URL for sending progress updates.
115
+ :param bool progress_is_incremental: Whether progress updates include all past progress.
50
116
  :return: None
51
117
  """
52
118
  msg = f"playbook_path: {playbook_path}, callback: {callback}"
53
119
  logger.info(msg)
54
- ansible_playbook_run = ansible_runner.run(playbook=playbook_path, inventory=inventory, extravars=extra_vars)
55
-
56
- payload = {
57
- "status": ansible_playbook_run.status,
58
- "job_id": job_id,
59
- "output": ansible_playbook_run.stdout.readlines(),
60
- "return_code": int(ansible_playbook_run.rc),
61
- }
62
-
63
- response = requests.post(str(callback), json=payload, timeout=settings.REQUEST_TIMEOUT_SEC)
64
- if not (status.HTTP_200_OK <= response.status_code < status.HTTP_300_MULTIPLE_CHOICES):
65
- msg = f"Callback failed: {response.text}, url: {callback}"
66
- raise CallbackFailedError(msg)
120
+ run(
121
+ playbook=playbook_path,
122
+ inventory=inventory,
123
+ extravars=extra_vars,
124
+ event_handler=playbook_event_handler_factory(progress, progress_is_incremental=progress_is_incremental),
125
+ finished_callback=playbook_finished_handler_factory(callback, job_id),
126
+ )
67
127
 
68
128
 
69
129
  @celery.task(name=RUN_EXECUTABLE) # type: ignore[misc]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "orchestrator-lso"
3
- version = "2.3.1"
3
+ version = "2.4.1"
4
4
  description = "LSO, an API for remotely running Ansible playbooks."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -120,8 +120,9 @@ ignore = [
120
120
  "D203",
121
121
  "D213",
122
122
  "N805",
123
- "PLR0913",
124
123
  "PLR0904",
124
+ "PLR0913",
125
+ "PLR0917",
125
126
  "PLW1514",
126
127
  "S104"
127
128
  ]