diff --git a/gso/services/lso_client.py b/gso/services/lso_client.py index 77c2264798a009c79fb5a28dcd15a317e17b87c5..cae5dd9c28d55d3b166f7250513ad7a5d7946cba 100644 --- a/gso/services/lso_client.py +++ b/gso/services/lso_client.py @@ -5,14 +5,14 @@ import json import logging -from typing import Any +from typing import Any, Literal, TypedDict import requests from orchestrator import step from orchestrator.config.assignee import Assignee from orchestrator.types import State from orchestrator.utils.errors import ProcessFailureError -from orchestrator.workflow import Step, StepList, begin, callback_step, inputstep +from orchestrator.workflow import Step, StepList, begin, callback_step, conditional, inputstep from pydantic import ConfigDict from pydantic_forms.core import FormPage from pydantic_forms.types import FormGenerator @@ -23,6 +23,18 @@ from gso import settings logger = logging.getLogger(__name__) +class _LSOState(TypedDict): # noqa: PYI049 + """An expanded state that must contain at least the required keys for the execution of an Ansible playbook.""" + + playbook_name: str + extra_vars: dict[str, Any] + inventory: dict[Literal["all"], dict[Literal["hosts"], dict[str, Any] | None]] + __extra_values__: Any # This is feature unavailable in python 3.12 + + +LSOState = State # FIXME: Use the above definition when python3.13 is released + + def _send_request(parameters: dict, callback_route: str) -> None: """Send a request to :term:`LSO`. The callback address is derived using the process ID provided. @@ -48,11 +60,9 @@ def _send_request(parameters: dict, callback_route: str) -> None: request.raise_for_status() -def execute_playbook( - playbook_name: str, - callback_route: str, - inventory: dict[str, Any] | str, - extra_vars: dict[str, Any], +@step("Execute Ansible playbook") +def _execute_playbook( + playbook_name: str, callback_route: str, inventory: dict[str, Any], extra_vars: dict[str, Any] ) -> None: """Execute a playbook remotely through the provisioning proxy. @@ -71,7 +81,8 @@ def execute_playbook( }, "host2.local": { "key": "value" - } + }, + "host3.local": None } } } @@ -141,7 +152,38 @@ def _show_results(state: State) -> FormGenerator: @step("Clean up keys from state") def _clean_state() -> State: - return {"__remove_keys": ["run_results", "lso_result_title", "lso_result_extra_label", "callback_result"]} + return { + "__remove_keys": [ + "run_results", + "lso_result_title", + "lso_result_extra_label", + "callback_result", + "playbook_name", + "callback_route", + "inventory", + "extra_vars", + ] + } + + +def _inventory_is_set(state: State) -> bool: + """Validate whether the passed Ansible inventory is empty. + + If the inventory is empty, which can happen in select cases, there should be no playbook run. This conditional will + prevent from calling out to :term:`LSO` with an empty playbook, which would cause the Ansible runner process to + hang. This in turn will result in a workflow step that is never called back to. + """ + if "inventory" not in state: + msg = "Missing Ansible inventory for playbook." + raise ProcessFailureError(msg, details="Key 'inventory' not found in state.") + if "all" not in state["inventory"] or "hosts" not in state["inventory"]["all"]: + msg = "Malformed Ansible inventory found in state." + raise ProcessFailureError(msg, details="Ansible inventory must be in YAML form, not string.") + + return state["inventory"]["all"]["hosts"] + + +_inventory_is_not_empty = conditional(_inventory_is_set) def lso_interaction(provisioning_step: Step) -> StepList: @@ -162,9 +204,15 @@ def lso_interaction(provisioning_step: Step) -> StepList: """ return ( begin - >> callback_step(name=provisioning_step.name, action_step=provisioning_step, validate_step=_evaluate_results) - >> step("Inject result title")(lambda: {"lso_result_title": provisioning_step.name}) - >> _show_results + >> provisioning_step + >> _inventory_is_not_empty( + begin + >> callback_step( + name="Running Ansible playbook", action_step=_execute_playbook, validate_step=_evaluate_results + ) + >> step("Inject result title")(lambda: {"lso_result_title": provisioning_step.name}) + >> _show_results + ) >> _clean_state ) @@ -187,9 +235,15 @@ def indifferent_lso_interaction(provisioning_step: Step) -> StepList: """ return ( begin - >> callback_step(name=provisioning_step.name, action_step=provisioning_step, validate_step=_ignore_results) - >> step("Inject result title")(lambda: {"lso_result_title": provisioning_step.name}) - >> _show_results + >> provisioning_step + >> _inventory_is_not_empty( + begin + >> callback_step( + name="Running Ansible playbook", action_step=_execute_playbook, validate_step=_ignore_results + ) + >> step("Inject result title")(lambda: {"lso_result_title": provisioning_step.name}) + >> _show_results + ) >> _clean_state ) @@ -207,6 +261,9 @@ def anonymous_lso_interaction(provisioning_step: Step, validation_step: Step = _ """ return ( begin - >> callback_step(name=provisioning_step.name, action_step=provisioning_step, validate_step=validation_step) + >> provisioning_step + >> _inventory_is_not_empty( + callback_step(name="Running Ansible playbook", action_step=_execute_playbook, validate_step=validation_step) + ) >> _clean_state )