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..d596cfe2 --- /dev/null +++ b/plugins/filter/graphql.py @@ -0,0 +1,83 @@ +"""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: + """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 (This isn't completely support with inventory yet, but code added here) + 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. + start (int): The starting indentation when compiling string. + + Returns: + str: GraphQL query string + """ + graphql_string = """""" + loop_index = 0 + query_len = len(query) + for k, v in query.items(): + loop_index += 1 + loop_string = "".rjust(start * 2, " ") + loop_filter = None + + # Determine what to do + if not v: + loop_string += f"{k}" + elif isinstance(v, dict): + if v.get("filters"): + loop_filter = build_graphql_filter_string(v.pop("filters")) + 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 double spaces, but add 2 more + loop_string += "{0}".format(" " * (start * 2 + 2)) + loop_string += f"{v}\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("'", '"') + + +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 660a0433..ea64f20a 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,7 +144,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 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,11 +253,23 @@ 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) - data = {"query": "query {%s}" % query} + base_query = { + "query": { + "devices": { + "name": None, + "platform": "napalm_driver", + "status": "name", + "primary_ip4": "host", + "device_role": "name", + "site": "name", + } + } + } + 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} try: response = open_url( @@ -272,6 +295,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"], 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 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" %} -} 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 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..6c5057ca --- /dev/null +++ b/tests/unit/filter/test_data/graphql_string.json @@ -0,0 +1,76 @@ +[ + { + "query": { + "serial": null, + "asset_tag": null + }, + "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, + "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')"