Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds virtual machines to GraphQL Inventory module #130

Merged
merged 2 commits into from
Apr 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()