Fact diff (#15)

* Add fact_diff action

* Formatting

* Update docs

* Add skip_lines

* Update docs

* black

* bug fix

* Add units for fact_diff

* Initial changes to support plugin architecture

* Restructure docstring

* Integration test fix

* Wrap diff plugin with try/except

* Fix integration test

* Restructure code to allow for plugin failure test

* Examples, docs

* Fix debug statement

* Update examples

* Minor doc updates

* Minor doc updates

* Minor doc updates

* Add change log

Co-authored-by: cidrblock <brad@thethorntons.net>
pull/19/head
Bradley A. Thornton 2020-10-23 04:48:16 -07:00 committed by GitHub
parent a727928e3d
commit c20ba34c7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1168 additions and 2 deletions

View File

@ -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
<!--end collection content-->

View File

@ -0,0 +1,3 @@
---
minor_changes:
- Add fact_diff module. Find the difference between text, files or facts

View File

@ -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
<table border=0 cellpadding=0 class="documentation-table">
<tr>
<th colspan="3">Parameter</th>
<th>Choices/<font color="blue">Defaults</font></th>
<th width="100%">Comments</th>
</tr>
<tr>
<td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>after</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
<span style="color: purple">raw</span>
/ <span style="color: red">required</span>
</div>
</td>
<td>
</td>
<td>
<div>The second fact to be used in the comparison</div>
</td>
</tr>
<tr>
<td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>before</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
<span style="color: purple">raw</span>
/ <span style="color: red">required</span>
</div>
</td>
<td>
</td>
<td>
<div>The first fact to be used in the comparison</div>
</td>
</tr>
<tr>
<td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>plugin</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
<span style="color: purple">dictionary</span>
</div>
</td>
<td>
<b>Default:</b><br/><div style="color: blue">{}</div>
</td>
<td>
<div>Configure and specify the diff plugin to use</div>
</td>
</tr>
<tr>
<td class="elbow-placeholder"></td>
<td colspan="2">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>name</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
<span style="color: purple">string</span>
</div>
</td>
<td>
<b>Default:</b><br/><div style="color: blue">"ansible.utils.native"</div>
</td>
<td>
<div>The diff plugin to use, in collection format</div>
</td>
</tr>
<tr>
<td class="elbow-placeholder"></td>
<td colspan="2">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>vars</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
<span style="color: purple">dictionary</span>
</div>
</td>
<td>
<b>Default:</b><br/><div style="color: blue">{}</div>
</td>
<td>
<div>Parameters passed to the diff plugin</div>
</td>
</tr>
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td colspan="1">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>skip_lines</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
<span style="color: purple">list</span>
</div>
</td>
<td>
</td>
<td>
<div>Skip lines matching these regular expressions</div>
<div>Matches will be removed prior to the diff</div>
<div>If the provided <em>before</em> and <em>after</em> are a string, they will be split</div>
<div>Each entry in each list will be cast to a string for the comparison</div>
</td>
</tr>
</table>
<br/>
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 <https://docs.ansible.com/ansible/latest/reference_appendices/common_return_values.html#common-return-values>`_, the following are the fields unique to this module:
.. raw:: html
<table border=0 cellpadding=0 class="documentation-table">
<tr>
<th colspan="1">Key</th>
<th>Returned</th>
<th width="100%">Description</th>
</tr>
<tr>
<td colspan="1">
<div class="ansibleOptionAnchor" id="return-"></div>
<b>diff_lines</b>
<a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
<div style="font-size: small">
<span style="color: purple">list</span>
</div>
</td>
<td>always</td>
<td>
<div>The <code>diff_text</code> split into lines</div>
<br/>
</td>
</tr>
<tr>
<td colspan="1">
<div class="ansibleOptionAnchor" id="return-"></div>
<b>diff_text</b>
<a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
<div style="font-size: small">
<span style="color: purple">string</span>
</div>
</td>
<td>always</td>
<td>
<div>The diff in text format</div>
<br/>
</td>
</tr>
</table>
<br/><br/>
Status
------
Authors
~~~~~~~
- Bradley Thornton (@cidrblock)

139
plugins/action/fact_diff.py Normal file
View File

@ -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

View File

@ -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}

View File

@ -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

View File

@ -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
"""

View File

@ -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 }}"

View File

@ -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]

View File

@ -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"

View File

@ -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 }}"

View File

@ -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"],
)

View File

@ -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