diff --git a/README.md b/README.md index 8d8df0c..9dd9884 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Name | Description ### Modules Name | Description --- | --- +[ansible.utils.fact_diff](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.fact_diff_module.rst)|Find the difference between currently set facts [ansible.utils.update_fact](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.update_fact_module.rst)|Update currently set facts diff --git a/changelogs/fragments/add_fact_diff.yaml b/changelogs/fragments/add_fact_diff.yaml new file mode 100644 index 0000000..85fcd42 --- /dev/null +++ b/changelogs/fragments/add_fact_diff.yaml @@ -0,0 +1,3 @@ +--- +minor_changes: + - Add fact_diff module. Find the difference between text, files or facts \ No newline at end of file diff --git a/docs/ansible.utils.fact_diff_module.rst b/docs/ansible.utils.fact_diff_module.rst new file mode 100644 index 0000000..8f994bb --- /dev/null +++ b/docs/ansible.utils.fact_diff_module.rst @@ -0,0 +1,340 @@ +.. _ansible.utils.fact_diff_module: + + +*********************** +ansible.utils.fact_diff +*********************** + +**Find the difference between currently set facts** + + +Version added: 1.0.0 + +.. contents:: + :local: + :depth: 1 + + +Synopsis +-------- +- Compare two facts or variables and get a diff + + + + +Parameters +---------- + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterChoices/DefaultsComments
+
+ after + +
+ raw + / required +
+
+ +
The second fact to be used in the comparison
+
+
+ before + +
+ raw + / required +
+
+ +
The first fact to be used in the comparison
+
+
+ plugin + +
+ dictionary +
+
+ Default:
{}
+
+
Configure and specify the diff plugin to use
+
+
+ name + +
+ string +
+
+ Default:
"ansible.utils.native"
+
+
The diff plugin to use, in collection format
+
+
+ vars + +
+ dictionary +
+
+ Default:
{}
+
+
Parameters passed to the diff plugin
+
+
+ skip_lines + +
+ list +
+
+ +
Skip lines matching these regular expressions
+
Matches will be removed prior to the diff
+
If the provided before and after are a string, they will be split
+
Each entry in each list will be cast to a string for the comparison
+
+
+ + + + +Examples +-------- + +.. code-block:: yaml + + - set_fact: + before: + a: + b: + c: + d: + - 0 + - 1 + after: + a: + b: + c: + d: + - 2 + - 3 + + - name: Show the difference in json format + ansible.utils.fact_diff: + before: "{{ before }}" + after: "{{ after }}" + + # TASK [ansible.utils.fact_diff] ************************************** + # --- before + # +++ after + # @@ -3,8 +3,8 @@ + # "b": { + # "c": { + # "d": [ + # - 0, + # - 1 + # + 2, + # + 3 + # ] + # } + # } + # + # changed: [localhost] + + - name: Show the difference in path format + ansible.utils.fact_diff: + before: "{{ before|ansible.utils.to_paths }}" + after: "{{ after|ansible.utils.to_paths }}" + + # TASK [ansible.utils.fact_diff] ************************************** + # --- before + # +++ after + # @@ -1,4 +1,4 @@ + # { + # - "a.b.c.d[0]": 0, + # - "a.b.c.d[1]": 1 + # + "a.b.c.d[0]": 2, + # + "a.b.c.d[1]": 3 + # } + # + # changed: [localhost] + + - name: Show the difference in yaml format + ansible.utils.fact_diff: + before: "{{ before|to_nice_yaml }}" + after: "{{ after|to_nice_yaml }}" + + # TASK [ansible.utils.fact_diff] ************************************** + # --- before + # +++ after + # @@ -2,5 +2,5 @@ + # b: + # c: + # d: + # - - 0 + # - - 1 + # + - 2 + # + - 3 + + # changed: [localhost] + + - name: Show the difference in yaml format + ansible.utils.fact_diff: + before: "{{ before }}" + after: "{{ before }}" + + + #### Show the difference between complex object using restconf + # ansible_connection: ansible.netcommon.httpapi + # ansible_httpapi_use_ssl: True + # ansible_httpapi_validate_certs: False + # ansible_network_os: ansible.netcommon.restconf + + - name: Get the current interface config prior ro changes + ansible.netcommon.restconf_get: + content: config + path: /data/Cisco-NX-OS-device:System/intf-items/phys-items + register: pre + + - name: Update the description of eth1/100 + ansible.utils.update_fact: + updates: + - path: "pre['response']['phys-items']['PhysIf-list'][{{ index }}]['descr']" + value: "Configured by ansible {{ 100 | random }}" + vars: + index: "{{ pre['response']['phys-items']['PhysIf-list']|ansible.utils.index_of('eq', 'eth1/100', 'id') }}" + register: updated + + - name: Apply the configuration + ansible.netcommon.restconf_config: + path: 'data/Cisco-NX-OS-device:System/intf-items/' + content: "{{ updated.pre.response}}" + method: patch + + - name: Get the current interface config after changes + ansible.netcommon.restconf_get: + content: config + path: /data/Cisco-NX-OS-device:System/intf-items/phys-items + register: post + + - name: Show the difference + ansible.utils.fact_diff: + before: "{{ pre.response|ansible.utils.to_paths }}" + after: "{{ post.response|ansible.utils.to_paths }}" + + # TASK [ansible.utils.fact_diff] ********************************************* + # --- before + # +++ after + # @@ -3604,7 +3604,7 @@ + # "phys-items['PhysIf-list'][37].bw": "0", + # "phys-items['PhysIf-list'][37].controllerId": "", + # "phys-items['PhysIf-list'][37].delay": "1", + # - "phys-items['PhysIf-list'][37].descr": "Configured by ansible 95", + # + "phys-items['PhysIf-list'][37].descr": "Configured by ansible 20", + # "phys-items['PhysIf-list'][37].dot1qEtherType": "0x8100", + # "phys-items['PhysIf-list'][37].duplex": "auto", + # "phys-items['PhysIf-list'][37].id": "eth1/100", + + # changed: [nxos101] + + + +Return Values +------------- +Common return values are documented `here `_, the following are the fields unique to this module: + +.. raw:: html + + + + + + + + + + + + + + + + + +
KeyReturnedDescription
+
+ diff_lines + +
+ list +
+
always +
The diff_text split into lines
+
+
+
+ diff_text + +
+ string +
+
always +
The diff in text format
+
+
+

