Skip to content

Commit

Permalink
Inventory - GraphQL: Fetches Virtual Machines (#130)
Browse files Browse the repository at this point in the history
  • Loading branch information
joewesch authored Apr 11, 2022
1 parent 9e8e603 commit 0e72178
Show file tree
Hide file tree
Showing 2 changed files with 172 additions and 117 deletions.
3 changes: 2 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ Dockerfile
docker-compose.yml
*.md
.env
.venv
.vscode/
.github/
.github/
286 changes: 170 additions & 116 deletions plugins/inventory/gql_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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."
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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()

0 comments on commit 0e72178

Please sign in to comment.