From d5a1fdc075845485551827ac4e812266993bd08d Mon Sep 17 00:00:00 2001 From: Mikhail Yohman Date: Fri, 11 Mar 2022 11:10:20 -0700 Subject: [PATCH 1/7] Moved GraphQL query construction into a Python function (filter plugin). Add ability to add filters at every level. --- .dockerignore | 6 ++ ansible.cfg | 2 +- plugins/filter/graphql.py | 72 +++++++++++++++++++ plugins/inventory/gql_inventory.py | 25 +++++-- plugins/templates/graphql_additional_query.j2 | 5 -- plugins/templates/graphql_default_query.j2 | 19 ----- plugins/templates/graphql_filters.j2 | 1 - 7 files changed, 98 insertions(+), 32 deletions(-) create mode 100644 .dockerignore create mode 100644 plugins/filter/graphql.py delete mode 100644 plugins/templates/graphql_additional_query.j2 delete mode 100644 plugins/templates/graphql_default_query.j2 delete mode 100644 plugins/templates/graphql_filters.j2 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5d30823a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +Dockerfile +docker-compose.yml +*.md +.env +.vscode/ +.github/ \ No newline at end of file diff --git a/ansible.cfg b/ansible.cfg index 57239e45..ab246d7a 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -2,4 +2,4 @@ force_valid_group_names = always [inventory] -enable_plugins = networktocode.nautobot.inventory, auto, host_list, yaml, ini, toml, script +enable_plugins = networktocode.nautobot.inventory, networktocode.nautobot.gql_inventory, yaml, ini diff --git a/plugins/filter/graphql.py b/plugins/filter/graphql.py new file mode 100644 index 00000000..3ea4f5bd --- /dev/null +++ b/plugins/filter/graphql.py @@ -0,0 +1,72 @@ +"""GraphQL related filter plugins.""" + + +def build_graphql_filter_string(filter: dict) -> str: + """Takes a dictionary and builds a graphql filter + + Args: + filter (dict): Key/Value pairs to build filter from + + Returns: + str: Proper graphQL filter + """ + base_filter = "({0})" + loop_filters = [] + for key, value in filter.items(): + temp_string = f"{key}: " + value_string = f"{value}" + + # GraphQL variables do not need quotes + if isinstance(value, str) and not key.startswith("$"): + value_string = "'" + value_string + "'" + + loop_filters.append(temp_string + value_string) + + return base_filter.format(", ".join(loop_filters)) + + +def convert_to_graphql_string(query: dict, start=0) -> str: + """Provide a dictionary to convert to a graphQL string. + + Args: + query (dict): A dictionary mapping to the graphQL call to be made. + + Returns: + str: GraphQL query string + """ + graphql_string = f"""""" + for k, v in query.items(): + loop_string = "{}".format(" " * (start * 2)) + loop_filter = None + if not v: + loop_string += f"{k}\n" + elif isinstance(v, dict): + if v.get("filters"): + loop_filter = build_graphql_filter_string(v.pop("filters")) + # Increment start for recursion + start += 1 + loop_string += "{0} {1}\n".format(k, loop_filter + " {" if loop_filter else " {") + loop_string += convert_to_graphql_string(v, start) + # Decrement start to continue at the same level we were at prior to recursion + start -= 1 + loop_string += "{0}{1}\n".format(" " * (start * 2), "}") + else: + loop_string += '{0} {1}\n'.format(k, "{") + # We want to keep the doubling spaces, but add 2 more + loop_string += "{0}".format(" " * (start * 2 + 2)) + loop_string += f"{v}\n" + loop_string += "{0}{1}\n".format(" " * (start * 2), "}") + # loop_string += "}\n" + graphql_string += loop_string + + return graphql_string.replace("'", '"') + + +class FilterModule: + """Return graphQL filters.""" + + def filters(self): + """Map filter functions to filter names.""" + return { + "graphql_string": convert_to_graphql_string, + } diff --git a/plugins/inventory/gql_inventory.py b/plugins/inventory/gql_inventory.py index 4746072a..7241ec1a 100644 --- a/plugins/inventory/gql_inventory.py +++ b/plugins/inventory/gql_inventory.py @@ -134,7 +134,8 @@ from ansible.module_utils.urls import open_url from ansible.module_utils.six.moves.urllib import error as urllib_error -from jinja2 import Environment, FileSystemLoader +# from jinja2 import Environment, FileSystemLoader +from ansible_collections.networktocode.nautobot.plugins.filter.graphql import convert_to_graphql_string try: from netutils.lib_mapper import ANSIBLE_LIB_MAPPER_REVERSE, NAPALM_LIB_MAPPER @@ -242,10 +243,20 @@ def main(self): if not HAS_NETUTILS: raise AnsibleError("networktocode.nautobot.gql_inventory requires netutils. Please pip install netutils.") - file_loader = FileSystemLoader(f"{PATH}/../templates") - env = Environment(loader=file_loader, autoescape=True) - template = env.get_template("graphql_default_query.j2") - query = template.render(query=self.gql_query, filters=self.filters) + base_query = { + "devices": { + "name": None, + "platform": "napalm_driver", + "status": "name", + "primary_ip4": "address", + "device_role": "name", + "site": "name", + + } + } + base_query["devices"].update(self.gql_query) + base_query["devices"]["filters"] = self.filters + query = convert_to_graphql_string(base_query) data = {"query": "query {%s}" % query} try: @@ -272,6 +283,7 @@ def main(self): # Need to return mock response data that is empty to prevent any failures downstream return {"results": [], "next": None} else: + self.display.display(f"{e.code}", color="red") self.display.display( "Something went wrong while executing the query.\nReason: {reason}".format( reason=json.loads(e.fp.read().decode())["errors"][0]["message"], @@ -295,7 +307,8 @@ def main(self): self.inventory.add_host(device["name"]) self.add_ipv4_address(device) self.add_ansible_platform(device) - self.populate_variables(device) + if self.variables: + self.populate_variables(device) self.create_groups(device) def parse(self, inventory, loader, path, cache=True): diff --git a/plugins/templates/graphql_additional_query.j2 b/plugins/templates/graphql_additional_query.j2 deleted file mode 100644 index 8a5fe362..00000000 --- a/plugins/templates/graphql_additional_query.j2 +++ /dev/null @@ -1,5 +0,0 @@ -{%- for key, value in query.items() -%} - {{ key }} { - {{value}} - } -{%- endfor -%} \ No newline at end of file diff --git a/plugins/templates/graphql_default_query.j2 b/plugins/templates/graphql_default_query.j2 deleted file mode 100644 index 7df9c06c..00000000 --- a/plugins/templates/graphql_default_query.j2 +++ /dev/null @@ -1,19 +0,0 @@ -devices {% include "graphql_filters.j2" %} { - name - platform { - napalm_driver - } - status { - name - } - primary_ip4 { - address - } - device_role { - name - } - site { - name - } -{% include "graphql_additional_query.j2" %} -} diff --git a/plugins/templates/graphql_filters.j2 b/plugins/templates/graphql_filters.j2 deleted file mode 100644 index 9f148ca8..00000000 --- a/plugins/templates/graphql_filters.j2 +++ /dev/null @@ -1 +0,0 @@ -{% if filters %}({% for key, value in filters.items() %}{{ key }}: "{{ value }}" {% endfor %}) {% endif %} \ No newline at end of file From 52e9edac1c09736b40861c36faccfc6bbac3777d Mon Sep 17 00:00:00 2001 From: Mikhail Yohman Date: Fri, 25 Mar 2022 12:57:48 -0600 Subject: [PATCH 2/7] Add testing and fixed bug when spacing issue --- plugins/filter/graphql.py | 40 +++++++----- plugins/inventory/gql_inventory.py | 14 +++- tests/unit/filter/__init__.py | 0 .../unit/filter/test_data/graphql_string.json | 65 +++++++++++++++++++ tests/unit/filter/test_graphql.py | 25 +++++++ 5 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 tests/unit/filter/__init__.py create mode 100644 tests/unit/filter/test_data/graphql_string.json create mode 100644 tests/unit/filter/test_graphql.py diff --git a/plugins/filter/graphql.py b/plugins/filter/graphql.py index 3ea4f5bd..24b8b422 100644 --- a/plugins/filter/graphql.py +++ b/plugins/filter/graphql.py @@ -1,4 +1,10 @@ """GraphQL related filter plugins.""" +# Copyright (c) 2022, Network to Code (@networktocode) +# 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 def build_graphql_filter_string(filter: dict) -> str: @@ -16,7 +22,7 @@ def build_graphql_filter_string(filter: dict) -> str: temp_string = f"{key}: " value_string = f"{value}" - # GraphQL variables do not need quotes + # GraphQL variables do not need quotes (This isn't completely support with inventory yet, but code added here) if isinstance(value, str) and not key.startswith("$"): value_string = "'" + value_string + "'" @@ -34,29 +40,33 @@ def convert_to_graphql_string(query: dict, start=0) -> str: Returns: str: GraphQL query string """ - graphql_string = f"""""" + graphql_string = """""" + loop_index = 0 + query_len = len(query) for k, v in query.items(): - loop_string = "{}".format(" " * (start * 2)) + loop_index += 1 + loop_string = "".rjust(start * 2, " ") loop_filter = None + + # Determine what to do if not v: - loop_string += f"{k}\n" + loop_string += f"{k}" elif isinstance(v, dict): if v.get("filters"): loop_filter = build_graphql_filter_string(v.pop("filters")) - # Increment start for recursion - start += 1 - loop_string += "{0} {1}\n".format(k, loop_filter + " {" if loop_filter else " {") - loop_string += convert_to_graphql_string(v, start) - # Decrement start to continue at the same level we were at prior to recursion - start -= 1 - loop_string += "{0}{1}\n".format(" " * (start * 2), "}") + loop_string += "{0} {1}\n".format(k, loop_filter + " {" if loop_filter else "{") + loop_string += convert_to_graphql_string(v, start + 1) + loop_string += "{0}{1}".format(" " * (start * 2), "}") else: - loop_string += '{0} {1}\n'.format(k, "{") - # We want to keep the doubling spaces, but add 2 more + loop_string += "{0} {1}\n".format(k, "{") + # We want to keep the double spaces, but add 2 more loop_string += "{0}".format(" " * (start * 2 + 2)) loop_string += f"{v}\n" - loop_string += "{0}{1}\n".format(" " * (start * 2), "}") - # loop_string += "}\n" + loop_string += "{0}{1}".format(" " * (start * 2), "}") + + if loop_index != query_len or start > 0: + loop_string += "\n" + graphql_string += loop_string return graphql_string.replace("'", '"') diff --git a/plugins/inventory/gql_inventory.py b/plugins/inventory/gql_inventory.py index 7241ec1a..f62bdf02 100644 --- a/plugins/inventory/gql_inventory.py +++ b/plugins/inventory/gql_inventory.py @@ -82,12 +82,22 @@ # inventory.yml file in YAML format # Example command line: ansible-inventory -v --list -i inventory.yml -# Add additional query parameter with query key +# Add additional query parameter with query key and use filters plugin: networktocode.nautobot.gql_inventory api_endpoint: http://localhost:8000 validate_certs: True query: tags: name + serial: + site: + filters: + tenant: "den" + name: + description: + contact_name: + description: + region: + name: # To group by use group_by key # Specify the full path to the data you would like to use to group by. @@ -134,6 +144,7 @@ from ansible.module_utils.urls import open_url from ansible.module_utils.six.moves.urllib import error as urllib_error + # from jinja2 import Environment, FileSystemLoader from ansible_collections.networktocode.nautobot.plugins.filter.graphql import convert_to_graphql_string @@ -251,7 +262,6 @@ def main(self): "primary_ip4": "address", "device_role": "name", "site": "name", - } } base_query["devices"].update(self.gql_query) diff --git a/tests/unit/filter/__init__.py b/tests/unit/filter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/filter/test_data/graphql_string.json b/tests/unit/filter/test_data/graphql_string.json new file mode 100644 index 00000000..30694c7a --- /dev/null +++ b/tests/unit/filter/test_data/graphql_string.json @@ -0,0 +1,65 @@ +[ + { + "query": { + "serial": null, + "asset_tag": null + }, + "expected": "serial\nasset_tag" + }, + { + "query": { + "serial": null, + "asset_tag": null, + "site": { + "name": null, + "contact_name": null, + "description": null, + "region": { + "name": null, + "parent": { + "name": null + } + } + } + }, + "expected": "serial\nasset_tag\nsite {\n name\n contact_name\n description\n region {\n name\n parent {\n name\n }\n }\n}" + }, + { + "query": { + "serial": null, + "asset_tag": null, + "site": { + "filters": { + "role": "core", + "tenant": "den" + }, + "name": null, + "contact_name": null, + "description": null, + "region": { + "filters": { + "$tenant": "den" + }, + "name": null, + "parent": { + "name": null + } + } + } + }, + "expected": "serial\nasset_tag\nsite (role: \"core\", tenant: \"den\") {\n name\n contact_name\n description\n region ($tenant: den) {\n name\n parent {\n name\n }\n }\n}" + }, + { + "query": { + "device": { + "name": null, + "platform": "napalm_driver", + "status": "name", + "primary_ip4": "address", + "device_role": "name", + "site": "name" + } + }, + "expected": "device {\n name\n platform {\n napalm_driver\n }\n status {\n name\n }\n primary_ip4 {\n address\n }\n device_role {\n name\n }\n site {\n name\n }\n}" + } +] \ No newline at end of file diff --git a/tests/unit/filter/test_graphql.py b/tests/unit/filter/test_graphql.py new file mode 100644 index 00000000..013ee2bc --- /dev/null +++ b/tests/unit/filter/test_graphql.py @@ -0,0 +1,25 @@ +"""Tests for Nautobot GraphQL filter plugins.""" + +import json +import pytest +from ansible_collections.networktocode.nautobot.plugins.filter.graphql import ( + convert_to_graphql_string, + build_graphql_filter_string, +) + + +def load_test_data(test): + with open(f"tests/unit/filter/test_data/{test}.json", "r") as f: + data = json.loads(f.read()) + return data + + +@pytest.mark.parametrize(("test_data"), load_test_data("graphql_string")) +def test_convert_to_graphql_string(test_data): + result = convert_to_graphql_string(test_data["query"]) + assert result == test_data["expected"] + + +def test_build_graphql_filter_string(): + gql_filters = {"role": "core", "$tenant": "den", "device_type": "c9300"} + assert build_graphql_filter_string(gql_filters) == "(role: 'core', $tenant: den, device_type: 'c9300')" From c3f275f3daeeecbd92b0d5fa42fbcaf2c4b4bb68 Mon Sep 17 00:00:00 2001 From: Mikhail Yohman Date: Mon, 28 Mar 2022 16:47:50 -0600 Subject: [PATCH 3/7] Update plugins/inventory/gql_inventory.py Co-authored-by: Joe Wesch <10467633+joewesch@users.noreply.github.com> --- plugins/inventory/gql_inventory.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/inventory/gql_inventory.py b/plugins/inventory/gql_inventory.py index 81003229..5e084a60 100644 --- a/plugins/inventory/gql_inventory.py +++ b/plugins/inventory/gql_inventory.py @@ -145,7 +145,6 @@ from ansible.module_utils.six.moves.urllib import error as urllib_error -# from jinja2 import Environment, FileSystemLoader from ansible_collections.networktocode.nautobot.plugins.filter.graphql import convert_to_graphql_string try: From 04e37f152b3fd4ad94e735d5bf5ae8f0a4841ef7 Mon Sep 17 00:00:00 2001 From: Mikhail Yohman Date: Mon, 28 Mar 2022 16:59:21 -0600 Subject: [PATCH 4/7] Updated doc string for missing arg. Fixed graphql query to fetch host instead of address from IP. Remove unnecessary jinja template. --- plugins/filter/graphql.py | 1 + plugins/inventory/gql_inventory.py | 2 +- plugins/templates/graphql_default_query.j2 | 19 ------------------- 3 files changed, 2 insertions(+), 20 deletions(-) delete mode 100644 plugins/templates/graphql_default_query.j2 diff --git a/plugins/filter/graphql.py b/plugins/filter/graphql.py index 24b8b422..d596cfe2 100644 --- a/plugins/filter/graphql.py +++ b/plugins/filter/graphql.py @@ -36,6 +36,7 @@ def convert_to_graphql_string(query: dict, start=0) -> str: Args: query (dict): A dictionary mapping to the graphQL call to be made. + start (int): The starting indentation when compiling string. Returns: str: GraphQL query string diff --git a/plugins/inventory/gql_inventory.py b/plugins/inventory/gql_inventory.py index 5e084a60..c19e0716 100644 --- a/plugins/inventory/gql_inventory.py +++ b/plugins/inventory/gql_inventory.py @@ -258,7 +258,7 @@ def main(self): "name": None, "platform": "napalm_driver", "status": "name", - "primary_ip4": "address", + "primary_ip4": "host", "device_role": "name", "site": "name", } diff --git a/plugins/templates/graphql_default_query.j2 b/plugins/templates/graphql_default_query.j2 deleted file mode 100644 index 34405e80..00000000 --- a/plugins/templates/graphql_default_query.j2 +++ /dev/null @@ -1,19 +0,0 @@ -devices {% include "graphql_filters.j2" %} { - name - platform { - napalm_driver - } - status { - name - } - primary_ip4 { - host - } - device_role { - name - } - site { - name - } -{% include "graphql_additional_query.j2" %} -} From 4ce1cc671645ae181c5d18a07ada0e1d148c5197 Mon Sep 17 00:00:00 2001 From: Mikhail Yohman Date: Mon, 28 Mar 2022 17:56:39 -0600 Subject: [PATCH 5/7] Removed some string manipulation. --- plugins/inventory/gql_inventory.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/plugins/inventory/gql_inventory.py b/plugins/inventory/gql_inventory.py index c19e0716..3afaa4a3 100644 --- a/plugins/inventory/gql_inventory.py +++ b/plugins/inventory/gql_inventory.py @@ -254,19 +254,23 @@ def main(self): raise AnsibleError("networktocode.nautobot.gql_inventory requires netutils. Please pip install netutils.") base_query = { - "devices": { - "name": None, - "platform": "napalm_driver", - "status": "name", - "primary_ip4": "host", - "device_role": "name", - "site": "name", + "query": { + "devices": { + "name": None, + "platform": "napalm_driver", + "status": "name", + "primary_ip4": "host", + "device_role": "name", + "site": "name", + } } } - base_query["devices"].update(self.gql_query) - base_query["devices"]["filters"] = self.filters + base_query["query"]["devices"].update(self.gql_query) + if self.filters: + base_query["query"]["devices"]["filters"] = self.filters query = convert_to_graphql_string(base_query) - data = {"query": "query {%s}" % query} + data = {"query": query} + self.display.display(query) try: response = open_url( @@ -316,8 +320,7 @@ def main(self): self.inventory.add_host(device["name"]) self.add_ipv4_address(device) self.add_ansible_platform(device) - if self.variables: - self.populate_variables(device) + self.populate_variables(device) self.create_groups(device) def parse(self, inventory, loader, path, cache=True): From 89442d25840a2fba5e620bec6c7861ed5566c9a2 Mon Sep 17 00:00:00 2001 From: Mikhail Yohman Date: Mon, 28 Mar 2022 18:37:49 -0600 Subject: [PATCH 6/7] Updated tests. --- tests/unit/filter/test_data/graphql_string.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/filter/test_data/graphql_string.json b/tests/unit/filter/test_data/graphql_string.json index 30694c7a..6c5057ca 100644 --- a/tests/unit/filter/test_data/graphql_string.json +++ b/tests/unit/filter/test_data/graphql_string.json @@ -6,6 +6,17 @@ }, "expected": "serial\nasset_tag" }, + { + "query": { + "query": { + "devices": { + "serial": null, + "asset_tag": null + } + } + }, + "expected": "query {\n devices {\n serial\n asset_tag\n }\n}" + }, { "query": { "serial": null, From 12ea5dbc657720cf819c51d32e333920d243efd9 Mon Sep 17 00:00:00 2001 From: Mikhail Yohman Date: Mon, 28 Mar 2022 19:52:32 -0600 Subject: [PATCH 7/7] Remove extraneous debug display. --- plugins/inventory/gql_inventory.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/inventory/gql_inventory.py b/plugins/inventory/gql_inventory.py index 3afaa4a3..ea64f20a 100644 --- a/plugins/inventory/gql_inventory.py +++ b/plugins/inventory/gql_inventory.py @@ -270,7 +270,6 @@ def main(self): base_query["query"]["devices"]["filters"] = self.filters query = convert_to_graphql_string(base_query) data = {"query": query} - self.display.display(query) try: response = open_url(