diff --git a/README.md b/README.md index d1fb01e..9d90049 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ Name | Description [ansible.utils.index_of](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.index_of_lookup.rst)|Find the indicies of items in a list matching some criteria [ansible.utils.to_paths](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.to_paths_lookup.rst)|Flatten a complex object into a dictionary of paths and values +### Modules +Name | Description +--- | --- +[ansible.utils.update_fact](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.update_fact_module.rst)|Update currently set facts + ## Installing this collection diff --git a/docs/ansible.utils.update_fact_module.rst b/docs/ansible.utils.update_fact_module.rst new file mode 100644 index 0000000..895e898 --- /dev/null +++ b/docs/ansible.utils.update_fact_module.rst @@ -0,0 +1,405 @@ +.. _ansible.utils.update_fact_module: + + +************************* +ansible.utils.update_fact +************************* + +**Update currently set facts** + + +Version added: 1.0.0 + +.. contents:: + :local: + :depth: 1 + + +Synopsis +-------- +- This module allows updating existing variables. +- Variables are updated on a host-by-host basis. +- Variable are not modified in place, instead they are returned by the module + + + + +Parameters +---------- + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterChoices/DefaultsComments
+
+ updates + +
+ list + / elements=dictionary + / required +
+
+ +
A list of dictionaries, each a desired update to make
+
+
+ path + +
+ string + / required +
+
+ +
The path in a currently set variable to update
+
The path can be in dot or bracket notation
+
It should be a valid jinja reference
+
+
+ value + +
+ raw + / required +
+
+ +
The value to be set at the path
+
Can be a simple or complex data structure
+
+
+ + + + +Examples +-------- + +.. code-block:: yaml + + # Update an exisitng fact, dot or bracket notation + - name: Set a fact + set_fact: + a: + b: + c: + - 1 + - 2 + + - name: Update the fact + ansible.utils.update_fact: + updates: + - path: a.b.c.0 + value: 10 + - path: "a['b']['c'][1]" + value: 20 + register: updated + + - debug: + var: updated.a + + # updated: + # a: + # b: + # c: + # - 10 + # - 20 + # changed: true + + + # Lists can be appended, new keys added to dictionaries + + - name: Set a fact + set_fact: + a: + b: + b1: + - 1 + - 2 + + - name: Update, add to list, add new key + ansible.utils.update_fact: + updates: + - path: a.b.b1.2 + value: 3 + - path: a.b.b2 + value: + - 10 + - 20 + - 30 + register: updated + + - debug: + var: updated.a + + # updated: + # a: + # b: + # b1: + # - 1 + # - 2 + # - 3 + # b2: + # - 10 + # - 20 + # - 30 + # changed: true + + ##################################################################### + # Update every item in a list of dictionaries + # build the update list ahead of time using a loop + # and then apply the changes to the fact + ##################################################################### + + - name: Set fact + set_fact: + addresses: + - raw: 10.1.1.0/255.255.255.0 + name: servers + - raw: 192.168.1.0/255.255.255.0 + name: printers + - raw: 8.8.8.8 + name: dns + + - name: Build a list of updates + set_fact: + update_list: "{{ update_list + update }}" + loop: "{{ addresses }}" + loop_control: + index_var: idx + vars: + update_list: [] + update: + - path: addresses[{{ idx }}].network + value: "{{ item['raw'] | ansible.netcommon.ipaddr('network') }}" + - path: addresses[{{ idx }}].prefix + value: "{{ item['raw'] | ansible.netcommon.ipaddr('prefix') }}" + + - debug: + var: update_list + + # TASK [debug] ******************* + # ok: [localhost] => + # update_list: + # - path: addresses[0].network + # value: 10.1.1.0 + # - path: addresses[0].prefix + # value: '24' + # - path: addresses[1].network + # value: 192.168.1.0 + # - path: addresses[1].prefix + # value: '24' + # - path: addresses[2].network + # value: 8.8.8.8 + # - path: addresses[2].prefix + # value: '32' + + - name: Make the updates + ansible.utils.update_fact: + updates: "{{ update_list }}" + register: updated + + - debug: + var: updated + + # TASK [debug] *********************** + # ok: [localhost] => + # updated: + # addresses: + # - name: servers + # network: 10.1.1.0 + # prefix: '24' + # raw: 10.1.1.0/255.255.255.0 + # - name: printers + # network: 192.168.1.0 + # prefix: '24' + # raw: 192.168.1.0/255.255.255.0 + # - name: dns + # network: 8.8.8.8 + # prefix: '32' + # raw: 8.8.8.8 + # changed: true + # failed: false + + + ##################################################################### + # Retrieve, update, and apply interface description change + # use index_of to locate Etherent1/1 + ##################################################################### + + - name: Get the current interface config + cisco.nxos.nxos_interfaces: + state: gathered + register: interfaces + + - name: Update the description of Ethernet1/1 + ansible.utils.update_fact: + updates: + - path: "interfaces.gathered[{{ index }}].description" + value: "Configured by ansible" + vars: + index: "{{ interfaces.gathered|ansible.utils.index_of('eq', 'Ethernet1/1', 'name') }}" + register: updated + + - name: Update the configuration + cisco.nxos.nxos_interfaces: + config: "{{ updated.interfaces.gathered }}" + state: overridden + register: result + + - name: Show the commands issued + debug: + msg: "{{ result['commands'] }}" + + # TASK [Show the commands issued] ************************************* + # ok: [nxos101] => { + # "msg": [ + # "interface Ethernet1/1", + # "description Configured by ansible" + # ] + # } + + + ##################################################################### + # Retrieve, update, and apply an ipv4 ACL change + # finding the index of AFI ipv4 acls + # finding the index of the ACL named 'test1' + # finding the index of sequence 10 + ##################################################################### + + - name: Retrieve the current acls + arista.eos.eos_acls: + state: gathered + register: current + + - name: Update the source of sequenmce 10 in the IPv4 ACL named test1 + ansible.utils.update_fact: + updates: + - path: current.gathered[{{ afi }}].acls[{{ acl }}].aces[{{ ace }}].source + value: + subnet_address: "192.168.2.0/24" + vars: + afi: "{{ current.gathered|ansible.utils.index_of('eq', 'ipv4', 'afi') }}" + acl: "{{ current.gathered[afi|int].acls|ansible.utils.index_of('eq', 'test1', 'name') }}" + ace: "{{ current.gathered[afi|int].acls[acl|int].aces|ansible.utils.index_of('eq', 10, 'sequence') }}" + register: updated + + - name: Apply the changes + arista.eos.eos_acls: + config: "{{ updated.current.gathered }}" + state: overridden + register: changes + + - name: Show the commands issued + debug: + msg: "{{ changes['commands'] }}" + + # TASK [Show the commands issued] ************************************* + # ok: [eos101] => { + # "msg": [ + # "ip access-list test1", + # "no 10", + # "10 permit ip 192.168.2.0/24 host 10.1.1.2" + # ] + # } + + + ##################################################################### + # Disable ip redirects on any layer3 interface + # find the layer 3 interfaces + # use each name to find their index in l3 interface + # build an 'update' list and apply the updates + ##################################################################### + + - name: Get the current interface and L3 interface configuration + cisco.nxos.nxos_facts: + gather_subset: min + gather_network_resources: + - interfaces + - l3_interfaces + + - name: Build the list of updates to make + set_fact: + updates: "{{ updates + [entry] }}" + vars: + updates: [] + entry: + path: "ansible_network_resources.l3_interfaces[{{ item }}].redirects" + value: False + w_mode: "{{ ansible_network_resources.interfaces|selectattr('mode', 'defined') }}" + m_l3: "{{ w_mode|selectattr('mode', 'eq', 'layer3') }}" + names: "{{ m_l3|map(attribute='name')|list }}" + l3_indicies: "{{ ansible_network_resources.l3_interfaces|ansible.utils.index_of('in', names, 'name', wantlist=True) }}" + loop: "{{ l3_indicies }}" + + # TASK [Build the list of updates to make] **************************** + # ok: [nxos101] => (item=99) => changed=false + # ansible_facts: + # updates: + # - path: ansible_network_resources.l3_interfaces[99].redirects + # value: false + # ansible_loop_var: item + # item: 99 + + - name: Update the l3 interfaces + ansible.utils.update_fact: + updates: "{{ updates }}" + register: updated + + # TASK [Update the l3 interfaces] ************************************* + # changed: [nxos101] => changed=true + # ansible_network_resources: + # l3_interfaces: + # <...> + # - ipv4: + # - address: 10.1.1.1/24 + # name: Ethernet1/100 + # redirects: false + + - name: Apply the configuration changes + cisco.nxos.l3_interfaces: + config: "{{ updated.ansible_network_resources.l3_interfaces }}" + state: overridden + register: changes + + # TASK [Apply the configuration changes] ****************************** + # changed: [nxos101] => changed=true + # commands: + # - interface Ethernet1/100 + # - no ip redirects + + + + +Status +------ + + +Authors +~~~~~~~ + +- Bradley Thornton (@cidrblock) diff --git a/plugins/action/update_fact.py b/plugins/action/update_fact.py new file mode 100644 index 0000000..1710909 --- /dev/null +++ b/plugins/action/update_fact.py @@ -0,0 +1,189 @@ +# -*- 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 +import ast +import json +import re +from ansible.plugins.action import ActionBase +from ansible.errors import AnsibleActionFail + +from ansible.module_utils.common._collections_compat import ( + MutableMapping, + MutableSequence, +) +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes, to_native +from jinja2 import Template, TemplateSyntaxError +from ansible_collections.ansible.utils.plugins.modules.update_fact import ( + DOCUMENTATION, +) +from ansible_collections.ansible.utils.plugins.module_utils.common.argspec_validate import ( + AnsibleArgSpecValidator, +) +from ansible.errors import AnsibleActionFail + + +class ActionModule(ActionBase): + """action module""" + + def __init__(self, *args, **kwargs): + """Start here""" + super(ActionModule, self).__init__(*args, **kwargs) + self._supports_async = True + self._updates = None + self._result = 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: + raise AnsibleActionFail(errors) + + def _ensure_valid_jinja(self): + """Ensure each path is jinja valid""" + errors = [] + for entry in self._task.args["updates"]: + try: + Template("{{" + entry["path"] + "}}") + except TemplateSyntaxError as exc: + error = ( + "While processing '{path}' found malformed path." + " Ensure syntax follows valid jinja format. The error was:" + " {error}" + ).format(path=entry["path"], error=to_native(exc)) + errors.append(error) + if errors: + raise AnsibleActionFail(" ".join(errors)) + + @staticmethod + def _field_split(path): + """Split the path into it's parts + + :param path: The user provided path + :type path: str + :return: the individual parts of the path + :rtype: list + """ + que = list(path) + val = que.pop(0) + fields = [] + try: + while True: + field = "" + # found a '.', move to the next character + if val == ".": + val = que.pop(0) + # found a '[', pop until ']' and then get the next + if val == "[": + val = que.pop(0) + while val != "]": + field += val + val = que.pop(0) + val = que.pop(0) + else: + while val not in [".", "["]: + field += val + val = que.pop(0) + try: + # make numbers numbers + fields.append(ast.literal_eval(field)) + except Exception: + # or strip the quotes + fields.append(re.sub("['\"]", "", field)) + except IndexError: + # pop'ed past the end of the que + # so add the final field + try: + fields.append(ast.literal_eval(field)) + except Exception: + fields.append(re.sub("['\"]", "", field)) + return fields + + def set_value(self, obj, path, val): + """Set a value + + :param obj: The object to modify + :type obj: mutable object + :param path: The path to where the update should be made + :type path: list + :param val: The new value to place at path + :type val: string, dict, list, bool, etc + """ + first, rest = path[0], path[1:] + if rest: + try: + new_obj = obj[first] + except (KeyError, TypeError): + msg = ( + "Error: the key '{first}' was not found " + "in {obj}.".format(obj=obj, first=first) + ) + raise AnsibleActionFail(msg) + self.set_value(new_obj, rest, val) + else: + if isinstance(obj, MutableMapping): + if obj.get(first) != val: + self._result["changed"] = True + obj[first] = val + elif isinstance(obj, MutableSequence): + if not isinstance(first, int): + msg = ( + "Error: {obj} is a list, " + "but index provided was not an integer: '{first}'" + ).format(obj=obj, first=first) + raise AnsibleActionFail(msg) + if first > len(obj): + msg = "Error: {obj} not long enough for item #{first} to be set.".format( + obj=obj, first=first + ) + raise AnsibleActionFail(msg) + if first == len(obj): + obj.append(val) + self._result["changed"] = True + else: + if obj[first] != val: + obj[first] = val + self._result["changed"] = True + else: + msg = "update_fact can only modify mutable objects." + raise AnsibleActionFail(msg) + + def run(self, tmp=None, task_vars=None): + """action entry point""" + self._task.diff = False + self._result = super(ActionModule, self).run(tmp, task_vars) + self._result["changed"] = False + self._check_argspec() + results = set() + self._ensure_valid_jinja() + for entry in self._task.args["updates"]: + parts = self._field_split(entry["path"]) + obj, path = parts[0], parts[1:] + results.add(obj) + if obj not in task_vars["vars"]: + msg = "'{obj}' was not found in the current facts.".format( + obj=obj + ) + raise AnsibleActionFail(msg) + retrieved = task_vars["vars"].get(obj) + if path: + self.set_value(retrieved, path, entry["value"]) + else: + if task_vars["vars"][obj] != entry["value"]: + task_vars["vars"][obj] = entry["value"] + self._result["changed"] = True + + for key in results: + value = task_vars["vars"].get(key) + self._result[key] = value + return self._result diff --git a/plugins/modules/update_fact.py b/plugins/modules/update_fact.py new file mode 100644 index 0000000..7f4f491 --- /dev/null +++ b/plugins/modules/update_fact.py @@ -0,0 +1,343 @@ +# -*- 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: update_fact +short_description: Update currently set facts +version_added: "1.0.0" +description: + - This module allows updating existing variables. + - Variables are updated on a host-by-host basis. + - Variable are not modified in place, instead they are returned by the module +options: + updates: + description: + - A list of dictionaries, each a desired update to make + type: list + elements: dict + required: True + suboptions: + path: + description: + - The path in a currently set variable to update + - The path can be in dot or bracket notation + - It should be a valid jinja reference + type: str + required: True + value: + description: + - The value to be set at the path + - Can be a simple or complex data structure + type: raw + required: True + + +notes: + +author: +- Bradley Thornton (@cidrblock) +""" + +EXAMPLES = r""" + +# Update an exisitng fact, dot or bracket notation +- name: Set a fact + set_fact: + a: + b: + c: + - 1 + - 2 + +- name: Update the fact + ansible.utils.update_fact: + updates: + - path: a.b.c.0 + value: 10 + - path: "a['b']['c'][1]" + value: 20 + register: updated + +- debug: + var: updated.a + +# updated: +# a: +# b: +# c: +# - 10 +# - 20 +# changed: true + + +# Lists can be appended, new keys added to dictionaries + +- name: Set a fact + set_fact: + a: + b: + b1: + - 1 + - 2 + +- name: Update, add to list, add new key + ansible.utils.update_fact: + updates: + - path: a.b.b1.2 + value: 3 + - path: a.b.b2 + value: + - 10 + - 20 + - 30 + register: updated + +- debug: + var: updated.a + +# updated: +# a: +# b: +# b1: +# - 1 +# - 2 +# - 3 +# b2: +# - 10 +# - 20 +# - 30 +# changed: true + +##################################################################### +# Update every item in a list of dictionaries +# build the update list ahead of time using a loop +# and then apply the changes to the fact +##################################################################### + +- name: Set fact + set_fact: + addresses: + - raw: 10.1.1.0/255.255.255.0 + name: servers + - raw: 192.168.1.0/255.255.255.0 + name: printers + - raw: 8.8.8.8 + name: dns + +- name: Build a list of updates + set_fact: + update_list: "{{ update_list + update }}" + loop: "{{ addresses }}" + loop_control: + index_var: idx + vars: + update_list: [] + update: + - path: addresses[{{ idx }}].network + value: "{{ item['raw'] | ansible.netcommon.ipaddr('network') }}" + - path: addresses[{{ idx }}].prefix + value: "{{ item['raw'] | ansible.netcommon.ipaddr('prefix') }}" + +- debug: + var: update_list + +# TASK [debug] ******************* +# ok: [localhost] => +# update_list: +# - path: addresses[0].network +# value: 10.1.1.0 +# - path: addresses[0].prefix +# value: '24' +# - path: addresses[1].network +# value: 192.168.1.0 +# - path: addresses[1].prefix +# value: '24' +# - path: addresses[2].network +# value: 8.8.8.8 +# - path: addresses[2].prefix +# value: '32' + +- name: Make the updates + ansible.utils.update_fact: + updates: "{{ update_list }}" + register: updated + +- debug: + var: updated + +# TASK [debug] *********************** +# ok: [localhost] => +# updated: +# addresses: +# - name: servers +# network: 10.1.1.0 +# prefix: '24' +# raw: 10.1.1.0/255.255.255.0 +# - name: printers +# network: 192.168.1.0 +# prefix: '24' +# raw: 192.168.1.0/255.255.255.0 +# - name: dns +# network: 8.8.8.8 +# prefix: '32' +# raw: 8.8.8.8 +# changed: true +# failed: false + + +##################################################################### +# Retrieve, update, and apply interface description change +# use index_of to locate Etherent1/1 +##################################################################### + +- name: Get the current interface config + cisco.nxos.nxos_interfaces: + state: gathered + register: interfaces + +- name: Update the description of Ethernet1/1 + ansible.utils.update_fact: + updates: + - path: "interfaces.gathered[{{ index }}].description" + value: "Configured by ansible" + vars: + index: "{{ interfaces.gathered|ansible.utils.index_of('eq', 'Ethernet1/1', 'name') }}" + register: updated + +- name: Update the configuration + cisco.nxos.nxos_interfaces: + config: "{{ updated.interfaces.gathered }}" + state: overridden + register: result + +- name: Show the commands issued + debug: + msg: "{{ result['commands'] }}" + +# TASK [Show the commands issued] ************************************* +# ok: [nxos101] => { +# "msg": [ +# "interface Ethernet1/1", +# "description Configured by ansible" +# ] +# } + + +##################################################################### +# Retrieve, update, and apply an ipv4 ACL change +# finding the index of AFI ipv4 acls +# finding the index of the ACL named 'test1' +# finding the index of sequence 10 +##################################################################### + +- name: Retrieve the current acls + arista.eos.eos_acls: + state: gathered + register: current + +- name: Update the source of sequenmce 10 in the IPv4 ACL named test1 + ansible.utils.update_fact: + updates: + - path: current.gathered[{{ afi }}].acls[{{ acl }}].aces[{{ ace }}].source + value: + subnet_address: "192.168.2.0/24" + vars: + afi: "{{ current.gathered|ansible.utils.index_of('eq', 'ipv4', 'afi') }}" + acl: "{{ current.gathered[afi|int].acls|ansible.utils.index_of('eq', 'test1', 'name') }}" + ace: "{{ current.gathered[afi|int].acls[acl|int].aces|ansible.utils.index_of('eq', 10, 'sequence') }}" + register: updated + +- name: Apply the changes + arista.eos.eos_acls: + config: "{{ updated.current.gathered }}" + state: overridden + register: changes + +- name: Show the commands issued + debug: + msg: "{{ changes['commands'] }}" + +# TASK [Show the commands issued] ************************************* +# ok: [eos101] => { +# "msg": [ +# "ip access-list test1", +# "no 10", +# "10 permit ip 192.168.2.0/24 host 10.1.1.2" +# ] +# } + + +##################################################################### +# Disable ip redirects on any layer3 interface +# find the layer 3 interfaces +# use each name to find their index in l3 interface +# build an 'update' list and apply the updates +##################################################################### + +- name: Get the current interface and L3 interface configuration + cisco.nxos.nxos_facts: + gather_subset: min + gather_network_resources: + - interfaces + - l3_interfaces + +- name: Build the list of updates to make + set_fact: + updates: "{{ updates + [entry] }}" + vars: + updates: [] + entry: + path: "ansible_network_resources.l3_interfaces[{{ item }}].redirects" + value: False + w_mode: "{{ ansible_network_resources.interfaces|selectattr('mode', 'defined') }}" + m_l3: "{{ w_mode|selectattr('mode', 'eq', 'layer3') }}" + names: "{{ m_l3|map(attribute='name')|list }}" + l3_indicies: "{{ ansible_network_resources.l3_interfaces|ansible.utils.index_of('in', names, 'name', wantlist=True) }}" + loop: "{{ l3_indicies }}" + +# TASK [Build the list of updates to make] **************************** +# ok: [nxos101] => (item=99) => changed=false +# ansible_facts: +# updates: +# - path: ansible_network_resources.l3_interfaces[99].redirects +# value: false +# ansible_loop_var: item +# item: 99 + +- name: Update the l3 interfaces + ansible.utils.update_fact: + updates: "{{ updates }}" + register: updated + +# TASK [Update the l3 interfaces] ************************************* +# changed: [nxos101] => changed=true +# ansible_network_resources: +# l3_interfaces: +# <...> +# - ipv4: +# - address: 10.1.1.1/24 +# name: Ethernet1/100 +# redirects: false + +- name: Apply the configuration changes + cisco.nxos.l3_interfaces: + config: "{{ updated.ansible_network_resources.l3_interfaces }}" + state: overridden + register: changes + +# TASK [Apply the configuration changes] ****************************** +# changed: [nxos101] => changed=true +# commands: +# - interface Ethernet1/100 +# - no ip redirects + +""" diff --git a/tests/integration/targets/update_fact/tasks/main.yaml b/tests/integration/targets/update_fact/tasks/main.yaml new file mode 100644 index 0000000..c5aa320 --- /dev/null +++ b/tests/integration/targets/update_fact/tasks/main.yaml @@ -0,0 +1,68 @@ +- name: Set a fact + set_fact: + a: + b: + c: + - 1 + - 2 + +- name: Update the fact + ansible.utils.update_fact: + updates: + - path: a.b.c.0 + value: 10 + - path: "a['b']['c'][1]" + value: 20 + register: updated + +- assert: + that: "{{ updated.a == expected.a }}" + vars: + expected: + a: + b: + c: + - 10 + - 20 + +- name: Update the fact + ansible.utils.update_fact: + updates: + - path: a + value: + x: + y: + z: + - 100 + - True + register: updated + +- assert: + that: "{{ updated.a == expected.a }}" + vars: + expected: + a: + x: + y: + z: + - 100 + - True + +- name: Update the fact + ansible.utils.update_fact: + updates: + - path: "a.b.c[{{ index }}]" + value: 20 + vars: + index: "{{ a.b.c|ansible.utils.index_of('eq', 2) }}" + register: updated + +- assert: + that: "{{ updated.a == expected.a }}" + vars: + expected: + a: + b: + c: + - 1 + - 20 diff --git a/tests/unit/plugins/action/test_update_fact.py b/tests/unit/plugins/action/test_update_fact.py new file mode 100644 index 0000000..8bb1a04 --- /dev/null +++ b/tests/unit/plugins/action/test_update_fact.py @@ -0,0 +1,366 @@ +# (c) 2020 Ansible Project +# 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 unittest +from jinja2 import Template, TemplateSyntaxError +from mock import MagicMock +from ansible.playbook.task import Task +from ansible.template import Templar + +from ansible_collections.ansible.utils.plugins.action.update_fact import ( + ActionModule, +) + +VALID_DATA = { + "a": { + "b": {"4.4": [{"1": {5: {"foo": 123}}}], 5.5: "float5.5"}, + "127.0.0.1": "localhost", + } +} + +VALID_TESTS = [ + { + "path": 'a.b["4.4"][0]["1"].5[\'foo\']', + "split": ["a", "b", "4.4", 0, "1", 5, "foo"], + "template_result": "123", + }, + { + "path": 'a.b["4.4"][0]["1"].5[\'foo\']', + "split": ["a", "b", "4.4", 0, "1", 5, "foo"], + "template_result": "123", + }, + { + "path": "a.b[5.5]", + "split": ["a", "b", 5.5], + "template_result": "float5.5", + }, + { + "path": "a['127.0.0.1']", + "split": ["a", "127.0.0.1"], + "template_result": "localhost", + }, + { + "path": "a.b['4.4'].0['1'].5['foo']", + "split": ["a", "b", "4.4", 0, "1", 5, "foo"], + "template_result": "123", + }, +] + + +INVALID_JINJA = [ + { + "path": "a.'1'", + "note": "quoted values are required to be in brackets", + "error": "expected name or number", + }, + { + "path": "a.[1]", + "note": "brackets can't follow dots", + "error": "expected name or number", + }, + { + "path": 'a.b["4.4"][0]["1"]."5"[\'foo\']', + "note": "quoted values are required to be in brackets", + "error": "expected name or number", + }, +] + + +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 = "update_fact" + + def test_argspec_no_updates(self): + """Check passing invalid argspec""" + self._plugin._task.args = {"a": 10} + with self.assertRaises(Exception) as error: + self._plugin.run(task_vars=None) + self.assertIn( + "missing required arguments: updates", + str(error.exception), + ) + + def test_argspec_none(self): + """Check passing a dict""" + self._plugin._task.args = {} + with self.assertRaises(Exception) as error: + self._plugin.run(task_vars=None) + self.assertIn( + "missing required arguments: updates", str(error.exception) + ) + + def test_valid_jinja(self): + for test in VALID_TESTS: + tmplt = Template("{{" + test["path"] + "}}") + result = tmplt.render(VALID_DATA) + self.assertEqual(result, test["template_result"]) + + def test_invalid_jinja(self): + for test in INVALID_JINJA: + with self.assertRaises(TemplateSyntaxError) as error: + Template("{{" + test["path"] + "}}") + self.assertIn(test["error"], str(error.exception)) + + def test_fields(self): + """Check the parsing of a path into it's parts""" + for stest in VALID_TESTS: + result = self._plugin._field_split(stest["path"]) + self.assertEqual(result, stest["split"]) + + def test_missing_var(self): + """Check for a missing fact""" + self._plugin._task.args = {"updates": [{"path": "a.b.c", "value": 5}]} + with self.assertRaises(Exception) as error: + self._plugin.run(task_vars={"vars": {}}) + self.assertIn( + "'a' was not found in the current facts.", str(error.exception) + ) + + def test_run_simple(self): + """Confirm a valid argspec passes""" + task_vars = {"vars": {"a": {"b": [1, 2, 3]}}} + expected = copy.deepcopy(task_vars["vars"]) + expected["a"]["b"] = 5 + expected.update({"changed": True}) + self._plugin._task.args = {"updates": [{"path": "a.b", "value": 5}]} + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected) + + def test_run_multiple(self): + """Confirm multiple paths passes""" + task_vars = { + "vars": {"a": {"b1": [1, 2, 3], "b2": {"c": "123", "d": False}}} + } + expected = {"a": {"b1": [1, 2, 3, 4], "b2": {"c": 456, "d": True}}} + expected.update({"changed": True}) + self._plugin._task.args = { + "updates": [ + {"path": "a.b1.3", "value": 4}, + {"path": "a.b2.c", "value": 456}, + {"path": "a.b2.d", "value": True}, + ] + } + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected) + + def test_run_replace_in_list(self): + """Replace in list""" + task_vars = {"vars": {"a": {"b": [1, 2, 3]}}} + expected = copy.deepcopy(task_vars["vars"]) + expected["a"]["b"][1] = 5 + expected.update({"changed": True}) + self._plugin._task.args = {"updates": [{"path": "a.b.1", "value": 5}]} + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected) + + def test_run_append_to_list(self): + """Append to list""" + task_vars = {"vars": {"a": {"b": [1, 2, 3]}}} + expected = copy.deepcopy(task_vars["vars"]) + expected["a"]["b"].append(4) + expected.update({"changed": True}) + self._plugin._task.args = {"updates": [{"path": "a.b.3", "value": 4}]} + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected) + + def test_run_bracket_single_quote(self): + """Bracket notation sigle quote""" + task_vars = {"vars": {"a": {"b": [1, 2, 3]}}} + expected = copy.deepcopy(task_vars["vars"]) + expected["a"]["b"].append(4) + expected.update({"changed": True}) + self._plugin._task.args = { + "updates": [{"path": "a['b'][3]", "value": 4}] + } + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected) + + def test_run_bracket_double_quote(self): + """Bracket notation double quote""" + task_vars = {"vars": {"a": {"b": [1, 2, 3]}}} + expected = copy.deepcopy(task_vars["vars"]) + expected["a"]["b"].append(4) + expected.update({"changed": True}) + self._plugin._task.args = { + "updates": [{"path": 'a["b"][3]', "value": 4}] + } + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected) + + def test_run_int_dict_keys(self): + """Integer dict keys""" + task_vars = {"vars": {"a": {0: [1, 2, 3]}}} + expected = copy.deepcopy(task_vars["vars"]) + expected["a"][0][0] = 0 + expected.update({"changed": True}) + self._plugin._task.args = {"updates": [{"path": "a.0.0", "value": 0}]} + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected) + + def test_run_int_as_string(self): + """Integer dict keys as string""" + task_vars = {"vars": {"a": {"0": [1, 2, 3]}}} + expected = copy.deepcopy(task_vars["vars"]) + expected["a"]["0"][0] = 0 + expected.update({"changed": True}) + self._plugin._task.args = { + "updates": [{"path": 'a["0"].0', "value": 0}] + } + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected) + + def test_run_invalid_path_quote_after_dot(self): + """Invalid path format""" + self._plugin._task.args = {"updates": [{"path": "a.'b'", "value": 0}]} + with self.assertRaises(Exception) as error: + self._plugin.run(task_vars={"vars": {}}) + self.assertIn("malformed", str(error.exception)) + + def test_run_invalid_path_bracket_after_dot(self): + """Invalid path format""" + self._plugin._task.args = { + "updates": [{"path": "a.['b']", "value": 0}] + } + with self.assertRaises(Exception) as error: + self._plugin.run(task_vars={"vars": {}}) + self.assertIn("malformed", str(error.exception)) + + def test_run_invalid_key_start_with_dot(self): + """Invalid key format""" + self._plugin._task.args = {"updates": [{"path": ".abc", "value": 0}]} + with self.assertRaises(Exception) as error: + self._plugin.run(task_vars={"vars": {}}) + self.assertIn("malformed", str(error.exception)) + + def test_run_no_update_list(self): + """Confirm no change when same""" + task_vars = {"vars": {"a": {"b": [1, 2, 3]}}} + expected = copy.deepcopy(task_vars["vars"]) + expected["a"]["b"] = [1, 2, 3] + expected.update({"changed": False}) + self._plugin._task.args = {"updates": [{"path": "a.b.0", "value": 1}]} + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected) + + def test_run_no_update_dict(self): + """Confirm no change when same""" + task_vars = {"vars": {"a": {"b": [1, 2, 3]}}} + expected = copy.deepcopy(task_vars["vars"]) + expected["a"]["b"] = [1, 2, 3] + expected.update({"changed": False}) + self._plugin._task.args = { + "updates": [{"path": "a.b", "value": [1, 2, 3]}] + } + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected) + + def test_run_missing_key(self): + """Confirm error when key not found""" + task_vars = {"vars": {"a": {"b": 1}}} + self._plugin._task.args = {"updates": [{"path": "a.c.d", "value": 1}]} + with self.assertRaises(Exception) as error: + self._plugin.run(task_vars=task_vars) + self.assertIn("the key 'c' was not found", str(error.exception)) + + def test_run_list_not_int(self): + """Confirm error when key not found""" + task_vars = {"vars": {"a": {"b": [1]}}} + self._plugin._task.args = { + "updates": [{"path": "a.b['0']", "value": 2}] + } + with self.assertRaises(Exception) as error: + self._plugin.run(task_vars=task_vars) + self.assertIn( + "index provided was not an integer", str(error.exception) + ) + + def test_run_list_not_long(self): + """List not long enough""" + task_vars = {"vars": {"a": {"b": [0]}}} + self._plugin._task.args = {"updates": [{"path": "a.b.2", "value": 2}]} + with self.assertRaises(Exception) as error: + self._plugin.run(task_vars=task_vars) + self.assertIn( + "not long enough for item #2 to be set", str(error.exception) + ) + + def test_not_mutable_sequence_or_mapping(self): + """Confirm graceful fail when immutable object + This should never happen in the real world + """ + obj = {"a": frozenset([1, 2, 3])} + path = ["a", 0] + val = 9 + with self.assertRaises(Exception) as error: + self._plugin.set_value(obj, path, val) + self.assertIn("can only modify mutable objects", str(error.exception)) + + def test_run_not_dotted_success_one(self): + """Test with a not dotted key""" + task_vars = {"vars": {"a": 0}} + expected = copy.deepcopy(task_vars["vars"]) + expected["a"] = 1 + expected.update({"changed": True}) + self._plugin._task.args = {"updates": [{"path": "a", "value": 1}]} + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected) + + def test_run_not_dotted_success_three(self): + """Test with a not dotted key longer""" + task_vars = {"vars": {"abc": 0}} + expected = copy.deepcopy(task_vars["vars"]) + expected["abc"] = 1 + expected.update({"changed": True}) + self._plugin._task.args = {"updates": [{"path": "abc", "value": 1}]} + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected) + + def test_run_not_dotted_fail_missing(self): + """Test with a not dotted key, missing""" + task_vars = {"vars": {"abc": 0}} + self._plugin._task.args = {"updates": [{"path": "123", "value": 1}]} + with self.assertRaises(Exception) as error: + self._plugin.run(task_vars=task_vars) + self.assertIn( + "'123' was not found in the current facts", str(error.exception) + ) + + def test_run_not_dotted_success_same(self): + """Test with a not dotted key, no change""" + task_vars = {"vars": {"a": 0}} + expected = copy.deepcopy(task_vars["vars"]) + expected.update({"changed": False}) + self._plugin._task.args = {"updates": [{"path": "a", "value": 0}]} + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected) + + def test_run_looks_like_a_bool(self): + """Test with a key that looks like a bool""" + task_vars = {"vars": {"a": {"True": 0}}} + expected = copy.deepcopy(task_vars["vars"]) + expected["a"]["True"] = 1 + expected.update({"changed": True}) + self._plugin._task.args = { + "updates": [{"path": "a['True']", "value": 1}] + } + result = self._plugin.run(task_vars=task_vars) + self.assertEqual(result, expected)