+ + +Status +------ + + +Authors +~~~~~~~ + +- Bradley Thornton (@cidrblock) diff --git a/plugins/action/fact_diff.py b/plugins/action/fact_diff.py new file mode 100644 index 0000000..86a141b --- /dev/null +++ b/plugins/action/fact_diff.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# Copyafter 2020 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +import re +from importlib import import_module +from ansible.plugins.action import ActionBase +from ansible.errors import AnsibleActionFail +from ansible.module_utils._text import to_native +from ansible_collections.ansible.utils.plugins.modules.fact_diff import ( + DOCUMENTATION, +) +from ansible_collections.ansible.utils.plugins.module_utils.common.argspec_validate import ( + AnsibleArgSpecValidator, +) + + +class ActionModule(ActionBase): + """action module""" + + def __init__(self, *args, **kwargs): + super(ActionModule, self).__init__(*args, **kwargs) + self._supports_async = True + self._task_vars = None + + def _check_argspec(self): + aav = AnsibleArgSpecValidator( + data=self._task.args, + schema=DOCUMENTATION, + schema_format="doc", + name=self._task.action, + ) + valid, errors, self._task.args = aav.validate() + if not valid: + self._result["failed"] = True + self._result["msg"] = errors + + def _debug(self, msg): + """Output text using ansible's display + + :param msg: The message + :type msg: str + """ + msg = "<{phost}> [fact_diff][{plugin}] {msg}".format( + phost=self._playhost, plugin=self._plugin, msg=msg + ) + self._display.vvvv(msg) + + def _load_plugin(self, plugin, directory, class_name): + """Load a plugin from the fs + + :param plugin: The name of the plugin in collection format + :type plugin: string + :param directory: The name of the plugin directory to use + :type directory: string + :param class_name: The name of the class to load from the plugin + :type class_name: string + :return: An instance of class class_name + :rtype: class_name + """ + if len(plugin.split(".")) != 3: + msg = "Plugin name should be provided as a full name including collection" + self._result["failed"] = True + self._result["msg"] = msg + return None + cref = dict(zip(["corg", "cname", "plugin"], plugin.split("."))) + cref.update(directory=directory) + parserlib = "ansible_collections.{corg}.{cname}.plugins.{directory}.{plugin}".format( + **cref + ) + try: + class_obj = getattr(import_module(parserlib), class_name) + class_instance = class_obj( + task_args=self._task.args, + task_vars=self._task_vars, + debug=self._debug, + ) + return class_instance + except Exception as exc: + self._result["failed"] = True + self._result[ + "msg" + ] = "Error loading plugin '{plugin}': {err}".format( + plugin=plugin, err=to_native(exc) + ) + return None + + def _run_diff(self, plugin_instance): + try: + result = plugin_instance.diff() + if "errors" in result: + self._result["failed"] = True + self._result["msg"] = result["errors"] + return result + + except Exception as exc: + msg = "Unhandled exception from plugin '{plugin}'. Error: {err}".format( + plugin=self._task.args["plugin"]["name"], err=to_native(exc) + ) + self._result["failed"] = True + self._result["msg"] = msg + return None + + def run(self, tmp=None, task_vars=None): + self._task.diff = True + self._result = super(ActionModule, self).run(tmp, task_vars) + self._task_vars = task_vars + self._playhost = task_vars.get("inventory_hostname") + + self._check_argspec() + if self._result.get("failed"): + return self._result + + self._plugin = self._task.args["plugin"]["name"] + plugin_instance = self._load_plugin( + self._plugin, "fact_diff", "FactDiff" + ) + if self._result.get("failed"): + return self._result + + result = self._run_diff(plugin_instance) + if self._result.get("failed"): + return self._result + + ansi_escape = re.compile(r"\x1B[@-_][0-?]*[ -/]*[@-~]") + diff_text = ansi_escape.sub("", result["diff"]) + self._result.update( + { + "diff": {"prepared": result["diff"]}, + "changed": bool(result["diff"]), + "diff_lines": diff_text.splitlines(), + "diff_text": diff_text, + } + ) + return self._result diff --git a/plugins/fact_diff/native.py b/plugins/fact_diff/native.py new file mode 100644 index 0000000..d841892 --- /dev/null +++ b/plugins/fact_diff/native.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Copyafter 2020 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import re +from ansible.plugins.callback import CallbackBase +from ansible_collections.ansible.utils.plugins.module_utils.base_classes.fact_diff import ( + FactDiffBase, +) + + +class FactDiff(FactDiffBase): + def _check_valid_regexes(self): + if self._skip_lines: + self._debug("Checking regex in 'split_lines' for validity") + for idx, regex in enumerate(self._skip_lines): + try: + self._skip_lines[idx] = re.compile(regex) + except re.error as exc: + msg = "The regex '{regex}', is not valid. The error was {err}.".format( + regex=regex, err=str(exc) + ) + self._errors.append(msg) + + def _xform(self): + if self._skip_lines: + if isinstance(self._before, str): + self._debug("'before' is a string, splitting lines") + self._before = self._before.splitlines() + if isinstance(self._after, str): + self._debug("'after' is a string, splitting lines") + self._after = self._after.splitlines() + self._before = [ + l + for l in self._before + if not any(regex.match(str(l)) for regex in self._skip_lines) + ] + self._after = [ + l + for l in self._after + if not any(regex.match(str(l)) for regex in self._skip_lines) + ] + if isinstance(self._before, list): + self._debug("'before' is a list, joining with \n") + self._before = "\n".join(map(str, self._before)) + "\n" + if isinstance(self._after, list): + self._debug("'after' is a list, joining with \n") + self._after = "\n".join(map(str, self._after)) + "\n" + + def diff(self): + self._after = self._task_args["after"] + self._before = self._task_args["before"] + self._errors = [] + self._skip_lines = self._task_args["plugin"]["vars"].get("skip_lines") + self._check_valid_regexes() + if self._errors: + return {"errors": " ".join(self._errors)} + self._xform() + diff = CallbackBase()._get_diff( + {"before": self._before, "after": self._after} + ) + return {"diff": diff} diff --git a/plugins/module_utils/base_classes/fact_diff.py b/plugins/module_utils/base_classes/fact_diff.py new file mode 100644 index 0000000..10766e4 --- /dev/null +++ b/plugins/module_utils/base_classes/fact_diff.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Copyafter 2020 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +class FactDiffBase: + def __init__(self, task_args, task_vars, debug): + self._debug = debug + self._task_args = task_args + self._task_vars = task_vars diff --git a/plugins/modules/fact_diff.py b/plugins/modules/fact_diff.py new file mode 100644 index 0000000..0817d8e --- /dev/null +++ b/plugins/modules/fact_diff.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +DOCUMENTATION = r""" +--- +module: fact_diff +short_description: Find the difference between currently set facts +version_added: "1.0.0" +description: + - Compare two facts or variables and get a diff +options: + before: + description: + - The first fact to be used in the comparison + type: raw + required: True + after: + description: + - The second fact to be used in the comparison + type: raw + required: True + plugin: + description: + - Configure and specify the diff plugin to use + type: dict + default: {} + suboptions: + name: + description: + - The diff plugin to use, in collection format + default: ansible.utils.native + type: str + vars: + description: + - Parameters passed to the diff plugin + type: dict + default: {} + suboptions: + skip_lines: + description: + - Skip lines matching these regular expressions + - Matches will be removed prior to the diff + - If the provided I(before) and I(after) are a string, they will be split + - Each entry in each list will be cast to a string for the comparison + type: list + +notes: + +author: +- Bradley Thornton (@cidrblock) +""" + +EXAMPLES = r""" +- set_fact: + before: + a: + b: + c: + d: + - 0 + - 1 + after: + a: + b: + c: + d: + - 2 + - 3 + +- name: Show the difference in json format + ansible.utils.fact_diff: + before: "{{ before }}" + after: "{{ after }}" + +# TASK [ansible.utils.fact_diff] ************************************** +# --- before +# +++ after +# @@ -3,8 +3,8 @@ +# "b": { +# "c": { +# "d": [ +# - 0, +# - 1 +# + 2, +# + 3 +# ] +# } +# } +# +# changed: [localhost] + +- name: Show the difference in path format + ansible.utils.fact_diff: + before: "{{ before|ansible.utils.to_paths }}" + after: "{{ after|ansible.utils.to_paths }}" + +# TASK [ansible.utils.fact_diff] ************************************** +# --- before +# +++ after +# @@ -1,4 +1,4 @@ +# { +# - "a.b.c.d[0]": 0, +# - "a.b.c.d[1]": 1 +# + "a.b.c.d[0]": 2, +# + "a.b.c.d[1]": 3 +# } +# +# changed: [localhost] + +- name: Show the difference in yaml format + ansible.utils.fact_diff: + before: "{{ before|to_nice_yaml }}" + after: "{{ after|to_nice_yaml }}" + +# TASK [ansible.utils.fact_diff] ************************************** +# --- before +# +++ after +# @@ -2,5 +2,5 @@ +# b: +# c: +# d: +# - - 0 +# - - 1 +# + - 2 +# + - 3 + +# changed: [localhost] + + +#### Show the difference between complex object using restconf +# ansible_connection: ansible.netcommon.httpapi +# ansible_httpapi_use_ssl: True +# ansible_httpapi_validate_certs: False +# ansible_network_os: ansible.netcommon.restconf + +- name: Get the current interface config prior to changes + ansible.netcommon.restconf_get: + content: config + path: /data/Cisco-NX-OS-device:System/intf-items/phys-items + register: pre + +- name: Update the description of eth1/100 + ansible.utils.update_fact: + updates: + - path: "pre['response']['phys-items']['PhysIf-list'][{{ index }}]['descr']" + value: "Configured by ansible {{ 100 | random }}" + vars: + index: "{{ pre['response']['phys-items']['PhysIf-list']|ansible.utils.index_of('eq', 'eth1/100', 'id') }}" + register: updated + +- name: Apply the configuration + ansible.netcommon.restconf_config: + path: 'data/Cisco-NX-OS-device:System/intf-items/' + content: "{{ updated.pre.response}}" + method: patch + +- name: Get the current interface config after changes + ansible.netcommon.restconf_get: + content: config + path: /data/Cisco-NX-OS-device:System/intf-items/phys-items + register: post + +- name: Show the difference + ansible.utils.fact_diff: + before: "{{ pre.response|ansible.utils.to_paths }}" + after: "{{ post.response|ansible.utils.to_paths }}" + +# TASK [ansible.utils.fact_diff] ********************************************* +# --- before +# +++ after +# @@ -3604,7 +3604,7 @@ +# "phys-items['PhysIf-list'][37].bw": "0", +# "phys-items['PhysIf-list'][37].controllerId": "", +# "phys-items['PhysIf-list'][37].delay": "1", +# - "phys-items['PhysIf-list'][37].descr": "Configured by ansible 95", +# + "phys-items['PhysIf-list'][37].descr": "Configured by ansible 20", +# "phys-items['PhysIf-list'][37].dot1qEtherType": "0x8100", +# "phys-items['PhysIf-list'][37].duplex": "auto", +# "phys-items['PhysIf-list'][37].id": "eth1/100", + +# changed: [nxos101] + +""" + + +RETURN = """ + +diff_text: + description: The diff in text format + returned: always + type: str +diff_lines: + description: The C(diff_text) split into lines + returned: always + type: list + +""" diff --git a/tests/integration/targets/fact_diff/tasks/include/argspec.yaml b/tests/integration/targets/fact_diff/tasks/include/argspec.yaml new file mode 100644 index 0000000..2ab89e7 --- /dev/null +++ b/tests/integration/targets/fact_diff/tasks/include/argspec.yaml @@ -0,0 +1,27 @@ +- name: Check argspec validation + ansible.utils.fact_diff: + ignore_errors: True + register: result + +- assert: + that: "{{ string in result.msg }}" + loop: + - "missing required arguments:" + - before + - after + loop_control: + loop_var: string + +- name: Check argspec validation, skip_lines must be a dict + ansible.utils.fact_diff: + before: hostvars[inventory_hostname] + after: hostvars[inventory_hostname] + plugin: + vars: + skip_lines: + a_dict: False + ignore_errors: True + register: result + +- assert: + that: "{{ 'unable to convert to list' in result.msg }}" \ No newline at end of file diff --git a/tests/integration/targets/fact_diff/tasks/include/examples.yaml b/tests/integration/targets/fact_diff/tasks/include/examples.yaml new file mode 100644 index 0000000..8351ebf --- /dev/null +++ b/tests/integration/targets/fact_diff/tasks/include/examples.yaml @@ -0,0 +1,128 @@ +- set_fact: + before: + a: + b: + c: + d: + - 0 + - 1 + after: + a: + b: + c: + d: + - 2 + - 3 + +- name: Show the difference in json format + ansible.utils.fact_diff: + before: "{{ before }}" + after: "{{ after }}" + +# TASK [ansible.utils.fact_diff] ************************************** +# --- before +# +++ after +# @@ -3,8 +3,8 @@ +# "b": { +# "c": { +# "d": [ +# - 0, +# - 1 +# + 2, +# + 3 +# ] +# } +# } +# +# changed: [localhost] + +- name: Show the difference in path format + ansible.utils.fact_diff: + before: "{{ before|ansible.utils.to_paths }}" + after: "{{ after|ansible.utils.to_paths }}" + +# TASK [ansible.utils.fact_diff] ************************************** +# --- before +# +++ after +# @@ -1,4 +1,4 @@ +# { +# - "a.b.c.d[0]": 0, +# - "a.b.c.d[1]": 1 +# + "a.b.c.d[0]": 2, +# + "a.b.c.d[1]": 3 +# } +# +# changed: [localhost] + +- name: Show the difference in yaml format + ansible.utils.fact_diff: + before: "{{ before|to_nice_yaml }}" + after: "{{ after|to_nice_yaml }}" + +# TASK [ansible.utils.fact_diff] ************************************** +# --- before +# +++ after +# @@ -2,5 +2,5 @@ +# b: +# c: +# d: +# - - 0 +# - - 1 +# + - 2 +# + - 3 + +# changed: [localhost] + + +#### Show the difference between complex object using restconf +# ansible_connection: ansible.netcommon.httpapi +# ansible_httpapi_use_ssl: True +# ansible_httpapi_validate_certs: False +# ansible_network_os: ansible.netcommon.restconf + +# - name: Get the current interface config prior to changes +# ansible.netcommon.restconf_get: +# content: config +# path: /data/Cisco-NX-OS-device:System/intf-items/phys-items +# register: pre + +# - name: Update the description of eth1/100 +# ansible.utils.update_fact: +# updates: +# - path: "pre['response']['phys-items']['PhysIf-list'][{{ index }}]['descr']" +# value: "Configured by ansible {{ 100 | random }}" +# vars: +# index: "{{ pre['response']['phys-items']['PhysIf-list']|ansible.utils.index_of('eq', 'eth1/100', 'id') }}" +# register: updated + +# - name: Apply the configuration +# ansible.netcommon.restconf_config: +# path: 'data/Cisco-NX-OS-device:System/intf-items/' +# content: "{{ updated.pre.response}}" +# method: patch + +# - name: Get the current interface config after changes +# ansible.netcommon.restconf_get: +# content: config +# path: /data/Cisco-NX-OS-device:System/intf-items/phys-items +# register: post + +# - name: Show the difference +# ansible.utils.fact_diff: +# before: "{{ pre.response|ansible.utils.to_paths }}" +# after: "{{ post.response|ansible.utils.to_paths }}" + +# TASK [ansible.utils.fact_diff] ********************************************* +# --- before +# +++ after +# @@ -3604,7 +3604,7 @@ +# "phys-items['PhysIf-list'][37].bw": "0", +# "phys-items['PhysIf-list'][37].controllerId": "", +# "phys-items['PhysIf-list'][37].delay": "1", +# - "phys-items['PhysIf-list'][37].descr": "Configured by ansible 95", +# + "phys-items['PhysIf-list'][37].descr": "Configured by ansible 20", +# "phys-items['PhysIf-list'][37].dot1qEtherType": "0x8100", +# "phys-items['PhysIf-list'][37].duplex": "auto", +# "phys-items['PhysIf-list'][37].id": "eth1/100", + +# changed: [nxos101] diff --git a/tests/integration/targets/fact_diff/tasks/include/simple.yaml b/tests/integration/targets/fact_diff/tasks/include/simple.yaml new file mode 100644 index 0000000..d9d62f2 --- /dev/null +++ b/tests/integration/targets/fact_diff/tasks/include/simple.yaml @@ -0,0 +1,15 @@ +- name: Check for graceful fail of invalid regex + ansible.utils.fact_diff: + before: [0, 1, 2] + after: [0, 1, 2, 3] + plugin: + vars: + skip_lines: + - '+' + ignore_errors: True + register: result + +- assert: + that: "{{ msg in result.msg }}" + vars: + msg: "The regex '+', is not valid" \ No newline at end of file diff --git a/tests/integration/targets/fact_diff/tasks/main.yaml b/tests/integration/targets/fact_diff/tasks/main.yaml new file mode 100644 index 0000000..c9cd63e --- /dev/null +++ b/tests/integration/targets/fact_diff/tasks/main.yaml @@ -0,0 +1,12 @@ +- name: Recursively find all test files + find: + file_type: file + paths: "{{ role_path }}/tasks/include" + recurse: yes + use_regex: yes + patterns: + - '^(?!_).+$' + register: found + +- include: "{{ item.path }}" + loop: "{{ found.files }}" diff --git a/tests/unit/plugins/action/test_fact_diff.py b/tests/unit/plugins/action/test_fact_diff.py new file mode 100644 index 0000000..66c9f98 --- /dev/null +++ b/tests/unit/plugins/action/test_fact_diff.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +# Copyafter 2020 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import copy +import re +import unittest +from mock import MagicMock +from ansible.playbook.task import Task +from ansible.template import Templar + +from ansible_collections.ansible.utils.plugins.action.fact_diff import ( + ActionModule, +) + + +class TestUpdate_Fact(unittest.TestCase): + def setUp(self): + task = MagicMock(Task) + play_context = MagicMock() + play_context.check_mode = False + connection = MagicMock() + fake_loader = {} + templar = Templar(loader=fake_loader) + self._plugin = ActionModule( + task=task, + connection=connection, + play_context=play_context, + loader=fake_loader, + templar=templar, + shared_loader_obj=None, + ) + self._plugin._task.action = "fact_diff" + self._task_vars = {"inventory_hostname": "mockdevice"} + + def test_argspec_no_updates(self): + """Check passing invalid argspec""" + self._plugin._task.args = {"before": True} + result = self._plugin.run(task_vars=self._task_vars) + self.assertTrue(result["failed"]) + self.assertIn( + "missing required arguments: after", + result["msg"], + ) + + def test_same(self): + """Ensure two equal string don't create a diff""" + before = "Lorem ipsum dolor sit amet" + after = before + self._plugin._task.args = {"before": before, "after": after} + result = self._plugin.run(task_vars=self._task_vars) + self.assertFalse(result["changed"]) + self.assertEqual([], result["diff_lines"]) + self.assertEqual("", result["diff_text"]) + + def test_string(self): + """Compare two strings""" + before = "Lorem ipsum dolor sit amet, consectetur adipiscing elit" + after = "Lorem ipsum dolor sit amet, AAA consectetur adipiscing elit" + self._plugin._task.args = {"before": before, "after": after} + result = self._plugin.run(task_vars=self._task_vars) + self.assertTrue(result["changed"]) + self.assertIn("-" + before, result["diff_lines"]) + self.assertIn("-" + before, result["diff_text"]) + self.assertIn("+" + after, result["diff_lines"]) + self.assertIn("+" + after, result["diff_text"]) + + def test_string_skip_lines(self): + """Compare two string, with skip_lines""" + before = "Lorem ipsum dolor sit amet, consectetur adipiscing elit" + after = "Lorem ipsum dolor sit amet, AAA consectetur adipiscing elit" + self._plugin._task.args = { + "before": before, + "after": after, + "plugin": {"vars": {"skip_lines": "^Lorem"}}, + } + result = self._plugin.run(task_vars=self._task_vars) + self.assertFalse(result["changed"]) + self.assertEqual([], result["diff_lines"]) + self.assertEqual("", result["diff_text"]) + + def test_same_list(self): + """Compare two lists that are the same""" + before = [0, 1, 2, 3] + after = before + self._plugin._task.args = {"before": before, "after": after} + result = self._plugin.run(task_vars=self._task_vars) + self.assertFalse(result["changed"]) + self.assertEqual([], result["diff_lines"]) + self.assertEqual("", result["diff_text"]) + + def test_diff_list_skip_lines(self): + """Compare two lists, with skip_lines""" + before = [0, 1, 2] + after = [0, 1, 2, 3] + self._plugin._task.args = { + "before": before, + "after": after, + "plugin": {"vars": {"skip_lines": "3"}}, + } + result = self._plugin.run(task_vars=self._task_vars) + self.assertFalse(result["changed"]) + self.assertEqual([], result["diff_lines"]) + self.assertEqual("", result["diff_text"]) + + def test_diff_list(self): + """Compare two lists with differences""" + before = [0, 1, 2, 3] + after = [0, 1, 2, 4] + self._plugin._task.args = {"before": before, "after": after} + result = self._plugin.run(task_vars=self._task_vars) + self.assertTrue(result["changed"]) + self.assertIn("-3", result["diff_lines"]) + self.assertIn("-3", result["diff_text"]) + self.assertIn("+4", result["diff_lines"]) + self.assertIn("+4", result["diff_text"]) + + def test_same_dict(self): + """Compare two dicts that are the same""" + before = {"a": {"b": {"c": {"d": [0, 1, 2]}}}} + after = before + self._plugin._task.args = {"before": before, "after": after} + result = self._plugin.run(task_vars=self._task_vars) + self.assertFalse(result["changed"]) + self.assertEqual([], result["diff_lines"]) + self.assertEqual("", result["diff_text"]) + + def test_diff_dict_skip_lines(self): + """Compare two dicts, with skip_lines""" + before = {"a": {"b": {"c": {"d": [0, 1, 2]}}}} + after = {"a": {"b": {"c": {"d": [0, 1, 2, 3]}}}} + self._plugin._task.args = { + "before": before, + "after": after, + "plugin": {"vars": {"skip_lines": "3"}}, + } + result = self._plugin.run(task_vars=self._task_vars) + self.assertFalse(result["changed"]) + self.assertEqual([], result["diff_lines"]) + self.assertEqual("", result["diff_text"]) + + def test_diff_dict(self): + """Compare two dicts that are different""" + before = {"a": {"b": {"c": {"d": [0, 1, 2, 3]}}}} + after = {"a": {"b": {"c": {"d": [0, 1, 2, 4]}}}} + self._plugin._task.args = {"before": before, "after": after} + result = self._plugin.run(task_vars=self._task_vars) + self.assertTrue(result["changed"]) + mlines = [l for l in result["diff_lines"] if re.match(r"^-\s+3$", l)] + self.assertEqual(1, len(mlines)) + mlines = [l for l in result["diff_lines"] if re.match(r"^\+\s+4$", l)] + self.assertEqual(1, len(mlines)) + + def test_invalid_diff_engine_not_collection(self): + """Check passing invalid argspec""" + self._plugin._task.args = { + "before": True, + "after": True, + "plugin": {"name": "a"}, + } + result = self._plugin.run(task_vars=self._task_vars) + self.assertTrue(result["failed"]) + self.assertIn( + "Plugin name should be provided as a full name including collection", + result["msg"], + ) + + def test_invalid_diff_engine_not_valid(self): + """Check passing invalid argspec""" + self._plugin._task.args = { + "before": True, + "after": True, + "plugin": {"name": "a.b.c"}, + } + result = self._plugin.run(task_vars=self._task_vars) + self.assertTrue(result["failed"]) + self.assertIn( + "Error loading plugin 'a.b.c'", + result["msg"], + ) + + def test_invalid_regex(self): + """Check with invalid regex""" + before = True + after = False + self._plugin._task.args = { + "before": before, + "after": after, + "plugin": {"vars": {"skip_lines": "+"}}, + } + result = self._plugin.run(task_vars=self._task_vars) + self.assertTrue(result["failed"]) + self.assertIn( + "The regex '+', is not valid", + result["msg"], + ) + + def test_fail_plugin(self): + """Simulate a diff plugin failure""" + self._plugin._result = {} + result = self._plugin._run_diff(None) + self.assertIsNone(result) + self.assertTrue(self._plugin._result["failed"]) + self.assertIn( + "'NoneType' object has no attribute 'diff'", + self._plugin._result["msg"], + ) diff --git a/tests/unit/plugins/action/test_update_fact.py b/tests/unit/plugins/action/test_update_fact.py index 8bb1a04..20cfd90 100644 --- a/tests/unit/plugins/action/test_update_fact.py +++ b/tests/unit/plugins/action/test_update_fact.py @@ -1,5 +1,7 @@ -# (c) 2020 Ansible Project -# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# -*- coding: utf-8 -*- +# Copyafter 2020 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import absolute_import, division, print_function