diff --git a/ansible_rulebook/action/__init__.py b/ansible_rulebook/action/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_rulebook/action/control.py b/ansible_rulebook/action/control.py new file mode 100644 index 000000000..8e701a4fa --- /dev/null +++ b/ansible_rulebook/action/control.py @@ -0,0 +1,54 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from dataclasses import dataclass +from typing import List + + +@dataclass(frozen=True) +class Control: + """Control information when running an action + + Attributes: + queue: asyncio.Queue + This is the queue on which we would be sending action status + periodically when the action is running + inventory: str + This is the inventory information from the command line + It currently is the data that is read from a file, in the future + it could be a directory or an inventory name from the controller + hosts: list[str] + The list of servers passed into ansible-playbook or controller + variables: dict + The variables passed in from the command line plus the matching event + data with event or events key. + project_data_file: str + This is the directory where the collection data is sent from the + AAP server over the websocket is untarred to. The collection could + contain the playbook that is used in the run_playbook action. + """ + + __slots__ = [ + "queue", + "inventory", + "hosts", + "variables", + "project_data_file", + ] + queue: asyncio.Queue + inventory: str + hosts: List[str] + variables: dict + project_data_file: str diff --git a/ansible_rulebook/action/debug.py b/ansible_rulebook/action/debug.py new file mode 100644 index 000000000..ba2b0d4d7 --- /dev/null +++ b/ansible_rulebook/action/debug.py @@ -0,0 +1,83 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import sys +from dataclasses import asdict +from pprint import pprint + +import dpath +from drools import ruleset as lang + +from ansible_rulebook.util import get_horizontal_rule + +from .control import Control +from .helper import Helper +from .metadata import Metadata + +logger = logging.getLogger(__name__) + + +class Debug: + """The debug action tries to mimic the ansible debug task with optional + msg: Prints a message + var: Prints a variable + default: print the metadata, control information and facts from the + rule engine + At the end we send back the action status + """ + + def __init__(self, metadata: Metadata, control: Control, **action_args): + self.helper = Helper(metadata, control, "debug") + self.action_args = action_args + + async def __call__(self): + if "msg" in self.action_args: + messages = self.action_args.get("msg") + if not isinstance(messages, list): + messages = [messages] + for msg in messages: + print(msg) + elif "var" in self.action_args: + key = self.action_args.get("var") + try: + print( + dpath.get( + self.helper.control.variables, key, separator="." + ) + ) + except KeyError: + logger.error("Key %s not found in variable pool", key) + raise + else: + print(get_horizontal_rule("=")) + print("kwargs:") + args = asdict(self.helper.metadata) + project_data_file = self.helper.control.project_data_file + args.update( + { + "inventory": self.helper.control.inventory, + "hosts": self.helper.control.hosts, + "variables": self.helper.control.variables, + "project_data_file": project_data_file, + } + ) + pprint(args) + print(get_horizontal_rule("=")) + print("facts:") + pprint(lang.get_facts(self.helper.metadata.rule_set)) + print(get_horizontal_rule("=")) + + sys.stdout.flush() + await self.helper.send_default_status() diff --git a/ansible_rulebook/action/helper.py b/ansible_rulebook/action/helper.py new file mode 100644 index 000000000..9c2d9902e --- /dev/null +++ b/ansible_rulebook/action/helper.py @@ -0,0 +1,143 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import uuid +from typing import Dict + +from ansible_rulebook.conf import settings +from ansible_rulebook.event_filter.insert_meta_info import main as insert_meta +from ansible_rulebook.util import run_at + +from .control import Control +from .metadata import Metadata + +KEY_EDA_VARS = "ansible_eda" +INTERNAL_ACTION_STATUS = "successful" + + +class Helper: + """ + Helper class stores the metadata, the control attributes and has + methods to send data to the Queue. + + Attributes + ---------- + metadata : Metadata + a data class that stores rule specific data + control : Control + a control dataclass that stores the runtime information about + the queue on which we send the status for the action, the inventory + information, the hosts data and the variables that we would like + to pass into the action + uuid : str + each action has a uuid that is generated to track it + action : str + the name of the action, set by the sub classe + + Methods + ------- + send_status(data={}, obj_type:"action") + Sends the action status information on the queue + send_default_status() + Sends the default action status, used mostly with internal + actions like debug, print_event, set_fact, retract_fact, + noop, post_event + get_events() + Fetches the matching events from the variables + collect_extra_vars() + Create extra_vars to be sent to playbook and job template which + includes rule and matching events. + embellish_internal_event() + Add internal sources for facts and events posted from inside of + a rulebook + """ + + def __init__(self, metadata: Metadata, control: Control, action: str): + self.metadata = metadata + self.control = control + self.uuid = str(uuid.uuid4()) + self.action = action + + async def send_status(self, data: Dict, obj_type: str = "Action") -> None: + """Send Action status information on the queue""" + payload = { + "type": obj_type, + "action": self.action, + "action_uuid": self.uuid, + "ruleset": self.metadata.rule_set, + "ruleset_uuid": self.metadata.rule_set_uuid, + "rule": self.metadata.rule, + "rule_uuid": self.metadata.rule_uuid, + "rule_run_at": self.metadata.rule_run_at, + "activation_id": settings.identifier, + "activation_instance_id": settings.identifier, + } + payload.update(data) + await self.control.queue.put(payload) + + async def send_default_status(self): + """Send default action status information on the queue""" + await self.send_status( + { + "run_at": run_at(), + "status": INTERNAL_ACTION_STATUS, + "matching_events": self.get_events(), + } + ) + + def get_events(self) -> Dict: + """From the control variables, detect if its a single event + match or a multi event match and return a dictionary with + the event data with + m key for single event stored in the event key + m_0,m_1,.... for multiple matching events stored in + the events key + """ + if "event" in self.control.variables: + return {"m": self.control.variables["event"]} + if "events" in self.control.variables: + return self.control.variables["events"] + return {} + + def embellish_internal_event(self, event: Dict) -> Dict: + """Insert metadata for every internally generated event""" + return insert_meta( + event, **{"source_name": self.action, "source_type": "internal"} + ) + + def set_action(self, action) -> None: + self.action = action + + def collect_extra_vars(self, user_extra_vars: Dict) -> Dict: + """When we send information to ansible-playbook or job template + on AWX, we need the rule and event specific information to + be sent to this external process + + the caller passes in the user_extra_vars from the action args + and then we append eda specific vars and return that as a + the updated dictionary that is sent to the external process + """ + extra_vars = user_extra_vars.copy() if user_extra_vars else {} + + eda_vars = { + "ruleset": self.metadata.rule_set, + "rule": self.metadata.rule, + } + if "events" in self.control.variables: + eda_vars["events"] = self.control.variables["events"] + if "event" in self.control.variables: + eda_vars["event"] = self.control.variables["event"] + + extra_vars[KEY_EDA_VARS] = eda_vars + return extra_vars diff --git a/ansible_rulebook/action/metadata.py b/ansible_rulebook/action/metadata.py new file mode 100644 index 000000000..c3eb73f03 --- /dev/null +++ b/ansible_rulebook/action/metadata.py @@ -0,0 +1,48 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Metadata: + """Metadata class stores the rule specific information + which is used when reporting stats for the action + + Attributes + ---------- + rule: str + Rule name + rule_uuid: str + Rule uuid + rule_set: str + Rule set name + rule_set_uuid: str + Rule set uuid + rule_run_at: str + ISO 8601 date/time when the rule was triggered + """ + + __slots__ = [ + "rule", + "rule_uuid", + "rule_set", + "rule_set_uuid", + "rule_run_at", + ] + rule: str + rule_uuid: str + rule_set: str + rule_set_uuid: str + rule_run_at: str diff --git a/ansible_rulebook/action/noop.py b/ansible_rulebook/action/noop.py new file mode 100644 index 000000000..23b84de7e --- /dev/null +++ b/ansible_rulebook/action/noop.py @@ -0,0 +1,34 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from .control import Control +from .helper import Helper +from .metadata import Metadata + +logger = logging.getLogger(__name__) + + +class Noop: + """The No Op action usually used for debugging, doesn't do anything and + just sends the action status + """ + + def __init__(self, metadata: Metadata, control: Control, **action_args): + self.helper = Helper(metadata, control, "noop") + self.action_args = action_args + + async def __call__(self): + await self.helper.send_default_status() diff --git a/ansible_rulebook/action/post_event.py b/ansible_rulebook/action/post_event.py new file mode 100644 index 000000000..4c2cc5bb3 --- /dev/null +++ b/ansible_rulebook/action/post_event.py @@ -0,0 +1,43 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from drools import ruleset as lang + +from .control import Control +from .helper import Helper +from .metadata import Metadata + +logger = logging.getLogger(__name__) + + +class PostEvent: + """The post_event action sends the event information into the Drools + rule engine, which can then trigger the rules based on matching + events. To mark that this is an internal event coming from inside + the rulebook we embellish the event with source information to + indicate that its an internal event. + """ + + def __init__(self, metadata: Metadata, control: Control, **action_args): + self.helper = Helper(metadata, control, "post_event") + self.action_args = action_args + + async def __call__(self): + lang.post( + self.action_args["ruleset"], + self.helper.embellish_internal_event(self.action_args["event"]), + ) + await self.helper.send_default_status() diff --git a/ansible_rulebook/action/print_event.py b/ansible_rulebook/action/print_event.py new file mode 100644 index 000000000..d85fa9f67 --- /dev/null +++ b/ansible_rulebook/action/print_event.py @@ -0,0 +1,45 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from pprint import pprint +from typing import Callable + +from .control import Control +from .helper import Helper +from .metadata import Metadata + + +class PrintEvent: + """The print_event action defined in the rule book + prints the event information to stdout and + send the action status + """ + + def __init__(self, metadata: Metadata, control: Control, **action_args): + self.helper = Helper(metadata, control, "print_event") + self.action_args = action_args + + async def __call__(self): + print_fn: Callable = print + if "pretty" in self.action_args: + print_fn = pprint + + var_name = ( + "events" if "events" in self.helper.control.variables else "event" + ) + + print_fn(self.helper.control.variables[var_name]) + sys.stdout.flush() + await self.helper.send_default_status() diff --git a/ansible_rulebook/action/retract_fact.py b/ansible_rulebook/action/retract_fact.py new file mode 100644 index 000000000..b5afaaf75 --- /dev/null +++ b/ansible_rulebook/action/retract_fact.py @@ -0,0 +1,57 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from drools import ruleset as lang + +from .control import Control +from .helper import Helper +from .metadata import Metadata + +logger = logging.getLogger(__name__) + + +class RetractFact: + """The retract_fact action removes a fact information from the Drools + rule engine, which can then trigger the rules based on removed + facts. + The action_args includes the following parameters + ruleset: str + The name of the ruleset to retract the fact from + fact: dict + The fact to retract from Drools + partial: true|false, default is true + if the fact has partial information or it has complete + information. + """ + + def __init__(self, metadata: Metadata, control: Control, **action_args): + self.helper = Helper(metadata, control, "retract_fact") + self.action_args = action_args + + async def __call__(self): + partial = self.action_args.get("partial", True) + if not partial: + exclude_keys = ["meta"] + else: + exclude_keys = [] + + lang.retract_matching_facts( + self.action_args["ruleset"], + self.action_args["fact"], + partial, + exclude_keys, + ) + await self.helper.send_default_status() diff --git a/ansible_rulebook/action/run_job_template.py b/ansible_rulebook/action/run_job_template.py new file mode 100644 index 000000000..72a95d06c --- /dev/null +++ b/ansible_rulebook/action/run_job_template.py @@ -0,0 +1,157 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import logging +import uuid +from urllib.parse import urljoin + +from drools import ruleset as lang + +from ansible_rulebook.conf import settings +from ansible_rulebook.exception import ( + ControllerApiException, + JobTemplateNotFoundException, +) +from ansible_rulebook.job_template_runner import job_template_runner +from ansible_rulebook.util import run_at + +from .control import Control +from .helper import Helper +from .metadata import Metadata + +logger = logging.getLogger(__name__) + + +class RunJobTemplate: + """run_job_template action launches a specified job template on + the controller. It waits for the job to be complete. + """ + + def __init__(self, metadata: Metadata, control: Control, **action_args): + self.helper = Helper(metadata, control, "run_job_template") + self.action_args = action_args + self.name = self.action_args["name"] + self.organization = self.action_args["organization"] + self.job_id = str(uuid.uuid4()) + self.job_args = self.action_args.get("job_args", {}) + self.job_args["limit"] = ",".join(self.helper.control.hosts) + self.controller_job = {} + + async def __call__(self): + logger.info( + "running job template: %s, organization: %s", + self.name, + self.organization, + ) + logger.info( + "ruleset: %s, rule %s", + self.helper.metadata.rule_set, + self.helper.metadata.rule, + ) + + self.job_args["extra_vars"] = self.helper.collect_extra_vars( + self.job_args.get("extra_vars", {}) + ) + await self._job_start_event() + await self._run() + + async def _run(self): + retries = self.action_args.get("retries", 0) + if self.action_args.get("retry", False): + retries = max(self.action_args.get("retries", 0), 1) + delay = self.action_args.get("delay", 0) + + try: + for i in range(retries + 1): + if i > 0: + if delay > 0: + await asyncio.sleep(delay) + logger.info( + "Previous run_job_template failed. Retry %d of %d", + i, + retries, + ) + controller_job = await job_template_runner.run_job_template( + self.name, + self.organization, + self.job_args, + ) + if controller_job["status"] != "failed": + break + except (ControllerApiException, JobTemplateNotFoundException) as ex: + logger.error(ex) + controller_job = {} + controller_job["status"] = "failed" + controller_job["created"] = run_at() + controller_job["error"] = str(ex) + + self.controller_job = controller_job + await self._post_process() + + async def _post_process(self) -> None: + a_log = { + "job_template_name": self.name, + "organization": self.organization, + "job_id": self.job_id, + "status": self.controller_job["status"], + "run_at": self.controller_job["created"], + "url": self._controller_job_url(), + "matching_events": self.helper.get_events(), + } + if "error" in self.controller_job: + a_log["message"] = self.controller_job["error"] + a_log["reason"] = {"error": self.controller_job["error"]} + + await self.helper.send_status(a_log) + set_facts = self.action_args.get("set_facts", False) + post_events = self.action_args.get("post_events", False) + + if set_facts or post_events: + ruleset = self.action_args.get( + "ruleset", self.helper.metadata.rule_set + ) + logger.debug("set_facts") + facts = self.controller_job.get("artifacts", {}) + if facts: + facts = self.helper.embellish_internal_event(facts) + logger.debug("facts %s", facts) + if set_facts: + lang.assert_fact(ruleset, facts) + if post_events: + lang.post(ruleset, facts) + else: + logger.debug("Empty facts are not set") + + async def _job_start_event(self): + await self.helper.send_status( + { + "run_at": run_at(), + "matching_events": self.helper.get_events(), + "action": self.helper.action, + "hosts": ",".join(self.helper.control.hosts), + "name": self.name, + "job_id": self.job_id, + "ansible_rulebook_id": settings.identifier, + }, + obj_type="Job", + ) + + def _controller_job_url(self) -> str: + if "id" in self.controller_job: + return urljoin( + job_template_runner.host, + "/#/jobs/" f"{self.controller_job['id']}/" "details", + ) + return "" diff --git a/ansible_rulebook/action/run_module.py b/ansible_rulebook/action/run_module.py new file mode 100644 index 000000000..3c6781061 --- /dev/null +++ b/ansible_rulebook/action/run_module.py @@ -0,0 +1,73 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import os + +import yaml + +from .control import Control +from .metadata import Metadata +from .run_playbook import RunPlaybook + +logger = logging.getLogger(__name__) + + +class RunModule(RunPlaybook): + """run_module runs an ansible module using the ansible runner""" + + MODULE_OUTPUT_KEY = "module_result" + + def __init__(self, metadata: Metadata, control: Control, **action_args): + super().__init__(metadata, control, **action_args) + self.helper.set_action("run_module") + self.output_key = self.MODULE_OUTPUT_KEY + + async def _pre_process(self) -> None: + await super()._pre_process() + self.playbook = os.path.join(self.private_data_dir, "wrapper.yml") + self._wrap_module_in_playbook() + + def _copy_playbook_files(self, project_dir): + pass + + def _runner_args(self): + return {"playbook": self.playbook, "inventory": self.inventory} + + def _wrap_module_in_playbook(self) -> None: + module_args = self.action_args.get("module_args", {}) + module_task = { + "name": "Module wrapper", + self.name: module_args, + "register": self.MODULE_OUTPUT_KEY, + } + result_str = "{{ " + self.MODULE_OUTPUT_KEY + " }}" + set_fact_task = { + "name": "save result", + "ansible.builtin.set_fact": { + self.MODULE_OUTPUT_KEY: result_str, + "cacheable": True, + }, + } + tasks = [module_task, set_fact_task] + wrapper = [ + dict( + name="wrapper", + hosts=self.host_limit, + gather_facts=False, + tasks=tasks, + ) + ] + with open(self.playbook, "w") as f: + yaml.dump(wrapper, f) diff --git a/ansible_rulebook/action/run_playbook.py b/ansible_rulebook/action/run_playbook.py new file mode 100644 index 000000000..2325daab1 --- /dev/null +++ b/ansible_rulebook/action/run_playbook.py @@ -0,0 +1,265 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import glob +import json +import logging +import os +import shutil +import tempfile +import uuid + +import yaml +from drools import ruleset as lang + +from ansible_rulebook.collection import ( + find_playbook, + has_playbook, + split_collection_name, +) +from ansible_rulebook.conf import settings +from ansible_rulebook.exception import ( + MissingArtifactKeyException, + PlaybookNotFoundException, + PlaybookStatusNotFoundException, +) +from ansible_rulebook.util import create_inventory, run_at + +from .control import Control +from .helper import Helper +from .metadata import Metadata +from .runner import Runner + +logger = logging.getLogger(__name__) + +tar = shutil.which("tar") + + +class RunPlaybook: + """run_playbook action runs an ansible playbook using the + ansible-runner + """ + + def __init__(self, metadata: Metadata, control: Control, **action_args): + self.helper = Helper(metadata, control, "run_playbook") + self.action_args = action_args + self.job_id = str(uuid.uuid4()) + self.default_copy_files = True + self.default_check_files = True + self.name = self.action_args["name"] + self.verbosity = self.action_args.get("verbosity", 0) + self.json_mode = self.action_args.get("json_mode", False) + self.host_limit = ",".join(self.helper.control.hosts) + self.private_data_dir = tempfile.mkdtemp(prefix="eda") + self.output_key = None + self.inventory = None + + async def __call__(self): + try: + logger.info( + f"ruleset: {self.helper.metadata.rule_set}, " + f"rule: {self.helper.metadata.rule}" + ) + logger.debug("private data dir %s", self.private_data_dir) + await self._pre_process() + await self._job_start_event() + logger.info("Calling Ansible runner") + await self._run() + finally: + if os.path.exists(self.private_data_dir): + shutil.rmtree(self.private_data_dir) + + async def _job_start_event(self): + await self.helper.send_status( + { + "run_at": run_at(), + "matching_events": self.helper.get_events(), + "action": self.helper.action, + "hosts": self.host_limit, + "name": self.name, + "job_id": self.job_id, + "ansible_rulebook_id": settings.identifier, + }, + obj_type="Job", + ) + + async def _run(self): + retries = self.action_args.get("retries", 0) + if self.action_args.get("retry", False): + retries = max(self.action_args.get("retries", 0), 1) + + delay = self.action_args.get("delay", 0) + + for i in range(retries + 1): + if i > 0: + if delay > 0: + await asyncio.sleep(delay) + logger.info( + "Previous run_playbook failed. Retry %d of %d", i, retries + ) + + await Runner( + self.private_data_dir, + self.host_limit, + self.verbosity, + self.job_id, + self.json_mode, + self.helper, + self._runner_args(), + )() + if self._get_latest_artifact("status") != "failed": + break + + await self._post_process() + + def _runner_args(self): + return {"playbook": self.name, "inventory": self.inventory} + + async def _pre_process(self) -> None: + playbook_extra_vars = self.helper.collect_extra_vars( + self.action_args.get("extra_vars", {}) + ) + + env_dir = os.path.join(self.private_data_dir, "env") + inventory_dir = os.path.join(self.private_data_dir, "inventory") + project_dir = os.path.join(self.private_data_dir, "project") + + os.mkdir(env_dir) + with open(os.path.join(env_dir, "extravars"), "w") as file_handle: + file_handle.write(yaml.dump(playbook_extra_vars)) + os.mkdir(inventory_dir) + + if self.helper.control.inventory: + create_inventory(inventory_dir, self.helper.control.inventory) + self.inventory = os.path.join( + inventory_dir, os.path.basename(self.helper.control.inventory) + ) + os.mkdir(project_dir) + + logger.debug( + "project_data_file: %s", self.helper.control.project_data_file + ) + if self.helper.control.project_data_file: + if os.path.exists(self.helper.control.project_data_file): + await self._untar_project( + project_dir, self.helper.control.project_data_file + ) + return + self._copy_playbook_files(project_dir) + + def _copy_playbook_files(self, project_dir): + if self.action_args.get("check_files", self.default_check_files): + if os.path.exists(self.name): + tail_name = os.path.basename(self.name) + shutil.copy(self.name, os.path.join(project_dir, tail_name)) + if self.action_args.get("copy_files", self.default_copy_files): + shutil.copytree( + os.path.dirname(os.path.abspath(self.name)), + project_dir, + dirs_exist_ok=True, + ) + self.name = tail_name + elif has_playbook(*split_collection_name(self.name)): + shutil.copy( + find_playbook(*split_collection_name(self.name)), + os.path.join(project_dir, self.name), + ) + else: + msg = ( + f"Could not find a playbook for {self.name} " + f"from {os.getcwd()}" + ) + logger.error(msg) + raise PlaybookNotFoundException(msg) + + async def _post_process(self): + rc = int(self._get_latest_artifact("rc")) + status = self._get_latest_artifact("status") + logger.info("Ansible runner rc: %d, status: %s", rc, status) + if rc != 0: + error_message = self._get_latest_artifact("stderr") + if not error_message: + error_message = self._get_latest_artifact("stdout") + logger.error(error_message) + + await self.helper.send_status( + { + "playbook_name": self.name, + "job_id": self.job_id, + "rc": rc, + "status": status, + "run_at": run_at(), + "matching_events": self.helper.get_events(), + } + ) + set_facts = self.action_args.get("set_facts", False) + post_events = self.action_args.get("post_events", False) + + if rc == 0 and (set_facts or post_events): + logger.debug("set_facts") + fact_folder = self._get_latest_artifact("fact_cache", False) + ruleset = self.action_args.get( + "ruleset", self.helper.metadata.rule_set + ) + for host_facts in glob.glob(os.path.join(fact_folder, "*")): + with open(host_facts) as file_handle: + fact = json.loads(file_handle.read()) + if self.output_key: + if self.output_key not in fact: + logger.error( + "The artifacts from the ansible-runner " + "does not have key %s", + self.output_key, + ) + raise MissingArtifactKeyException( + f"Missing key: {self.output_key} in artifacts" + ) + fact = fact[self.output_key] + fact = self.helper.embellish_internal_event(fact) + logger.debug("fact %s", fact) + if set_facts: + lang.assert_fact(ruleset, fact) + if post_events: + lang.post(ruleset, fact) + + def _get_latest_artifact(self, component: str, content: bool = True): + files = glob.glob( + os.path.join(self.private_data_dir, "artifacts", "*", component) + ) + files.sort(key=os.path.getmtime, reverse=True) + if not files: + raise PlaybookStatusNotFoundException(f"No {component} file found") + if content: + with open(files[0], "r") as file_handle: + content = file_handle.read() + return content + return files[0] + + async def _untar_project(self, output_dir, project_data_file): + + cmd = [tar, "zxvf", project_data_file] + proc = await asyncio.create_subprocess_exec( + *cmd, + cwd=output_dir, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + stdout, stderr = await proc.communicate() + + if stdout: + logger.debug(stdout.decode()) + if stderr: + logger.debug(stderr.decode()) diff --git a/ansible_rulebook/action/run_workflow_template.py b/ansible_rulebook/action/run_workflow_template.py new file mode 100644 index 000000000..072628da7 --- /dev/null +++ b/ansible_rulebook/action/run_workflow_template.py @@ -0,0 +1,163 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import logging +import uuid +from urllib.parse import urljoin + +from drools import ruleset as lang + +from ansible_rulebook.conf import settings +from ansible_rulebook.exception import ( + ControllerApiException, + WorkflowJobTemplateNotFoundException, +) +from ansible_rulebook.job_template_runner import job_template_runner +from ansible_rulebook.util import run_at + +from .control import Control +from .helper import Helper +from .metadata import Metadata + +logger = logging.getLogger(__name__) + + +class RunWorkflowTemplate: + """run_workflow_template action launches a specified workflow template on + the controller. It waits for the job to be complete. + """ + + def __init__(self, metadata: Metadata, control: Control, **action_args): + self.helper = Helper(metadata, control, "run_workflow_template") + self.action_args = action_args + self.name = self.action_args["name"] + self.organization = self.action_args["organization"] + self.job_id = str(uuid.uuid4()) + self.job_args = self.action_args.get("job_args", {}) + self.job_args["limit"] = ",".join(self.helper.control.hosts) + self.controller_job = {} + + async def __call__(self): + logger.info( + "running workflow template: %s, organization: %s", + self.name, + self.organization, + ) + logger.info( + "ruleset: %s, rule %s", + self.helper.metadata.rule_set, + self.helper.metadata.rule, + ) + + self.job_args["extra_vars"] = self.helper.collect_extra_vars( + self.job_args.get("extra_vars", {}) + ) + await self._job_start_event() + await self._run() + + async def _run(self): + retries = self.action_args.get("retries", 0) + if self.action_args.get("retry", False): + retries = max(self.action_args.get("retries", 0), 1) + delay = self.action_args.get("delay", 0) + + try: + for i in range(retries + 1): + if i > 0: + if delay > 0: + await asyncio.sleep(delay) + logger.info( + "Previous run_workflow_template failed. " + "Retry %d of %d", + i, + retries, + ) + controller_job = ( + await job_template_runner.run_workflow_job_template( + self.name, + self.organization, + self.job_args, + ) + ) + if controller_job["status"] != "failed": + break + except ( + ControllerApiException, + WorkflowJobTemplateNotFoundException, + ) as ex: + logger.error(ex) + controller_job = {} + controller_job["status"] = "failed" + controller_job["created"] = run_at() + controller_job["error"] = str(ex) + + self.controller_job = controller_job + await self._post_process() + + async def _post_process(self) -> None: + a_log = { + "name": self.name, + "organization": self.organization, + "job_id": self.job_id, + "status": self.controller_job["status"], + "run_at": self.controller_job["created"], + "url": self._controller_job_url(), + "matching_events": self.helper.get_events(), + } + if "error" in self.controller_job: + a_log["message"] = self.controller_job["error"] + a_log["reason"] = {"error": self.controller_job["error"]} + + await self.helper.send_status(a_log) + set_facts = self.action_args.get("set_facts", False) + post_events = self.action_args.get("post_events", False) + + if set_facts or post_events: + ruleset = self.action_args.get( + "ruleset", self.helper.metadata.rule_set + ) + logger.debug("set_facts") + facts = self.controller_job.get("artifacts", {}) + if facts: + facts = self.helper.embellish_internal_event(facts) + logger.debug("facts %s", facts) + if set_facts: + lang.assert_fact(ruleset, facts) + if post_events: + lang.post(ruleset, facts) + else: + logger.debug("Empty facts are not set") + + async def _job_start_event(self): + await self.helper.send_status( + { + "run_at": run_at(), + "matching_events": self.helper.get_events(), + "action": self.helper.action, + "hosts": ",".join(self.helper.control.hosts), + "name": self.name, + "job_id": self.job_id, + "ansible_rulebook_id": settings.identifier, + }, + obj_type="Job", + ) + + def _controller_job_url(self) -> str: + if "id" in self.controller_job: + return urljoin( + job_template_runner.host, + "/#/jobs/workflow/" f"{self.controller_job['id']}/" "details", + ) + return "" diff --git a/ansible_rulebook/action/runner.py b/ansible_rulebook/action/runner.py new file mode 100644 index 000000000..293388fd8 --- /dev/null +++ b/ansible_rulebook/action/runner.py @@ -0,0 +1,117 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import concurrent.futures +import logging +from asyncio.exceptions import CancelledError +from functools import partial + +import ansible_runner +import janus + +from ansible_rulebook.conf import settings + +logger = logging.getLogger(__name__) + + +class Runner: + """calls ansible-runner to launch either playbooks/modules + ansible-runner + """ + + def __init__( + self, + data_dir, + host_limit, + verbosity, + job_id, + json_mode, + helper, + runner_args, + ): + self.private_data_dir = data_dir + self.host_limit = host_limit + self.verbosity = verbosity + self.job_id = job_id + self.helper = helper + self.runner_args = runner_args + self.json_mode = json_mode + + async def __call__(self): + shutdown = False + + loop = asyncio.get_running_loop() + + queue = janus.Queue() + + # The event_callback is called from the ansible-runner thread + # It needs a thread-safe synchronous queue. + # Janus provides a sync queue connected to an async queue + # Here we push the event into the sync side of janus + def event_callback(event, *_args, **_kwargs): + event["job_id"] = self.job_id + event["ansible_rulebook_id"] = settings.identifier + queue.sync_q.put({"type": "AnsibleEvent", "event": event}) + + # Here we read the async side and push it into the event queue + # which is also async. + # We do this until cancelled at the end of the ansible runner call. + # We might need to drain the queue here before ending. + async def read_queue(): + try: + while True: + val = await queue.async_q.get() + event_data = val.get("event", {}) + val["run_at"] = event_data.get("created") + await self.helper.send_status(val) + except CancelledError: + logger.info("Ansible runner Queue task cancelled") + + def cancel_callback(): + return shutdown + + tasks = [] + + tasks.append(asyncio.create_task(read_queue())) + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as task_pool: + try: + await loop.run_in_executor( + task_pool, + partial( + ansible_runner.run, + private_data_dir=self.private_data_dir, + limit=self.host_limit, + verbosity=self.verbosity, + event_handler=event_callback, + cancel_callback=cancel_callback, + json_mode=self.json_mode, + **self.runner_args, + ), + ) + except CancelledError: + logger.debug( + "Ansible Runner Thread Pool executor task cancelled" + ) + shutdown = True + raise + finally: + # Cancel the queue reading task + for task in tasks: + if not task.done(): + logger.debug("Cancel Queue reading task") + task.cancel() + + await asyncio.gather(*tasks) diff --git a/ansible_rulebook/action/set_fact.py b/ansible_rulebook/action/set_fact.py new file mode 100644 index 000000000..adca68a07 --- /dev/null +++ b/ansible_rulebook/action/set_fact.py @@ -0,0 +1,48 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from drools import ruleset as lang + +from .control import Control +from .helper import Helper +from .metadata import Metadata + +logger = logging.getLogger(__name__) + + +class SetFact: + """The set_fact action sends the fact information into the Drools + rule engine, which can then trigger the rules based on matching + facts. To mark that this is an internal fact coming from inside + the rulebook we embellish the fact with source information to + indicate that its an internal fact. + """ + + def __init__(self, metadata: Metadata, control: Control, **action_args): + self.helper = Helper(metadata, control, "set_fact") + self.action_args = action_args + + async def __call__(self): + logger.debug( + "set_fact %s %s", + self.action_args["ruleset"], + self.action_args["fact"], + ) + lang.assert_fact( + self.action_args["ruleset"], + self.helper.embellish_internal_event(self.action_args["fact"]), + ) + await self.helper.send_default_status() diff --git a/ansible_rulebook/action/shutdown.py b/ansible_rulebook/action/shutdown.py new file mode 100644 index 000000000..e33c32253 --- /dev/null +++ b/ansible_rulebook/action/shutdown.py @@ -0,0 +1,59 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ansible_rulebook.exception import ShutdownException +from ansible_rulebook.messages import Shutdown as ShutdownMessage +from ansible_rulebook.util import run_at + +from .control import Control +from .helper import INTERNAL_ACTION_STATUS, Helper +from .metadata import Metadata + + +class Shutdown: + """shutdown action initiates a shutdown from inside of a rulebook""" + + def __init__(self, metadata: Metadata, control: Control, **action_args): + self.helper = Helper(metadata, control, "shutdown") + self.action_args = action_args + + async def __call__(self): + delay = self.action_args.get("delay", 60.0) + message = self.action_args.get("message", "Default shutdown message") + kind = self.action_args.get("kind", "graceful") + + await self.helper.send_status( + { + "run_at": run_at(), + "status": INTERNAL_ACTION_STATUS, + "matching_events": self.helper.get_events(), + "delay": delay, + "message": message, + "kind": kind, + } + ) + print( + "Ruleset: %s rule: %s has initiated shutdown of type: %s. " + "Delay: %.3f seconds, Message: %s" + % ( + self.helper.metadata.rule_set, + self.helper.metadata.rule, + kind, + delay, + message, + ) + ) + raise ShutdownException( + ShutdownMessage(message=message, delay=delay, kind=kind) + ) diff --git a/ansible_rulebook/rule_set_runner.py b/ansible_rulebook/rule_set_runner.py index 91fa8c2f2..e220d8b99 100644 --- a/ansible_rulebook/rule_set_runner.py +++ b/ansible_rulebook/rule_set_runner.py @@ -28,7 +28,19 @@ ) from drools.ruleset import session_stats -from ansible_rulebook.builtin import actions as builtin_actions +from ansible_rulebook.action.control import Control +from ansible_rulebook.action.debug import Debug +from ansible_rulebook.action.metadata import Metadata +from ansible_rulebook.action.noop import Noop +from ansible_rulebook.action.post_event import PostEvent +from ansible_rulebook.action.print_event import PrintEvent +from ansible_rulebook.action.retract_fact import RetractFact +from ansible_rulebook.action.run_job_template import RunJobTemplate +from ansible_rulebook.action.run_module import RunModule +from ansible_rulebook.action.run_playbook import RunPlaybook +from ansible_rulebook.action.run_workflow_template import RunWorkflowTemplate +from ansible_rulebook.action.set_fact import SetFact +from ansible_rulebook.action.shutdown import Shutdown as ShutdownAction from ansible_rulebook.conf import settings from ansible_rulebook.exception import ( ShutdownException, @@ -50,6 +62,20 @@ logger = logging.getLogger(__name__) +ACTION_CLASSES = { + "debug": Debug, + "print_event": PrintEvent, + "none": Noop, + "set_fact": SetFact, + "post_event": PostEvent, + "retract_fact": RetractFact, + "shutdown": ShutdownAction, + "run_playbook": RunPlaybook, + "run_module": RunModule, + "run_job_template": RunJobTemplate, + "run_workflow_template": RunWorkflowTemplate, +} + class RuleSetRunner: def __init__( @@ -292,13 +318,17 @@ def _run_action( f"{action_item.rule}" ) logger.debug("Creating action task %s", task_name) + metadata = Metadata( + rule_set=action_item.ruleset, + rule_set_uuid=action_item.ruleset_uuid, + rule=action_item.rule, + rule_uuid=action_item.rule_uuid, + rule_run_at=rule_run_at, + ) + task = asyncio.create_task( self._call_action( - action_item.ruleset, - action_item.ruleset_uuid, - action_item.rule, - action_item.rule_uuid, - rule_run_at, + metadata, action.action, MappingProxyType(action.action_args), action_item.variables, @@ -314,11 +344,7 @@ def _run_action( async def _call_action( self, - ruleset: str, - ruleset_uuid: str, - rule: str, - rule_uuid: str, - rule_run_at: str, + metadata: Metadata, action: str, immutable_action_args: MappingProxyType, variables: Dict, @@ -330,9 +356,12 @@ async def _call_action( action_args = immutable_action_args.copy() error = None - if action in builtin_actions: + if action in ACTION_CLASSES: try: - if action == "run_job_template": + if ( + action == "run_job_template" + or action == "run_workflow_template" + ): limit = dpath.get( action_args, "job_args.limit", @@ -395,21 +424,20 @@ async def _call_action( logger.info("action args: %s", action_args) if "ruleset" not in action_args: - action_args["ruleset"] = ruleset + action_args["ruleset"] = metadata.rule_set - await builtin_actions[action]( - event_log=self.event_log, + control = Control( + queue=self.event_log, inventory=inventory, hosts=hosts, variables=variables_copy, project_data_file=self.project_data_file, - source_ruleset_name=ruleset, - source_ruleset_uuid=ruleset_uuid, - source_rule_name=rule, - source_rule_uuid=rule_uuid, - rule_run_at=rule_run_at, - **action_args, ) + + await ACTION_CLASSES[action]( + metadata, control, **action_args + )() + except KeyError as e: logger.error( "KeyError %s with variables %s", @@ -457,12 +485,12 @@ async def _call_action( playbook_name=action_args.get("name"), status="failed", run_at=run_at(), - rule_run_at=rule_run_at, + rule_run_at=metadata.rule_run_at, message=str(error), - rule=rule, - ruleset=ruleset, - rule_uuid=rule_uuid, - ruleset_uuid=ruleset_uuid, + rule=metadata.rule, + ruleset=metadata.rule_set, + rule_uuid=metadata.rule_uuid, + ruleset_uuid=metadata.rule_set_uuid, ) ) diff --git a/tests/e2e/test_actions.py b/tests/e2e/test_actions.py index 05a8308cb..76a84ff49 100644 --- a/tests/e2e/test_actions.py +++ b/tests/e2e/test_actions.py @@ -98,8 +98,8 @@ def test_actions_sanity(update_environment): "'hosts': ['all']", f"'inventory': '{inventory}'", "'project_data_file': None,", - "'ruleset': 'Test actions sanity'", - "'source_rule_name': 'debug',", + "'rule_set': 'Test actions sanity'", + "'rule': 'debug',", f"'variables': {{'DEFAULT_EVENT_DELAY': '{DEFAULT_EVENT_DELAY}'", f"'DEFAULT_SHUTDOWN_AFTER': '{DEFAULT_SHUTDOWN_AFTER}',", f"'DEFAULT_STARTUP_DELAY': '{DEFAULT_STARTUP_DELAY}'", @@ -119,8 +119,8 @@ def test_actions_sanity(update_environment): r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}" ) expected_debug_regexs = [ - r"'source_rule_uuid':" + f" '{uuid_regex}'", - r"'source_ruleset_uuid':" + f" '{uuid_regex}'", + r"'rule_uuid':" + f" '{uuid_regex}'", + r"'rule_set_uuid':" + f" '{uuid_regex}'", r"'uuid': " + f"'{uuid_regex}'" + r"}}}}", ] @@ -173,7 +173,7 @@ def test_actions_sanity(update_environment): ), "multiple_action action failed" assert ( - len(result.stdout.splitlines()) == 106 + len(result.stdout.splitlines()) == 105 ), "unexpected output from the rulebook" diff --git a/tests/examples/69_enhanced_debug.yml b/tests/examples/69_enhanced_debug.yml index a38a65a59..ab45a9cba 100644 --- a/tests/examples/69_enhanced_debug.yml +++ b/tests/examples/69_enhanced_debug.yml @@ -23,11 +23,6 @@ - "Hello World {{ event }}" - "Hello Java" - "Hello Java again {{ event }}" - - name: r4 - condition: event.i == 3 - action: - debug: - var: event.does_not_exist - name: r5 condition: event.i == 4 action: diff --git a/tests/test_examples.py b/tests/test_examples.py index eceee736f..3693c33a6 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -2103,7 +2103,8 @@ async def test_46_job_template(): job_url = "https://examples.com/#/jobs/945/details" with SourceTask(rs.sources[0], "sources", {}, queue): with patch( - "ansible_rulebook.builtin.job_template_runner.run_job_template", + "ansible_rulebook.action.run_job_template." + "job_template_runner.run_job_template", return_value=response_obj, ): await run_rulesets( @@ -2138,7 +2139,8 @@ async def test_46_job_template_exception(err_msg, err): rs = ruleset_queues[0][0] with SourceTask(rs.sources[0], "sources", {}, queue): with patch( - "ansible_rulebook.builtin.job_template_runner.run_job_template", + "ansible_rulebook.action.run_job_template." + "job_template_runner.run_job_template", side_effect=err, ): await run_rulesets( @@ -2256,8 +2258,8 @@ async def test_79_workflow_job_template_exception(err_msg, err): rs = ruleset_queues[0][0] with SourceTask(rs.sources[0], "sources", {}, queue): with patch( - "ansible_rulebook.builtin.job_template_runner." - "run_workflow_job_template", + "ansible_rulebook.action.run_workflow_template." + "job_template_runner.run_workflow_job_template", side_effect=err, ): await run_rulesets( @@ -2306,8 +2308,8 @@ async def test_79_workflow_job_template(): job_url = "https://examples.com/#/jobs/workflow/945/details" with SourceTask(rs.sources[0], "sources", {}, queue): with patch( - "ansible_rulebook.builtin.job_template_runner." - "run_workflow_job_template", + "ansible_rulebook.action.run_workflow_template." + "job_template_runner.run_workflow_job_template", return_value=response_obj, ): await run_rulesets( diff --git a/tests/unit/action/__init__.py b/tests/unit/action/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/action/playbooks/fail.yml b/tests/unit/action/playbooks/fail.yml new file mode 100644 index 000000000..7d470e067 --- /dev/null +++ b/tests/unit/action/playbooks/fail.yml @@ -0,0 +1,8 @@ +- name: Fail the rule + hosts: all + gather_facts: false + tasks: + - name: Fail if we have a rule name defined + when: ansible_eda.rule is defined + ansible.builtin.fail: + msg: "Failed because of Rule name: {{ ansible_eda.rule }}" diff --git a/tests/unit/action/playbooks/rule_name.yml b/tests/unit/action/playbooks/rule_name.yml new file mode 100644 index 000000000..f41872085 --- /dev/null +++ b/tests/unit/action/playbooks/rule_name.yml @@ -0,0 +1,14 @@ +- name: Print rule name that called this playbook + hosts: all + gather_facts: false + tasks: + - name: Print rule name + when: ansible_eda.rule is defined + ansible.builtin.debug: + msg: "Rule name: {{ ansible_eda.rule }}" + - name: Set the RuleName as a fact + ansible.builtin.set_fact: + results: + my_rule_name: "{{ ansible_eda.rule }}" + my_rule_set_name: "{{ ansible_eda.ruleset }}" + cacheable: true diff --git a/tests/unit/action/test_debug.py b/tests/unit/action/test_debug.py new file mode 100644 index 000000000..91dbee977 --- /dev/null +++ b/tests/unit/action/test_debug.py @@ -0,0 +1,180 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +from unittest.mock import patch + +import pytest +from freezegun import freeze_time + +from ansible_rulebook.action.control import Control +from ansible_rulebook.action.debug import Debug +from ansible_rulebook.action.helper import INTERNAL_ACTION_STATUS +from ansible_rulebook.action.metadata import Metadata +from ansible_rulebook.conf import settings + +DUMMY_UUID = "eb7de03f-6f8f-4943-b69e-3c90db346edf" +RULE_UUID = "abcdef3f-6f8f-4943-b69e-3c90db346edf" +RULE_SET_UUID = "00aabbcc-1111-2222-b69e-3c90db346edf" +RULE_RUN_AT = "2023-06-11T12:13:10Z" +ACTION_RUN_AT = "2023-06-11T12:13:14Z" +REQUIRED_KEYS = { + "action", + "action_uuid", + "activation_id", + "activation_instance_id", + "reason", + "rule_run_at", + "run_at", + "rule", + "ruleset", + "rule_uuid", + "ruleset_uuid", + "status", + "type", + "matching_events", +} + + +def _validate(queue, metadata): + while not queue.empty(): + event = queue.get_nowait() + if event["type"] == "Action": + action = event + + assert action["action"] == "debug" + assert action["action_uuid"] == DUMMY_UUID + assert action["activation_id"] == settings.identifier + assert action["run_at"] == ACTION_RUN_AT + assert action["rule_run_at"] == metadata.rule_run_at + assert action["rule"] == metadata.rule + assert action["ruleset"] == metadata.rule_set + assert action["rule_uuid"] == metadata.rule_uuid + assert action["ruleset_uuid"] == metadata.rule_set_uuid + assert action["status"] == INTERNAL_ACTION_STATUS + assert action["type"] == "Action" + assert action["matching_events"] == {"m": {"a": 1}} + assert len(set(action.keys()).difference(REQUIRED_KEYS)) == 0 + + +@freeze_time("2023-06-11 12:13:14") +@pytest.mark.asyncio +async def test_debug(): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"event": {"a": 1}}, + project_data_file="", + ) + action_args = {} + + with patch("uuid.uuid4", return_value=DUMMY_UUID): + with patch( + "ansible_rulebook.action.run_job_template.lang.get_facts", + return_value={"a": 1}, + ) as drools_mock: + await Debug(metadata, control, **action_args)() + drools_mock.assert_called_once() + + _validate(queue, metadata) + + +MSG_DATA = [ + ("msg", "Simple Message"), + ("msg", ["First Message", "Second Message"]), +] + + +@freeze_time("2023-06-11 12:13:14") +@pytest.mark.parametrize("mtype, arg", MSG_DATA) +@pytest.mark.asyncio +async def test_debug_msg(mtype, arg): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"event": {"a": 1}}, + project_data_file="", + ) + action_args = {mtype: arg} + + with patch("uuid.uuid4", return_value=DUMMY_UUID): + await Debug(metadata, control, **action_args)() + + _validate(queue, metadata) + + +@freeze_time("2023-06-11 12:13:14") +@pytest.mark.asyncio +async def test_debug_var(): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"abc": {"xyz": 1}, "event": {"a": 1}}, + project_data_file="", + ) + action_args = {"var": "abc.xyz"} + + with patch("uuid.uuid4", return_value=DUMMY_UUID): + await Debug(metadata, control, **action_args)() + + _validate(queue, metadata) + + +@pytest.mark.asyncio +async def test_debug_var_missing_key(): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"abc": {"xyz": 1}, "event": {"a": 1}}, + project_data_file="", + ) + action_args = {"var": "abc.klm"} + + with pytest.raises(KeyError): + await Debug(metadata, control, **action_args)() diff --git a/tests/unit/action/test_noop.py b/tests/unit/action/test_noop.py new file mode 100644 index 000000000..ec9ff4f9a --- /dev/null +++ b/tests/unit/action/test_noop.py @@ -0,0 +1,91 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +from unittest.mock import patch + +import pytest +from freezegun import freeze_time + +from ansible_rulebook.action.control import Control +from ansible_rulebook.action.helper import INTERNAL_ACTION_STATUS +from ansible_rulebook.action.metadata import Metadata +from ansible_rulebook.action.noop import Noop +from ansible_rulebook.conf import settings + +DUMMY_UUID = "eb7de03f-6f8f-4943-b69e-3c90db346edf" +RULE_UUID = "abcdef3f-6f8f-4943-b69e-3c90db346edf" +RULE_SET_UUID = "00aabbcc-1111-2222-b69e-3c90db346edf" +RULE_RUN_AT = "2023-06-11T12:13:10Z" +ACTION_RUN_AT = "2023-06-11T12:13:14Z" + + +@freeze_time("2023-06-11 12:13:14") +@pytest.mark.asyncio +async def test_noop(): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"event": {"a": 1}}, + project_data_file="", + ) + action_args = {} + + with patch("uuid.uuid4", return_value=DUMMY_UUID): + await Noop(metadata, control, **action_args)() + + while not queue.empty(): + event = queue.get_nowait() + if event["type"] == "Action": + action = event + + required_keys = { + "action", + "action_uuid", + "activation_id", + "activation_instance_id", + "reason", + "rule_run_at", + "run_at", + "rule", + "ruleset", + "rule_uuid", + "ruleset_uuid", + "status", + "type", + "matching_events", + } + assert action["action"] == "noop" + assert action["action_uuid"] == DUMMY_UUID + assert action["activation_id"] == settings.identifier + assert action["activation_instance_id"] == settings.identifier + assert action["run_at"] == ACTION_RUN_AT + assert action["rule_run_at"] == metadata.rule_run_at + assert action["rule"] == metadata.rule + assert action["ruleset"] == metadata.rule_set + assert action["rule_uuid"] == metadata.rule_uuid + assert action["ruleset_uuid"] == metadata.rule_set_uuid + assert action["status"] == INTERNAL_ACTION_STATUS + assert action["type"] == "Action" + assert action["matching_events"] == {"m": {"a": 1}} + + assert len(set(action.keys()).difference(required_keys)) == 0 diff --git a/tests/unit/action/test_post_event.py b/tests/unit/action/test_post_event.py new file mode 100644 index 000000000..f2d0b3bea --- /dev/null +++ b/tests/unit/action/test_post_event.py @@ -0,0 +1,97 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +from unittest.mock import patch + +import pytest +from freezegun import freeze_time + +from ansible_rulebook.action.control import Control +from ansible_rulebook.action.helper import INTERNAL_ACTION_STATUS +from ansible_rulebook.action.metadata import Metadata +from ansible_rulebook.action.post_event import PostEvent +from ansible_rulebook.conf import settings + +DUMMY_UUID = "eb7de03f-6f8f-4943-b69e-3c90db346edf" +RULE_UUID = "abcdef3f-6f8f-4943-b69e-3c90db346edf" +RULE_SET_UUID = "00aabbcc-1111-2222-b69e-3c90db346edf" +RULE_RUN_AT = "2023-06-11T12:13:10Z" +ACTION_RUN_AT = "2023-06-11T12:13:14Z" + + +@freeze_time("2023-06-11 12:13:14") +@pytest.mark.asyncio +async def test_noop(): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"event": {"a": 1}}, + project_data_file="", + ) + action_args = {"event": {"b": 1}, "ruleset": metadata.rule_set} + + with patch("uuid.uuid4", return_value=DUMMY_UUID): + with patch( + "ansible_rulebook.action.run_job_template.lang.post" + ) as drools_mock: + await PostEvent(metadata, control, **action_args)() + drools_mock.assert_called_once_with( + action_args["ruleset"], action_args["event"] + ) + + while not queue.empty(): + event = queue.get_nowait() + if event["type"] == "Action": + action = event + + required_keys = { + "action", + "action_uuid", + "activation_id", + "activation_instance_id", + "reason", + "rule_run_at", + "run_at", + "rule", + "ruleset", + "rule_uuid", + "ruleset_uuid", + "status", + "type", + "matching_events", + } + assert action["action"] == "post_event" + assert action["action_uuid"] == DUMMY_UUID + assert action["activation_id"] == settings.identifier + assert action["activation_instance_id"] == settings.identifier + assert action["run_at"] == ACTION_RUN_AT + assert action["rule_run_at"] == metadata.rule_run_at + assert action["rule"] == metadata.rule + assert action["ruleset"] == metadata.rule_set + assert action["rule_uuid"] == metadata.rule_uuid + assert action["ruleset_uuid"] == metadata.rule_set_uuid + assert action["status"] == INTERNAL_ACTION_STATUS + assert action["type"] == "Action" + assert action["matching_events"] == {"m": {"a": 1}} + + assert len(set(action.keys()).difference(required_keys)) == 0 diff --git a/tests/unit/action/test_print_event.py b/tests/unit/action/test_print_event.py new file mode 100644 index 000000000..2cff0d183 --- /dev/null +++ b/tests/unit/action/test_print_event.py @@ -0,0 +1,91 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +from unittest.mock import patch + +import pytest +from freezegun import freeze_time + +from ansible_rulebook.action.control import Control +from ansible_rulebook.action.helper import INTERNAL_ACTION_STATUS +from ansible_rulebook.action.metadata import Metadata +from ansible_rulebook.action.print_event import PrintEvent +from ansible_rulebook.conf import settings + +DUMMY_UUID = "eb7de03f-6f8f-4943-b69e-3c90db346edf" +RULE_UUID = "abcdef3f-6f8f-4943-b69e-3c90db346edf" +RULE_SET_UUID = "00aabbcc-1111-2222-b69e-3c90db346edf" +RULE_RUN_AT = "2023-06-11T12:13:10Z" +ACTION_RUN_AT = "2023-06-11T12:13:14Z" + + +@freeze_time("2023-06-11 12:13:14") +@pytest.mark.asyncio +async def test_print_event(): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"event": {"a": 1}}, + project_data_file="", + ) + action_args = dict(pretty=True) + + with patch("uuid.uuid4", return_value=DUMMY_UUID): + await PrintEvent(metadata, control, **action_args)() + + while not queue.empty(): + event = queue.get_nowait() + if event["type"] == "Action": + action = event + + required_keys = { + "action", + "action_uuid", + "activation_id", + "activation_instance_id", + "reason", + "rule_run_at", + "run_at", + "rule", + "ruleset", + "rule_uuid", + "ruleset_uuid", + "status", + "type", + "matching_events", + } + assert action["action"] == "print_event" + assert action["action_uuid"] == DUMMY_UUID + assert action["activation_id"] == settings.identifier + assert action["activation_instance_id"] == settings.identifier + assert action["run_at"] == ACTION_RUN_AT + assert action["rule_run_at"] == metadata.rule_run_at + assert action["rule"] == metadata.rule + assert action["ruleset"] == metadata.rule_set + assert action["rule_uuid"] == metadata.rule_uuid + assert action["ruleset_uuid"] == metadata.rule_set_uuid + assert action["status"] == INTERNAL_ACTION_STATUS + assert action["type"] == "Action" + assert action["matching_events"] == {"m": {"a": 1}} + + assert len(set(action.keys()).difference(required_keys)) == 0 diff --git a/tests/unit/action/test_retract_fact.py b/tests/unit/action/test_retract_fact.py new file mode 100644 index 000000000..8fc694228 --- /dev/null +++ b/tests/unit/action/test_retract_fact.py @@ -0,0 +1,108 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +from unittest.mock import patch + +import pytest +from freezegun import freeze_time + +from ansible_rulebook.action.control import Control +from ansible_rulebook.action.helper import INTERNAL_ACTION_STATUS +from ansible_rulebook.action.metadata import Metadata +from ansible_rulebook.action.retract_fact import RetractFact +from ansible_rulebook.conf import settings + +DUMMY_UUID = "eb7de03f-6f8f-4943-b69e-3c90db346edf" +RULE_UUID = "abcdef3f-6f8f-4943-b69e-3c90db346edf" +RULE_SET_UUID = "00aabbcc-1111-2222-b69e-3c90db346edf" +RULE_RUN_AT = "2023-06-11T12:13:10Z" +ACTION_RUN_AT = "2023-06-11T12:13:14Z" + +TEST_DATA = [(True, []), (False, ["meta"])] + + +@freeze_time("2023-06-11 12:13:14") +@pytest.mark.parametrize("partial,keys_excluded", TEST_DATA) +@pytest.mark.asyncio +async def test_retract_fact(partial, keys_excluded): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"event": {"a": 1}}, + project_data_file="", + ) + action_args = { + "fact": {"b": 1}, + "ruleset": metadata.rule_set, + "partial": partial, + } + + with patch("uuid.uuid4", return_value=DUMMY_UUID): + with patch( + "ansible_rulebook.action.run_job_template." + "lang.retract_matching_facts" + ) as drools_mock: + await RetractFact(metadata, control, **action_args)() + drools_mock.assert_called_once_with( + action_args["ruleset"], + action_args["fact"], + partial, + keys_excluded, + ) + + while not queue.empty(): + event = queue.get_nowait() + if event["type"] == "Action": + action = event + + required_keys = { + "action", + "action_uuid", + "activation_id", + "activation_instance_id", + "reason", + "rule_run_at", + "run_at", + "rule", + "ruleset", + "rule_uuid", + "ruleset_uuid", + "status", + "type", + "matching_events", + } + assert action["action"] == "retract_fact" + assert action["action_uuid"] == DUMMY_UUID + assert action["activation_id"] == settings.identifier + assert action["activation_instance_id"] == settings.identifier + assert action["run_at"] == ACTION_RUN_AT + assert action["rule_run_at"] == metadata.rule_run_at + assert action["rule"] == metadata.rule + assert action["ruleset"] == metadata.rule_set + assert action["rule_uuid"] == metadata.rule_uuid + assert action["ruleset_uuid"] == metadata.rule_set_uuid + assert action["status"] == INTERNAL_ACTION_STATUS + assert action["type"] == "Action" + assert action["matching_events"] == {"m": {"a": 1}} + + assert len(set(action.keys()).difference(required_keys)) == 0 diff --git a/tests/unit/action/test_run_job_template.py b/tests/unit/action/test_run_job_template.py new file mode 100644 index 000000000..8646729e8 --- /dev/null +++ b/tests/unit/action/test_run_job_template.py @@ -0,0 +1,217 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +from unittest.mock import patch + +import pytest + +from ansible_rulebook.action.control import Control +from ansible_rulebook.action.metadata import Metadata +from ansible_rulebook.action.run_job_template import RunJobTemplate +from ansible_rulebook.exception import ( + ControllerApiException, + JobTemplateNotFoundException, +) + + +def _validate(queue, success, reason=None): + while not queue.empty(): + event = queue.get_nowait() + if event["type"] == "Action": + action = event + + assert action["action"] == "run_job_template" + if reason: + assert action["reason"] == reason + + required_keys = { + "action", + "action_uuid", + "activation_id", + "activation_instance_id", + "reason", + "rule_run_at", + "run_at", + "rule", + "ruleset", + "rule_uuid", + "ruleset_uuid", + "status", + "type", + "job_template_name", + "matching_events", + "url", + "organization", + "job_id", + } + + if not success: + required_keys.add("message") + + x = set(action.keys()).difference(required_keys) + assert len(x) == 0 + + +JOB_TEMPLATE_ERRORS = [ + ("api error", ControllerApiException("api error")), + ("jt does not exist", JobTemplateNotFoundException("jt does not exist")), +] + + +@pytest.mark.parametrize("err_msg,err", JOB_TEMPLATE_ERRORS) +@pytest.mark.asyncio +async def test_run_job_template_exception(err_msg, err): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid="u1", + rule_set_uuid="u2", + rule_run_at="abc", + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"a": 1}, + project_data_file="", + ) + action_args = { + "name": "fred", + "set_facts": True, + "organization": "Default", + "retries": 0, + "retry": True, + "delay": 0, + } + with patch( + "ansible_rulebook.action.run_job_template." + "job_template_runner.run_job_template", + side_effect=err, + ): + await RunJobTemplate(metadata, control, **action_args)() + _validate(queue, False, {"error": err_msg}) + + +DROOLS_CALLS = [ + ( + "ansible_rulebook.action.run_job_template.lang.assert_fact", + dict(set_facts=True), + ), + ( + "ansible_rulebook.action.run_job_template.lang.post", + dict(post_events=True), + ), +] + + +@pytest.mark.parametrize("drools_call,additional_args", DROOLS_CALLS) +@pytest.mark.asyncio +async def test_run_job_template(drools_call, additional_args): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid="u1", + rule_set_uuid="u2", + rule_run_at="abc", + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"a": 1}, + project_data_file="", + ) + action_args = { + "name": "fred", + "organization": "Default", + "retries": 0, + "retry": True, + "delay": 0, + } + action_args.update(additional_args) + controller_job = { + "status": "failed", + "rc": 0, + "artifacts": dict(b=1), + "created": "abc", + "id": 10, + } + with patch( + "ansible_rulebook.action.run_job_template." + "job_template_runner.run_job_template", + return_value=controller_job, + ): + with patch(drools_call) as drools_mock: + await RunJobTemplate(metadata, control, **action_args)() + drools_mock.assert_called_once() + + _validate(queue, True) + + +@pytest.mark.asyncio +async def test_run_job_template_retries(): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid="u1", + rule_set_uuid="u2", + rule_run_at="abc", + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"a": 1}, + project_data_file="", + ) + action_args = { + "name": "fred", + "organization": "Default", + "retries": 1, + "retry": True, + "delay": 1, + "set_facts": True, + } + controller_job = [ + { + "status": "failed", + "rc": 0, + "artifacts": dict(b=1), + "created": "abc", + "id": 10, + }, + { + "status": "success", + "rc": 0, + "artifacts": dict(b=1), + "created": "abc", + "id": 10, + }, + ] + + with patch( + "ansible_rulebook.action.run_job_template." + "job_template_runner.run_job_template", + side_effect=controller_job, + ): + with patch( + "ansible_rulebook.action.run_job_template.lang.assert_fact" + ) as drools_mock: + await RunJobTemplate(metadata, control, **action_args)() + drools_mock.assert_called_once() + + _validate(queue, True) diff --git a/tests/unit/action/test_run_module.py b/tests/unit/action/test_run_module.py new file mode 100644 index 000000000..408e95762 --- /dev/null +++ b/tests/unit/action/test_run_module.py @@ -0,0 +1,103 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +import os +from unittest.mock import patch + +import pytest +from freezegun import freeze_time + +from ansible_rulebook.action.control import Control +from ansible_rulebook.action.metadata import Metadata +from ansible_rulebook.action.run_module import RunModule +from ansible_rulebook.conf import settings + +DUMMY_UUID = "eb7de03f-6f8f-4943-b69e-3c90db346edf" +RULE_UUID = "abcdef3f-6f8f-4943-b69e-3c90db346edf" +RULE_SET_UUID = "00aabbcc-1111-2222-b69e-3c90db346edf" +RULE_RUN_AT = "2023-06-11T12:13:10Z" +ACTION_RUN_AT = "2023-06-11T12:13:14Z" +HERE = os.path.dirname(os.path.abspath(__file__)) +INVENTORY_FILE = os.path.join(HERE, "../../playbooks/inventory.yml") + + +def _validate(queue, metadata, status, rc): + while not queue.empty(): + event = queue.get_nowait() + if event["type"] == "Action": + action = event + + required_keys = { + "action", + "action_uuid", + "activation_id", + "activation_instance_id", + "reason", + "rule_run_at", + "run_at", + "rule", + "ruleset", + "rule_uuid", + "ruleset_uuid", + "status", + "type", + "matching_events", + "job_id", + "playbook_name", + "rc", + } + assert action["action"] == "run_module" + assert action["action_uuid"] == DUMMY_UUID + assert action["activation_id"] == settings.identifier + assert action["run_at"] == ACTION_RUN_AT + assert action["rule_run_at"] == metadata.rule_run_at + assert action["rule"] == metadata.rule + assert action["ruleset"] == metadata.rule_set + assert action["rule_uuid"] == metadata.rule_uuid + assert action["ruleset_uuid"] == metadata.rule_set_uuid + assert action["status"] == status + assert action["rc"] == rc + assert action["type"] == "Action" + assert action["matching_events"] == {"m_0": {"a": 1}, "m_1": {"b": 2}} + + assert len(set(action.keys()).difference(required_keys)) == 0 + + +@freeze_time("2023-06-11 12:13:14") +@pytest.mark.asyncio +async def test_run_module(): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory=INVENTORY_FILE, + hosts=["localhost"], + variables={"events": {"m_0": {"a": 1}, "m_1": {"b": 2}}}, + project_data_file="", + ) + action_args = { + "module_args": {"name": "Fred Flintstone"}, + "name": "ansible.eda.upcase", + } + + with patch("uuid.uuid4", return_value=DUMMY_UUID): + await RunModule(metadata, control, **action_args)() + + _validate(queue, metadata, "successful", 0) diff --git a/tests/unit/action/test_run_playbook.py b/tests/unit/action/test_run_playbook.py new file mode 100644 index 000000000..d4d888ec4 --- /dev/null +++ b/tests/unit/action/test_run_playbook.py @@ -0,0 +1,200 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +import os +from unittest.mock import patch + +import pytest +from freezegun import freeze_time + +from ansible_rulebook.action.control import Control +from ansible_rulebook.action.metadata import Metadata +from ansible_rulebook.action.run_playbook import RunPlaybook +from ansible_rulebook.conf import settings +from ansible_rulebook.exception import PlaybookNotFoundException + +DUMMY_UUID = "eb7de03f-6f8f-4943-b69e-3c90db346edf" +RULE_UUID = "abcdef3f-6f8f-4943-b69e-3c90db346edf" +RULE_SET_UUID = "00aabbcc-1111-2222-b69e-3c90db346edf" +RULE_RUN_AT = "2023-06-11T12:13:10Z" +ACTION_RUN_AT = "2023-06-11T12:13:14Z" +HERE = os.path.dirname(os.path.abspath(__file__)) +INVENTORY_FILE = os.path.join(HERE, "../../playbooks/inventory.yml") + + +def _validate(queue, metadata, status, rc): + while not queue.empty(): + event = queue.get_nowait() + if event["type"] == "Action": + action = event + + required_keys = { + "action", + "action_uuid", + "activation_id", + "activation_instance_id", + "ansible_rulebook_id", + "reason", + "rule_run_at", + "run_at", + "rule", + "ruleset", + "rule_uuid", + "ruleset_uuid", + "status", + "type", + "matching_events", + "job_id", + "playbook_name", + "rc", + } + assert action["action"] == "run_playbook" + assert action["action_uuid"] == DUMMY_UUID + assert action["activation_id"] == settings.identifier + assert action["run_at"] == ACTION_RUN_AT + assert action["rule_run_at"] == metadata.rule_run_at + assert action["rule"] == metadata.rule + assert action["ruleset"] == metadata.rule_set + assert action["rule_uuid"] == metadata.rule_uuid + assert action["ruleset_uuid"] == metadata.rule_set_uuid + assert action["status"] == status + assert action["rc"] == rc + assert action["type"] == "Action" + assert action["matching_events"] == {"m": {"a": 1}} + + assert len(set(action.keys()).difference(required_keys)) == 0 + + +HERE = os.path.dirname(os.path.abspath(__file__)) + +DROOLS_CALLS = [ + ( + "ansible_rulebook.action.run_job_template.lang.assert_fact", + dict(set_facts=True), + ), + ( + "ansible_rulebook.action.run_job_template.lang.post", + dict(post_events=True), + ), +] + + +@pytest.mark.parametrize("drools_call,additional_args", DROOLS_CALLS) +@pytest.mark.asyncio +@freeze_time("2023-06-11 12:13:14") +@pytest.mark.asyncio +async def test_run_playbook(drools_call, additional_args): + os.chdir(HERE) + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory=INVENTORY_FILE, + hosts=["all"], + variables={"event": {"a": 1}}, + project_data_file="", + ) + action_args = { + "ruleset": metadata.rule_set, + "name": "./playbooks/rule_name.yml", + } + action_args.update(additional_args) + + set_fact_args = { + "results": { + "my_rule_name": metadata.rule, + "my_rule_set_name": metadata.rule_set, + }, + "meta": { + "source": {"name": "run_playbook", "type": "internal"}, + "received_at": ACTION_RUN_AT, + "uuid": DUMMY_UUID, + }, + } + + with patch("uuid.uuid4", return_value=DUMMY_UUID): + with patch(drools_call) as drools_mock: + await RunPlaybook(metadata, control, **action_args)() + drools_mock.assert_called_once_with( + action_args["ruleset"], set_fact_args + ) + + _validate(queue, metadata, "successful", 0) + + +@freeze_time("2023-06-11 12:13:14") +@pytest.mark.asyncio +async def test_run_playbook_missing(): + os.chdir(HERE) + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory=INVENTORY_FILE, + hosts=["all"], + variables={"event": {"a": 1}}, + project_data_file="", + ) + action_args = { + "ruleset": metadata.rule_set, + "name": "./playbooks/does_not_exist.yml", + "set_facts": True, + } + + with patch("uuid.uuid4", return_value=DUMMY_UUID): + with pytest.raises(PlaybookNotFoundException): + await RunPlaybook(metadata, control, **action_args)() + + +@freeze_time("2023-06-11 12:13:14") +@pytest.mark.asyncio +async def test_run_playbook_fail(): + os.chdir(HERE) + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory=INVENTORY_FILE, + hosts=["all"], + variables={"event": {"a": 1}}, + project_data_file="", + ) + action_args = { + "ruleset": metadata.rule_set, + "name": "./playbooks/fail.yml", + "set_facts": True, + } + + with patch("uuid.uuid4", return_value=DUMMY_UUID): + await RunPlaybook(metadata, control, **action_args)() + + _validate(queue, metadata, "failed", 2) diff --git a/tests/unit/action/test_run_workflow_template.py b/tests/unit/action/test_run_workflow_template.py new file mode 100644 index 000000000..c780fe175 --- /dev/null +++ b/tests/unit/action/test_run_workflow_template.py @@ -0,0 +1,220 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +from unittest.mock import patch + +import pytest + +from ansible_rulebook.action.control import Control +from ansible_rulebook.action.metadata import Metadata +from ansible_rulebook.action.run_workflow_template import RunWorkflowTemplate +from ansible_rulebook.exception import ( + ControllerApiException, + WorkflowJobTemplateNotFoundException, +) + + +def _validate(queue, success, reason=None): + while not queue.empty(): + event = queue.get_nowait() + if event["type"] == "Action": + action = event + + assert action["action"] == "run_workflow_template" + if reason: + assert action["reason"] == reason + + required_keys = { + "action", + "action_uuid", + "activation_id", + "activation_instance_id", + "reason", + "rule_run_at", + "run_at", + "rule", + "ruleset", + "rule_uuid", + "ruleset_uuid", + "status", + "type", + "name", + "matching_events", + "url", + "organization", + "job_id", + } + + if not success: + required_keys.add("message") + + x = set(action.keys()).difference(required_keys) + assert len(x) == 0 + + +WORKFLOW_TEMPLATE_ERRORS = [ + ("api error", ControllerApiException("api error")), + ( + "wf does not exist", + WorkflowJobTemplateNotFoundException("wf does not exist"), + ), +] + + +@pytest.mark.parametrize("err_msg,err", WORKFLOW_TEMPLATE_ERRORS) +@pytest.mark.asyncio +async def test_run_workflow_template_exception(err_msg, err): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid="u1", + rule_set_uuid="u2", + rule_run_at="abc", + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"a": 1}, + project_data_file="", + ) + action_args = { + "name": "fred", + "set_facts": True, + "organization": "Default", + "retries": 0, + "retry": True, + "delay": 0, + } + with patch( + "ansible_rulebook.action.run_workflow_template." + "job_template_runner.run_workflow_job_template", + side_effect=err, + ): + await RunWorkflowTemplate(metadata, control, **action_args)() + _validate(queue, False, {"error": err_msg}) + + +DROOLS_CALLS = [ + ( + "ansible_rulebook.action.run_workflow_template.lang.assert_fact", + dict(set_facts=True), + ), + ( + "ansible_rulebook.action.run_workflow_template.lang.post", + dict(post_events=True), + ), +] + + +@pytest.mark.parametrize("drools_call,additional_args", DROOLS_CALLS) +@pytest.mark.asyncio +async def test_run_workflow_template(drools_call, additional_args): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid="u1", + rule_set_uuid="u2", + rule_run_at="abc", + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"a": 1}, + project_data_file="", + ) + action_args = { + "name": "fred", + "organization": "Default", + "retries": 0, + "retry": True, + "delay": 0, + } + action_args.update(additional_args) + controller_job = { + "status": "failed", + "rc": 0, + "artifacts": dict(b=1), + "created": "abc", + "id": 10, + } + with patch( + "ansible_rulebook.action.run_workflow_template." + "job_template_runner.run_workflow_job_template", + return_value=controller_job, + ): + with patch(drools_call) as drools_mock: + await RunWorkflowTemplate(metadata, control, **action_args)() + drools_mock.assert_called_once() + + _validate(queue, True) + + +@pytest.mark.asyncio +async def test_run_workflow_template_retries(): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid="u1", + rule_set_uuid="u2", + rule_run_at="abc", + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"a": 1}, + project_data_file="", + ) + action_args = { + "name": "fred", + "organization": "Default", + "retries": 1, + "retry": True, + "delay": 1, + "set_facts": True, + } + controller_job = [ + { + "status": "failed", + "rc": 0, + "artifacts": dict(b=1), + "created": "abc", + "id": 10, + }, + { + "status": "success", + "rc": 0, + "artifacts": dict(b=1), + "created": "abc", + "id": 10, + }, + ] + + with patch( + "ansible_rulebook.action.run_workflow_template." + "job_template_runner.run_workflow_job_template", + side_effect=controller_job, + ): + with patch( + "ansible_rulebook.action.run_workflow_template.lang.assert_fact" + ) as drools_mock: + await RunWorkflowTemplate(metadata, control, **action_args)() + drools_mock.assert_called_once() + + _validate(queue, True) diff --git a/tests/unit/action/test_set_fact.py b/tests/unit/action/test_set_fact.py new file mode 100644 index 000000000..f06c7dfbb --- /dev/null +++ b/tests/unit/action/test_set_fact.py @@ -0,0 +1,96 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +from unittest.mock import patch + +import pytest +from freezegun import freeze_time + +from ansible_rulebook.action.control import Control +from ansible_rulebook.action.helper import INTERNAL_ACTION_STATUS +from ansible_rulebook.action.metadata import Metadata +from ansible_rulebook.action.set_fact import SetFact +from ansible_rulebook.conf import settings + +DUMMY_UUID = "eb7de03f-6f8f-4943-b69e-3c90db346edf" +RULE_UUID = "abcdef3f-6f8f-4943-b69e-3c90db346edf" +RULE_SET_UUID = "00aabbcc-1111-2222-b69e-3c90db346edf" +RULE_RUN_AT = "2023-06-11T12:13:10Z" +ACTION_RUN_AT = "2023-06-11T12:13:14Z" + + +@freeze_time("2023-06-11 12:13:14") +@pytest.mark.asyncio +async def test_noop(): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"event": {"a": 1}}, + project_data_file="", + ) + action_args = {"fact": {"b": 1}, "ruleset": metadata.rule_set} + + with patch("uuid.uuid4", return_value=DUMMY_UUID): + with patch( + "ansible_rulebook.action.run_job_template.lang.assert_fact" + ) as drools_mock: + await SetFact(metadata, control, **action_args)() + drools_mock.assert_called_once_with( + action_args["ruleset"], action_args["fact"] + ) + + while not queue.empty(): + event = queue.get_nowait() + if event["type"] == "Action": + action = event + + required_keys = { + "action", + "action_uuid", + "activation_id", + "activation_instance_id", + "reason", + "rule_run_at", + "run_at", + "rule", + "ruleset", + "rule_uuid", + "ruleset_uuid", + "status", + "type", + "matching_events", + } + assert action["action"] == "set_fact" + assert action["action_uuid"] == DUMMY_UUID + assert action["activation_id"] == settings.identifier + assert action["run_at"] == ACTION_RUN_AT + assert action["rule_run_at"] == metadata.rule_run_at + assert action["rule"] == metadata.rule + assert action["ruleset"] == metadata.rule_set + assert action["rule_uuid"] == metadata.rule_uuid + assert action["ruleset_uuid"] == metadata.rule_set_uuid + assert action["status"] == INTERNAL_ACTION_STATUS + assert action["type"] == "Action" + assert action["matching_events"] == {"m": {"a": 1}} + + assert len(set(action.keys()).difference(required_keys)) == 0 diff --git a/tests/unit/action/test_shutdown.py b/tests/unit/action/test_shutdown.py new file mode 100644 index 000000000..15036355b --- /dev/null +++ b/tests/unit/action/test_shutdown.py @@ -0,0 +1,95 @@ +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +from unittest.mock import patch + +import pytest +from freezegun import freeze_time + +from ansible_rulebook.action.control import Control +from ansible_rulebook.action.helper import INTERNAL_ACTION_STATUS +from ansible_rulebook.action.metadata import Metadata +from ansible_rulebook.action.shutdown import Shutdown +from ansible_rulebook.conf import settings +from ansible_rulebook.exception import ShutdownException + +DUMMY_UUID = "eb7de03f-6f8f-4943-b69e-3c90db346edf" +RULE_UUID = "abcdef3f-6f8f-4943-b69e-3c90db346edf" +RULE_SET_UUID = "00aabbcc-1111-2222-b69e-3c90db346edf" +RULE_RUN_AT = "2023-06-11T12:13:10Z" +ACTION_RUN_AT = "2023-06-11T12:13:14Z" + + +@freeze_time("2023-06-11 12:13:14") +@pytest.mark.asyncio +async def test_shutdown(): + queue = asyncio.Queue() + metadata = Metadata( + rule="r1", + rule_set="rs1", + rule_uuid=RULE_UUID, + rule_set_uuid=RULE_SET_UUID, + rule_run_at=RULE_RUN_AT, + ) + control = Control( + queue=queue, + inventory="abc", + hosts=["all"], + variables={"event": {"a": 1}}, + project_data_file="", + ) + action_args = dict(delay=60, message="Testing Shutdown") + + with patch("uuid.uuid4", return_value=DUMMY_UUID): + with pytest.raises(ShutdownException): + await Shutdown(metadata, control, **action_args)() + + while not queue.empty(): + event = queue.get_nowait() + if event["type"] == "Action": + action = event + + required_keys = { + "action", + "action_uuid", + "activation_id", + "activation_instance_id", + "reason", + "rule_run_at", + "run_at", + "rule", + "ruleset", + "rule_uuid", + "ruleset_uuid", + "status", + "type", + "matching_events", + "delay", + "message", + "kind", + } + assert action["action"] == "shutdown" + assert action["action_uuid"] == DUMMY_UUID + assert action["activation_id"] == settings.identifier + assert action["run_at"] == ACTION_RUN_AT + assert action["rule_run_at"] == metadata.rule_run_at + assert action["rule"] == metadata.rule + assert action["ruleset"] == metadata.rule_set + assert action["rule_uuid"] == metadata.rule_uuid + assert action["ruleset_uuid"] == metadata.rule_set_uuid + assert action["status"] == INTERNAL_ACTION_STATUS + assert action["type"] == "Action" + assert action["matching_events"] == {"m": {"a": 1}} + + assert len(set(action.keys()).difference(required_keys)) == 0