From 1b76548d9d5bd808e571165907546e1fdaa7fb75 Mon Sep 17 00:00:00 2001 From: Priyam Sahoo <42550351+priyamsahoo@users.noreply.github.com> Date: Thu, 3 Jun 2021 12:47:07 +0530 Subject: [PATCH] Added 'usable_range' filter plugin (#77) Added 'usable_range' filter plugin Reviewed-by: https://github.com/apps/ansible-zuul --- README.md | 1 + .../add_usable_range_filter_plugin.yml | 3 + docs/ansible.utils.usable_range_filter.rst | 213 ++++++++++++++++++ plugins/filter/usable_range.py | 194 ++++++++++++++++ .../usable_range/tasks/include/argspec.yml | 77 +++++++ .../tasks/include/example_filter.yml | 43 ++++ .../targets/usable_range/tasks/main.yml | 13 ++ .../targets/usable_range/vars/main.yml | 41 ++++ .../unit/plugins/filter/test_usable_range.py | 125 ++++++++++ 9 files changed, 710 insertions(+) create mode 100644 changelogs/fragments/add_usable_range_filter_plugin.yml create mode 100644 docs/ansible.utils.usable_range_filter.rst create mode 100644 plugins/filter/usable_range.py create mode 100644 tests/integration/targets/usable_range/tasks/include/argspec.yml create mode 100644 tests/integration/targets/usable_range/tasks/include/example_filter.yml create mode 100644 tests/integration/targets/usable_range/tasks/main.yml create mode 100644 tests/integration/targets/usable_range/vars/main.yml create mode 100644 tests/unit/plugins/filter/test_usable_range.py diff --git a/README.md b/README.md index f6a79b2..2645d9b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Name | Description [ansible.utils.index_of](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.index_of_filter.rst)|Find the indices 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_filter.rst)|Flatten a complex object into a dictionary of paths and values [ansible.utils.to_xml](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.to_xml_filter.rst)|Convert given JSON string to XML +[ansible.utils.usable_range](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.usable_range_filter.rst)|Expand the usable IP addresses [ansible.utils.validate](https://github.com/ansible-collections/ansible.utils/blob/main/docs/ansible.utils.validate_filter.rst)|Validate data with provided criteria ### Lookup plugins diff --git a/changelogs/fragments/add_usable_range_filter_plugin.yml b/changelogs/fragments/add_usable_range_filter_plugin.yml new file mode 100644 index 0000000..c864d4f --- /dev/null +++ b/changelogs/fragments/add_usable_range_filter_plugin.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - Add usable_range test plugin diff --git a/docs/ansible.utils.usable_range_filter.rst b/docs/ansible.utils.usable_range_filter.rst new file mode 100644 index 0000000..7f32fad --- /dev/null +++ b/docs/ansible.utils.usable_range_filter.rst @@ -0,0 +1,213 @@ +.. _ansible.utils.usable_range_filter: + + +************************** +ansible.utils.usable_range +************************** + +**Expand the usable IP addresses** + + +Version added: 2.3.0 + +.. contents:: + :local: + :depth: 1 + + +Synopsis +-------- +- For a given IP address (IPv4 or IPv6) in CIDR form, the plugin generates a list of usable IP addresses belonging to the network. + + + + +Parameters +---------- + +.. raw:: html + + + + + + + + + + + + + + +
ParameterChoices/DefaultsConfigurationComments
+
+ ip + +
+ string + / required +
+
+ + +
A string that represents an IP address of network in CIDR form
+
{'For example': ['10.0.0.0/24', '2001:db8:abcd:0012::0/124']}
+
+
+ + + + +Examples +-------- + +.. code-block:: yaml + + #### Simple examples + + - name: Expand and produce list of usable IP addresses in 10.0.0.0/28 + ansible.builtin.set_fact: + data: "{{ '10.0.0.0/28' | ansible.utils.usable_range }}" + + # TASK [Expand and produce list of usable IP addresses in 10.0.0.0/28] ************************ + # ok: [localhost] => { + # "ansible_facts": { + # "data": { + # "number_of_ips": 16, + # "usable_ips": [ + # "10.0.0.0", + # "10.0.0.1", + # "10.0.0.2", + # "10.0.0.3", + # "10.0.0.4", + # "10.0.0.5", + # "10.0.0.6", + # "10.0.0.7", + # "10.0.0.8", + # "10.0.0.9", + # "10.0.0.10", + # "10.0.0.11", + # "10.0.0.12", + # "10.0.0.13", + # "10.0.0.14", + # "10.0.0.15" + # ] + # } + # }, + # "changed": false + # } + + - name: Expand and produce list of usable IP addresses in 2001:db8:abcd:0012::0/126 + ansible.builtin.set_fact: + data1: "{{ '2001:db8:abcd:0012::0/126' | ansible.utils.usable_range }}" + + # TASK [Expand and produce list of usable IP addresses in 2001:db8:abcd:0012::0/126] *** + # ok: [localhost] => { + # "ansible_facts": { + # "data1": { + # "number_of_ips": 4, + # "usable_ips": [ + # "2001:db8:abcd:12::", + # "2001:db8:abcd:12::1", + # "2001:db8:abcd:12::2", + # "2001:db8:abcd:12::3" + # ] + # } + # }, + # "changed": false + # } + + - name: Expand and produce list of usable IP addresses in 10.1.1.1 + ansible.builtin.set_fact: + data: "{{ '10.1.1.1' | ansible.utils.usable_range }}" + + # TASK [Expand and produce list of usable IP addresses in 10.1.1.1] *************************** + # ok: [localhost] => { + # "ansible_facts": { + # "data": { + # "number_of_ips": 1, + # "usable_ips": [ + # "10.1.1.1" + # ] + # } + # }, + # "changed": false + # } + + #### Simple Use-case (looping through the list result) + + - name: Expand and produce list of usable IP addresses in 192.0.2.0/28 + ansible.builtin.set_fact: + data1: "{{ '127.0.0.0/28' | ansible.utils.usable_range }}" + + - name: Ping all but first IP addresses from the generated list + shell: "ping -c 1 {{ item }}" + loop: "{{ data1.usable_ips[1:] }}" + + # TASK [Expand and produce list of usable IP addresses in 192.0.2.0/28] ****************************** + # ok: [localhost] + + # TASK [Ping all but first IP addresses from the generated list] ************************************* + # changed: [localhost] => (item=127.0.0.1) + # changed: [localhost] => (item=127.0.0.2) + # changed: [localhost] => (item=127.0.0.3) + # changed: [localhost] => (item=127.0.0.4) + # changed: [localhost] => (item=127.0.0.5) + # changed: [localhost] => (item=127.0.0.6) + # changed: [localhost] => (item=127.0.0.7) + # changed: [localhost] => (item=127.0.0.8) + # changed: [localhost] => (item=127.0.0.9) + # changed: [localhost] => (item=127.0.0.10) + # changed: [localhost] => (item=127.0.0.11) + # changed: [localhost] => (item=127.0.0.12) + # changed: [localhost] => (item=127.0.0.13) + # changed: [localhost] => (item=127.0.0.14) + # changed: [localhost] => (item=127.0.0.15) + + + +Return Values +------------- +Common return values are documented `here `_, the following are the fields unique to this filter: + +.. raw:: html + + + + + + + + + + + + +
KeyReturnedDescription
+
+ data + +
+ - +
+
+
Total number of usable IP addresses under the key number_of_ips
+
List of usable IP addresses under the key usable_ips
+
+
+

+ + +Status +------ + + +Authors +~~~~~~~ + +- Priyam Sahoo (@priyamsahoo) + + +.. hint:: + Configuration entries for each entry type have a low to high priority order. For example, a variable that is lower in the list will override a variable that is higher up. diff --git a/plugins/filter/usable_range.py b/plugins/filter/usable_range.py new file mode 100644 index 0000000..dc4ce11 --- /dev/null +++ b/plugins/filter/usable_range.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +# Copyright 2021 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Filter plugin file for usable_range +""" + +from __future__ import absolute_import, division, print_function +from ipaddress import IPv4Network, IPv6Network + +from ansible_collections.ansible.utils.plugins.plugin_utils.base.ipaddress_utils import ( + _validate_args, + ip_network, + _need_ipaddress, +) + +__metaclass__ = type + +DOCUMENTATION = """ + name: usable_range + author: Priyam Sahoo (@priyamsahoo) + version_added: "2.3.0" + short_description: Expand the usable IP addresses + description: + - For a given IP address (IPv4 or IPv6) in CIDR form, the plugin generates a list of usable IP addresses belonging to the network. + options: + ip: + description: + - A string that represents an IP address of network in CIDR form + - For example: + - "10.0.0.0/24" + - "2001:db8:abcd:0012::0/124" + type: str + required: True + notes: +""" + +EXAMPLES = r""" + +#### Simple examples + +- name: Expand and produce list of usable IP addresses in 10.0.0.0/28 + ansible.builtin.set_fact: + data: "{{ '10.0.0.0/28' | ansible.utils.usable_range }}" + +# TASK [Expand and produce list of usable IP addresses in 10.0.0.0/28] ************************ +# ok: [localhost] => { +# "ansible_facts": { +# "data": { +# "number_of_ips": 16, +# "usable_ips": [ +# "10.0.0.0", +# "10.0.0.1", +# "10.0.0.2", +# "10.0.0.3", +# "10.0.0.4", +# "10.0.0.5", +# "10.0.0.6", +# "10.0.0.7", +# "10.0.0.8", +# "10.0.0.9", +# "10.0.0.10", +# "10.0.0.11", +# "10.0.0.12", +# "10.0.0.13", +# "10.0.0.14", +# "10.0.0.15" +# ] +# } +# }, +# "changed": false +# } + +- name: Expand and produce list of usable IP addresses in 2001:db8:abcd:0012::0/126 + ansible.builtin.set_fact: + data1: "{{ '2001:db8:abcd:0012::0/126' | ansible.utils.usable_range }}" + +# TASK [Expand and produce list of usable IP addresses in 2001:db8:abcd:0012::0/126] *** +# ok: [localhost] => { +# "ansible_facts": { +# "data1": { +# "number_of_ips": 4, +# "usable_ips": [ +# "2001:db8:abcd:12::", +# "2001:db8:abcd:12::1", +# "2001:db8:abcd:12::2", +# "2001:db8:abcd:12::3" +# ] +# } +# }, +# "changed": false +# } + +- name: Expand and produce list of usable IP addresses in 10.1.1.1 + ansible.builtin.set_fact: + data: "{{ '10.1.1.1' | ansible.utils.usable_range }}" + +# TASK [Expand and produce list of usable IP addresses in 10.1.1.1] *************************** +# ok: [localhost] => { +# "ansible_facts": { +# "data": { +# "number_of_ips": 1, +# "usable_ips": [ +# "10.1.1.1" +# ] +# } +# }, +# "changed": false +# } + +#### Simple Use-case (looping through the list result) + +- name: Expand and produce list of usable IP addresses in 192.0.2.0/28 + ansible.builtin.set_fact: + data1: "{{ '127.0.0.0/28' | ansible.utils.usable_range }}" + +- name: Ping all but first IP addresses from the generated list + shell: "ping -c 1 {{ item }}" + loop: "{{ data1.usable_ips[1:] }}" + +# TASK [Expand and produce list of usable IP addresses in 192.0.2.0/28] ****************************** +# ok: [localhost] + +# TASK [Ping all but first IP addresses from the generated list] ************************************* +# changed: [localhost] => (item=127.0.0.1) +# changed: [localhost] => (item=127.0.0.2) +# changed: [localhost] => (item=127.0.0.3) +# changed: [localhost] => (item=127.0.0.4) +# changed: [localhost] => (item=127.0.0.5) +# changed: [localhost] => (item=127.0.0.6) +# changed: [localhost] => (item=127.0.0.7) +# changed: [localhost] => (item=127.0.0.8) +# changed: [localhost] => (item=127.0.0.9) +# changed: [localhost] => (item=127.0.0.10) +# changed: [localhost] => (item=127.0.0.11) +# changed: [localhost] => (item=127.0.0.12) +# changed: [localhost] => (item=127.0.0.13) +# changed: [localhost] => (item=127.0.0.14) +# changed: [localhost] => (item=127.0.0.15) + +""" + +RETURN = """ + data: + description: + - Total number of usable IP addresses under the key C(number_of_ips) + - List of usable IP addresses under the key C(usable_ips) +""" + +from ansible.errors import AnsibleFilterError +from ansible.module_utils.common.text.converters import to_text +from ansible.module_utils.six import ensure_text + + +@_need_ipaddress +def _usable_range(ip): + """Expand the usable IP addresses""" + + params = {"ip": ip} + _validate_args("usable_range", DOCUMENTATION, params) + + try: + if ip_network(ip).version == 4: + ips = [ + to_text(usable_ips) + for usable_ips in IPv4Network(ensure_text(ip)) + ] + no_of_ips = IPv4Network(ensure_text(ip)).num_addresses + if ip_network(ip).version == 6: + ips = [ + to_text(usable_ips) + for usable_ips in IPv6Network(ensure_text(ip)) + ] + no_of_ips = IPv6Network(ensure_text(ip)).num_addresses + + except Exception as e: + raise AnsibleFilterError( + "Error while using plugin 'usable_range': {msg}".format( + msg=to_text(e) + ) + ) + + return {"usable_ips": ips, "number_of_ips": no_of_ips} + + +class FilterModule(object): + """ usable_range """ + + def filters(self): + + """a mapping of filter names to functions""" + return {"usable_range": _usable_range} diff --git a/tests/integration/targets/usable_range/tasks/include/argspec.yml b/tests/integration/targets/usable_range/tasks/include/argspec.yml new file mode 100644 index 0000000..0faba9a --- /dev/null +++ b/tests/integration/targets/usable_range/tasks/include/argspec.yml @@ -0,0 +1,77 @@ +--- +- name: Check argspec validation with filter (missing arg) + ansible.builtin.set_fact: + _result1: "{{ '' | ansible.utils.usable_range }}" + ignore_errors: true + register: result1 + +- assert: + that: "{{ msg in result1.msg }}" + vars: + msg: "does not appear to be an IPv4 or IPv6 network" + +- name: Check argspec validation with filter (random string arg) + ansible.builtin.set_fact: + _result2: "{{ 'helloworld' | ansible.utils.usable_range }}" + ignore_errors: true + register: result2 + +- assert: + that: "{{ msg in result2.msg }}" + vars: + msg: "does not appear to be an IPv4 or IPv6 network" + +- name: Check argspec validation with filter (invalid arg for expansion) + ansible.builtin.set_fact: + _result3: "{{ '192.168.1.25/24' | ansible.utils.usable_range }}" + ignore_errors: true + register: result3 + +- assert: + that: "{{ msg in result3.msg }}" + vars: + msg: "has host bits set" + +- name: Check argspec validation with filter (invalid format for arg) + ansible.builtin.set_fact: + _result4: "{{ '192.0.2.0/23/24' | ansible.utils.usable_range }}" + ignore_errors: true + register: result4 + +- assert: + that: "{{ msg in result4.msg }}" + vars: + msg: "does not appear to be an IPv4 or IPv6 network" + +- name: Check argspec validation with filter (invalid format for arg) + ansible.builtin.set_fact: + _result5: "{{ '::/20/30' | ansible.utils.usable_range }}" + ignore_errors: true + register: result5 + +- assert: + that: "{{ msg in result5.msg }}" + vars: + msg: "does not appear to be an IPv4 or IPv6 network" + +- name: Check argspec validation with filter (invalid netmask) + ansible.builtin.set_fact: + _result6: "{{ '10.0.0.0/322' | ansible.utils.usable_range }}" + ignore_errors: true + register: result6 + +- assert: + that: "{{ msg in result6.msg }}" + vars: + msg: "does not appear to be an IPv4 or IPv6 network" + +- name: Check argspec validation with filter (invalid netmask) + ansible.builtin.set_fact: + _result7: "{{ '2001:db8:abcd:0012::0/129' | ansible.utils.usable_range }}" + ignore_errors: true + register: result7 + +- assert: + that: "{{ msg in result7.msg }}" + vars: + msg: "does not appear to be an IPv4 or IPv6 network" diff --git a/tests/integration/targets/usable_range/tasks/include/example_filter.yml b/tests/integration/targets/usable_range/tasks/include/example_filter.yml new file mode 100644 index 0000000..d68176e --- /dev/null +++ b/tests/integration/targets/usable_range/tasks/include/example_filter.yml @@ -0,0 +1,43 @@ +--- +# IPv4 +- name: Expand and produce list of usable IP addresses in 10.1.1.1 + ansible.builtin.set_fact: + result1: "{{ '10.1.1.1' | ansible.utils.usable_range }}" + +- name: Assert result for 10.1.1.1 + assert: + that: "{{ result1 == result1_val }}" + +- name: Expand and produce list of usable IP addresses in 10.0.0.0/28 + ansible.builtin.set_fact: + result2: "{{ '10.0.0.0/28' | ansible.utils.usable_range }}" + +- name: Assert result for 10.0.0.0/28 + assert: + that: "{{ result2 == result2_val }}" + +- name: Expand and produce list of usable IP addresses in 192.0.2.0/24 + ansible.builtin.set_fact: + result3: "{{ '192.0.2.0/24' | ansible.utils.usable_range }}" + +- name: Assert result for 192.0.2.0/24 + assert: + # Since the list is huge, asserting only on number of ips + that: "{{ result3.number_of_ips == result3_val.number_of_ips }}" + +# IPv6 +- name: Expand and produce list of usable IP addresses in 2001:db8:abcd:0012::0/126 + ansible.builtin.set_fact: + result4: "{{ '2001:db8:abcd:0012::0/126' | ansible.utils.usable_range }}" + +- name: Assert result for 2001:db8:abcd:0012::0/126 + assert: + that: "{{ result4 == result4_val }}" + +- name: "Expand and produce list of usable IP addresses in 2001:db8:abcd:12::" + ansible.builtin.set_fact: + result5: "{{ '2001:db8:abcd:12::' | ansible.utils.usable_range }}" + +- name: "Assert result for 2001:db8:abcd:12::" + assert: + that: "{{ result5 == result5_val }}" diff --git a/tests/integration/targets/usable_range/tasks/main.yml b/tests/integration/targets/usable_range/tasks/main.yml new file mode 100644 index 0000000..b1ea41b --- /dev/null +++ b/tests/integration/targets/usable_range/tasks/main.yml @@ -0,0 +1,13 @@ +--- +- name: Recursively find all test files + find: + file_type: file + paths: "{{ role_path }}/tasks/include" + recurse: true + use_regex: true + patterns: + - "^(?!_).+$" + register: found + +- include: "{{ item.path }}" + loop: "{{ found.files }}" diff --git a/tests/integration/targets/usable_range/vars/main.yml b/tests/integration/targets/usable_range/vars/main.yml new file mode 100644 index 0000000..800517f --- /dev/null +++ b/tests/integration/targets/usable_range/vars/main.yml @@ -0,0 +1,41 @@ +--- +result1_val: + number_of_ips: 1 + usable_ips: + - "10.1.1.1" + +result2_val: + number_of_ips: 16 + usable_ips: + - "10.0.0.0" + - "10.0.0.1" + - "10.0.0.2" + - "10.0.0.3" + - "10.0.0.4" + - "10.0.0.5" + - "10.0.0.6" + - "10.0.0.7" + - "10.0.0.8" + - "10.0.0.9" + - "10.0.0.10" + - "10.0.0.11" + - "10.0.0.12" + - "10.0.0.13" + - "10.0.0.14" + - "10.0.0.15" + +result3_val: + number_of_ips: 256 + +result4_val: + number_of_ips: 4 + usable_ips: + - "2001:db8:abcd:12::" + - "2001:db8:abcd:12::1" + - "2001:db8:abcd:12::2" + - "2001:db8:abcd:12::3" + +result5_val: + number_of_ips: 1 + usable_ips: + - "2001:db8:abcd:12::" diff --git a/tests/unit/plugins/filter/test_usable_range.py b/tests/unit/plugins/filter/test_usable_range.py new file mode 100644 index 0000000..dc6bd87 --- /dev/null +++ b/tests/unit/plugins/filter/test_usable_range.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# Copyright 2021 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit test file for usable_range filter plugin +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import unittest +from ansible.errors import AnsibleError +from ansible_collections.ansible.utils.plugins.filter.usable_range import ( + _usable_range, +) + +INVALID_DATA_1 = [ + "helloworld", + "192.0.2.0/23/24", + "::/20/30", + "10.0.0.0/322", + "2001:db8:abcd:0012::0/129", +] +INVALID_DATA_2 = ["192.168.1.25/24", "2001:db8:abcd:12::2/126"] + +VALID_DATA = [ + "10.0.0.8/30", + "192.0.2.0/28", + "2001:db8:abcd:0012::0/126", + "2001:DB8:ABCD:12::", +] + +VALID_OUTPUT_1 = { + "number_of_ips": 4, + "usable_ips": ["10.0.0.8", "10.0.0.9", "10.0.0.10", "10.0.0.11"], +} +VALID_OUTPUT_2 = { + "number_of_ips": 16, + "usable_ips": [ + "192.0.2.0", + "192.0.2.1", + "192.0.2.2", + "192.0.2.3", + "192.0.2.4", + "192.0.2.5", + "192.0.2.6", + "192.0.2.7", + "192.0.2.8", + "192.0.2.9", + "192.0.2.10", + "192.0.2.11", + "192.0.2.12", + "192.0.2.13", + "192.0.2.14", + "192.0.2.15", + ], +} + +VALID_OUTPUT_3 = { + "number_of_ips": 4, + "usable_ips": [ + "2001:db8:abcd:12::", + "2001:db8:abcd:12::1", + "2001:db8:abcd:12::2", + "2001:db8:abcd:12::3", + ], +} +VALID_OUTPUT_4 = {"number_of_ips": 1, "usable_ips": ["2001:db8:abcd:12::"]} + + +class TestUsableRange(unittest.TestCase): + def setUp(self): + pass + + def test_missing_data(self): + """Check passing missing argspec""" + + # missing required arguments + ip = "" + with self.assertRaises(AnsibleError) as error: + _usable_range(ip) + self.assertIn( + "does not appear to be an IPv4 or IPv6 network", + str(error.exception), + ) + + def test_invalid_data(self): + """Check passing invalid argspec""" + + # invalid required arguments + + for invalid_data in INVALID_DATA_1: + with self.assertRaises(AnsibleError) as error: + _usable_range(invalid_data) + self.assertIn( + "does not appear to be an IPv4 or IPv6 network", + str(error.exception), + ) + + for invalid_data in INVALID_DATA_2: + with self.assertRaises(AnsibleError) as error: + _usable_range(invalid_data) + self.assertIn("has host bits set", str(error.exception)) + + def test_valid_data(self): + """Check passing valid data as per criteria""" + + ip = VALID_DATA[0] + result = _usable_range(ip) + self.assertEqual(result, VALID_OUTPUT_1) + + ip = VALID_DATA[1] + result = _usable_range(ip) + self.assertEqual(result, VALID_OUTPUT_2) + + ip = VALID_DATA[2] + result = _usable_range(ip) + self.assertEqual(result, VALID_OUTPUT_3) + + ip = VALID_DATA[3] + result = _usable_range(ip) + self.assertEqual(result, VALID_OUTPUT_4)