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
parent
a727928e3d
commit
c20ba34c7d
|
@ -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-->
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
minor_changes:
|
||||
- Add fact_diff module. Find the difference between text, files or facts
|
|
@ -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)
|
|
@ -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
|
|
@ -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}
|
|
@ -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
|
|
@ -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
|
||||
|
||||
"""
|
|
@ -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 }}"
|
|
@ -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]
|
|
@ -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"
|
|
@ -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 }}"
|
|
@ -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"],
|
||||
)
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in New Issue