diff --git a/.dockerignore b/.dockerignore index 5d30823a..fbc8fa33 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,6 @@ Dockerfile docker-compose.yml *.md .env +.venv .vscode/ -.github/ \ No newline at end of file +.github/ diff --git a/plugins/inventory/gql_inventory.py b/plugins/inventory/gql_inventory.py index ea64f20a..e5ea579b 100644 --- a/plugins/inventory/gql_inventory.py +++ b/plugins/inventory/gql_inventory.py @@ -7,131 +7,178 @@ __metaclass__ = type DOCUMENTATION = """ - name: gql_inventory - plugin_type: inventory - author: - - Armen Martirosyan - short_description: Nautobot inventory source using GraphQL capability +name: gql_inventory +plugin_type: inventory +author: + - Armen Martirosyan +short_description: Nautobot inventory source using GraphQL capability +description: + - Get inventory hosts from Nautobot using GraphQL queries +requirements: + - netutils +options: + plugin: + description: Setting that ensures this is a source file for the 'networktocode.nautobot' plugin. + required: True + choices: ["networktocode.nautobot.gql_inventory"] + api_endpoint: + description: Endpoint of the Nautobot API + required: True + env: + - name: NAUTOBOT_URL + timeout: + description: Timeout for Nautobot requests in seconds + type: int + default: 60 + follow_redirects: description: - - Get inventory hosts from Nautobot using GraphQL queries - requirements: - - netutils - options: - plugin: - description: Setting that ensures this is a source file for the 'networktocode.nautobot' plugin. - required: True - choices: ['networktocode.nautobot.gql_inventory'] - api_endpoint: - description: Endpoint of the Nautobot API - required: True - env: - - name: NAUTOBOT_URL - timeout: - description: Timeout for Nautobot requests in seconds - type: int - default: 60 - follow_redirects: - description: - - Determine how redirects are followed. - - By default, I(follow_redirects) is set to uses urllib2 default behavior. - default: urllib2 - choices: ['urllib2', 'all', 'yes', 'safe', 'none'] - validate_certs: - description: - - Allows connection when SSL certificates are not valid. Set to C(false) when certificates are not trusted. - default: True - type: boolean - token: - required: True - description: - - Nautobot API token to be able to read against Nautobot. - - This may not be required depending on the Nautobot setup. - env: - # in order of precedence - - name: NAUTOBOT_TOKEN - query: - required: False - description: - - GraphQL query to send to Nautobot to obtain desired data - type: dict - default: {} - additional_variables: - required: False - description: - - Variable types and values to use while making the call - type: list - default: [] - group_by: - required: False - description: - - List of dot-sparated paths to index graphql query results (e.g. `platform.slug`) - - The final value returned by each path is used to derive group names and then group the devices into these groups. - - Valid group names must be string, so indexing the dotted path should return a string (i.e. `platform.slug` instead of `platform`) - - If value returned by the defined path is a dictionary, an attempt will first be made to access the `name` field, and then the `slug` field. (i.e. `platform` would attempt to lookup `platform.name`, and if that data was not returned, it would then try `platform.slug`) - type: list - default: [] - filters: - required: false - description: - - Granular device search query - type: dict - default: {} + - Determine how redirects are followed. + - By default, I(follow_redirects) is set to uses urllib2 default behavior. + default: urllib2 + choices: ["urllib2", "all", "yes", "safe", "none"] + validate_certs: + description: + - Allows connection when SSL certificates are not valid. Set to C(false) when certificates are not trusted. + default: True + type: boolean + token: + required: True + description: + - Nautobot API token to be able to read against Nautobot. + - This may not be required depending on the Nautobot setup. + env: + # in order of precedence + - name: NAUTOBOT_TOKEN + query: + required: False + description: + - GraphQL query parameters or filters to send to Nautobot to obtain desired data + type: dict + default: {} + suboptions: + devices: + description: + - Additional query parameters or filters for devices + type: dict + required: false + virtual_machines: + description: + - Additional query parameters or filters for VMs + type: dict + required: false + group_by: + required: False + description: + - List of dot-sparated paths to index graphql query results (e.g. `platform.slug`) + - The final value returned by each path is used to derive group names and then group the devices into these groups. + - Valid group names must be string, so indexing the dotted path should return a string (i.e. `platform.slug` instead of `platform`) + - If value returned by the defined path is a dictionary, an attempt will first be made to access the `name` field, and then the `slug` field. (i.e. `platform` would attempt to lookup `platform.name`, and if that data was not returned, it would then try `platform.slug`) + type: list + default: [] """ EXAMPLES = """ # inventory.yml file in YAML format # Example command line: ansible-inventory -v --list -i inventory.yml +# Add -vvv to the command to also see the GraphQL query that gets sent in the debug output. +# Add -vvvv to the command to also see the JSON response that comes back in the debug output. -# Add additional query parameter with query key and use filters +# Minimum required parameters +plugin: networktocode.nautobot.gql_inventory +api_endpoint: http://localhost:8000 # Can be omitted if the NAUTOBOT_URL environment variable is set +token: 1234567890123456478901234567 # Can be omitted if the NAUTOBOT_TOKEN environment variable is set + +# This will send the default GraphQL query of: +# query { +# devices { +# name +# primary_ip4 { +# host +# } +# platform { +# napalm_driver +# } +# } +# virtual_machines { +# name +# primary_ip4 { +# host +# } +# platform { +# name +# } +# } +# } + +# This module will automatically add the ansible_host key and set it equal to primary_ip4.host +# as well as the ansible_network_os key and set it to platform.napalm_driver +# if the primary_ip4.host and platform.napalm_driver are present on the device in Nautobot. + +# Add additional query parameters with the query key. 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: + devices: + tags: name + serial: + tenant: name + site: + name: + contact_name: + description: + region: name + virtual_machines: + tags: name + tenant: name # To group by use group_by key # Specify the full path to the data you would like to use to group by. +# Ensure all paths are also included in the query. plugin: networktocode.nautobot.gql_inventory api_endpoint: http://localhost:8000 -validate_certs: True +query: + devices: + tags: name + serial: + tenant: name + status: slug + site: + name: + contact_name: + description: + region: name + virtual_machines: + tags: name + tenant: name + status: slug group_by: - tenant.name - status.slug -# Add additional variables +# Filter output using any supported parameters. +# To get supported parameters check the api/docs page for devices. +# Add `filters` to any level of the dictionary and a filter will be added to the GraphQL query at that level. +# (use -vvv to see the underlying GraphQL query being sent) plugin: networktocode.nautobot.gql_inventory api_endpoint: http://localhost:8000 -validate_certs: True -additional_variables: - - device_role - -# Add additional variables combined with additional query -plugin: networktocode.nautobot.gql_inventory -api_endpoint: http://localhost:8000 -validate_certs: True query: - interfaces: name -additional_variables: - - interfaces + devices: + filters: + name__ic: ams + interfaces: + filters: + name__ic: ethernet + name: + ip_addresses: address -# Filter output using any supported parameters -# To get supported parameters check the api/docs page for devices +# You can filter to just devices/virtual_machines by filtering the opposite type to a name that doesn't exist. +# For example, to only get devices: plugin: networktocode.nautobot.gql_inventory api_endpoint: http://localhost:8000 -validate_certs: True -filters: - name__ic: nym01-leaf-01 - site: nym01 +query: + virtual_machines: + filters: + name: EXCLUDE ALL """ import json @@ -140,7 +187,6 @@ from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable from ansible.module_utils.ansible_release import __version__ as ansible_version from ansible.errors import AnsibleError -from ansible.module_utils._text import to_native from ansible.module_utils.urls import open_url from ansible.module_utils.six.moves.urllib import error as urllib_error @@ -188,13 +234,17 @@ def add_variable(self, host: str, var: str, var_type: str): def add_ipv4_address(self, device): """Add primary IPv4 address to host.""" if device["primary_ip4"]: + if not device["primary_ip4"].get("host"): + self.display.error("Mapping ansible_host requires primary_ip4.host as part of the query.") + self.add_variable(device["name"], device["name"], "ansible_host") + return self.add_variable(device["name"], device["primary_ip4"]["host"], "ansible_host") else: self.add_variable(device["name"], device["name"], "ansible_host") def add_ansible_platform(self, device): """Add network platform to host""" - if device["platform"] and "napalm_driver" in device["platform"]: + if device.get("platform") and "napalm_driver" in device["platform"]: self.add_variable( device["name"], ANSIBLE_LIB_MAPPER_REVERSE.get(NAPALM_LIB_MAPPER.get(device["platform"]["napalm_driver"])), # Convert napalm_driver to ansible_network_os value @@ -203,8 +253,8 @@ def add_ansible_platform(self, device): def populate_variables(self, device): """Add specified variables to device.""" - for var in self.variables: - if var in device and device[var]: + for var in device: + if device[var]: self.add_variable(device["name"], device[var], var) def create_groups(self, device): @@ -241,8 +291,9 @@ def create_groups(self, device): self.display.display(f"No slug or name value for {group_name} in {group_by_path} on device {device_name}.") if isinstance(group_name, str): - self.inventory.add_group(group_name) - self.inventory.add_child(group_name, device_name) + # If using force_valid_group_names=always in ansible.cfg, hyphens in Nautobot slugs will be converted to underscores + group = self.inventory.add_group(group_name) + self.inventory.add_child(group, device_name) else: self.display.display( f"Groups must be a string. {group_name} is not a string. Please make sure your group_by path specified resolves to a string value." @@ -257,19 +308,23 @@ def main(self): "query": { "devices": { "name": None, + "primary_ip4": "host", "platform": "napalm_driver", - "status": "name", + }, + "virtual_machines": { + "name": None, "primary_ip4": "host", - "device_role": "name", - "site": "name", - } + "platform": "name", + }, } } - base_query["query"]["devices"].update(self.gql_query) - if self.filters: - base_query["query"]["devices"]["filters"] = self.filters + if self.gql_query.get("devices"): + base_query["query"]["devices"].update(self.gql_query["devices"]) + if self.gql_query.get("virtual_machines"): + base_query["query"]["virtual_machines"].update(self.gql_query["virtual_machines"]) query = convert_to_graphql_string(base_query) data = {"query": query} + self.display.vvv(f"GraphQL query:\n{query}") try: response = open_url( @@ -305,6 +360,7 @@ def main(self): # Need to return mock response data that is empty to prevent any failures downstream return {"results": [], "next": None} json_data = json.loads(response.read()) + self.display.vvvv(f"JSON response: {json_data}") # Error handling in case of a malformed query if "errors" in json_data: @@ -315,7 +371,7 @@ def main(self): # Need to return mock response data that is empty to prevent any failures downstream return {"results": [], "next": None} - for device in json_data["data"]["devices"]: + for device in json_data["data"].get("devices", []) + json_data["data"].get("virtual_machines", []): self.inventory.add_host(device["name"]) self.add_ipv4_address(device) self.add_ansible_platform(device) @@ -342,8 +398,6 @@ def parse(self, inventory, loader, path, cache=True): self.gql_query = self.get_option("query") self.group_by = self.get_option("group_by") - self.filters = self.get_option("filters") - self.variables = self.get_option("additional_variables") self.follow_redirects = self.get_option("follow_redirects") self.main()