Skip to content
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
49 changes: 49 additions & 0 deletions plugins/doc_fragments/azure_kql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-

# Copyright: (c) 2016 Matt Davis, <[email protected]>
# Copyright: (c) 2016 Chris Houseknecht, <[email protected]>
# 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


class ModuleDocFragment(object):

# Azure doc fragment
DOCUMENTATION = r'''
options:
plugin:
description: marks this as an instance of the 'azure_rm' plugin
required: true
choices: ['azure_kql', 'azure.azcollection.azure_kql']
graph_query:
description: A graph query which will retrieve the inventory of hosts you are interested in.
You must return inventory_hostname as a field from your query.
fail_on_template_errors:
description: When false, template failures during group and filter processing are silently ignored (eg,
if a filter or group expression refers to an undefined host variable)
choices: [True, False]
default: True
keyed_groups:
description: Creates groups based on the value of a host variable. Requires a list of dictionaries,
defining C(key) (the source dictionary-typed variable), C(prefix) (the prefix to use for the new group
name), and optionally C(separator) (which defaults to C(_))
groups:
description: A mapping of group names to Jinja2 expressions. When the mapped expression is true, the host
is added to the named group.
compose:
description: A mapping of hostvar names to Jinja2 expressions. The value for each host is the result of the
Jinja2 expression (which may refer to any of the host's existing variables at the time this inventory
plugin runs).
hostnames:
description:
- A list of Jinja2 expressions in order of precedence to compose inventory_hostname.
- Ignores expression if result is an empty string or None value.
- An expression of C(default) will force using the default hostname generator if no previous hostname expression
resulted in a valid hostname.
- Use C(default_inventory_hostname) to access the default hostname generator's value in any of the Jinja2 expressions.
type: list
elements: str
default: [default]
'''
305 changes: 305 additions & 0 deletions plugins/inventory/azure_kql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
# Copyright (c) 2018 Ansible Project
# 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

DOCUMENTATION = r'''
name: azure_kql
version_added: "3.7.0"
short_description: Azure Resource Manager inventory plugin using Graph QL
extends_documentation_fragment:
- azure.azcollection.azure
- azure.azcollection.azure_kql
- constructed
- inventory_cache
description:
- Query VM details from Azure Resource Manager using Graph QL
- See https://learn.microsoft.com/en-us/azure/virtual-machines/resource-graph-samples?tabs=azure-cli
for how to craft your own query. The one requirement is that you need to provide inventory_hostname.
- Requires a YAML configuration file whose name ends with 'azure_kql.(yml|yaml)'
- Be aware that currently Azure Resource Graph may not be consistent with the actual state of your
resources. It can take up to 30 minutes for updates to propagate. This applies both for resources
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you misread my comment :) 30 hours not minutes.

to appear and to dissapear.
'''

EXAMPLES = '''
plugin: azure.azcollection.azure_kql

graph_query: |-
Resources
| where type =~ 'microsoft.compute/virtualmachines'
| project vmId = tolower(tostring(id)),
inventory_hostname = name,
tags,
location,
resourceGroup,
osType = tostring(properties.storageProfile.osDisk.osType),
powerState = tostring(properties.extended.instanceView.powerState.displayStatus),
hostName = properties.osProfile.computerName,
subscription_id = subscriptionId
| join kind=inner (ResourceContainers
| where type=='microsoft.resources/subscriptions'
| extend subscription_name = name,
subscription_id = subscriptionId,
state = properties.state
| where state == 'Enabled'
| project subscription_name,
subscription_id)
on subscription_id
| project-away subscription_id1
| join (Resources
| where type =~ 'microsoft.network/networkinterfaces'
| mv-expand ipconfig=properties.ipConfigurations
| project vmId = tolower(tostring(properties.virtualMachine.id)),
privateIp = ipconfig.properties.privateIPAddress,
publicIpId = tostring(ipconfig.properties.publicIPAddress.id)
| join kind=leftouter (Resources
| where type =~ 'microsoft.network/publicipaddresses'
| project publicIpId = id, publicIp = properties.ipAddress
) on publicIpId
| project-away publicIpId, publicIpId1
| summarize privateIps = make_list(privateIp), publicIps = make_list(publicIp) by vmId
) on vmId
| project-away vmId1
| sort by inventory_hostname asc

# adds variables to each host found by this inventory plugin, whose values are the result of the associated expression
compose:
ansible_host: "(publicIps + privateIps) | first"
ansible_winrm_kerberos_hostname_override: "inventory_name + '.domain.tld'"
ansible_winrm_transport: "'ntlm' if ('AAP_managed' in tags and ('DMZ' in (tags.AAP_Managed|list) or 'Local' in (tags.AAP_Managed|list))) else 'kerberos'"

groups:
AAP_Managed: "'AAP_Managed' in (tags|list)"
ubuntu18: "'AAP_Managed' in (tags|list) and 'ubuntu18' in tags.Ansible_OS"
ubuntu20: "'AAP_Managed' in (tags|list) and 'ubuntu20' in tags.Ansible_OS"
ubuntu22: "'AAP_Managed' in (tags|list) and 'ubuntu22' in tags.Ansible_OS"
rhel7: "'AAP_Managed' in (tags|list) and 'rhel7' in tags.Ansible_OS"
rhel8: "'AAP_Managed' in (tags|list) and 'rhel8' in tags.Ansible_OS"
rhel9: "'AAP_Managed' in (tags|list) and 'rhel9' in tags.Ansible_OS"
windows2012: "'AAP_Managed' in (tags|list) and 'windows2012' in tags.Ansible_OS"
windows2016: "'AAP_Managed' in (tags|list) and 'windows2016' in tags.Ansible_OS"
windows2019: "'AAP_Managed' in (tags|list) and 'windows2019' in tags.Ansible_OS"
windows2022: "'AAP_Managed' in (tags|list) and 'windows2022' in tags.Ansible_OS"
Asia: "'AAP_managed' in (tags|list) and 'Asia' in tags.AAP_Managed"
North_America: "'AAP_managed' in (tags|list) and 'Asia' not in tags.AAP_Managed"

# change how inventory_hostname is generated. Each item is a jinja2 expression similar to hostvar_expressions.
hostnames:
- "tags.vm_name if 'vm_name' in tags"
- default_inventory_hostname + ".domain.tld" # Transfer to fqdn if you use shortnames for VMs
- default # special var that uses the default hashed name

keyed_groups:
- prefix: ""
separator: ""
key: osType
- prefix: ""
separator: ""
key: location
- prefix: ""
separator: ""
key: powerState
'''

import re
from ansible.module_utils.six import iteritems
from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable
from ansible.errors import AnsibleError
from ansible.module_utils.parsing.convert_bool import boolean
from ansible.module_utils._text import to_native, to_text
from ansible_collections.azure.azcollection.plugins.module_utils.azure_rm_common import AzureRMAuth
from os import environ

try:
import pandas as pd
import azure.mgmt.resourcegraph as arg
except ImportError:
pd = object
arg = object
pass


class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable):

NAME = 'azure.azcollection.azure_kql'

def __init__(self):
super(InventoryModule, self).__init__()

self.azure_auth = None

def verify_file(self, path):
""" Verify inventory file """
if super(InventoryModule, self).verify_file(path):
if re.match(r'.{0,}azure_kql\.y(a)?ml$', path):
return True
raise AnsibleError("azure_kql inventory filename must end with 'azure_kql.yml' or 'azure_kql.yaml'")

def parse(self, inventory, loader, path, cache=True):
""" parses the inventory file """

super(InventoryModule, self).parse(inventory, loader, path)

self._read_config_data(path)

# Load results from Cache if requested
cache_key = self.get_cache_key(path)

# cache may be True or False at this point to indicate if the inventory is being refreshed
# get the user's cache option too to see if we should save the cache if it is changing
user_cache_setting = self.get_option('cache')

# read if the user has caching enabled and the cache isn't being refreshed
attempt_to_read_cache = user_cache_setting and cache
# update if the user has caching enabled and the cache is being refreshed;
# update this value to True if the cache has expired below
cache_needs_update = user_cache_setting and not cache

# attempt to read the cache if inventory isn't being refreshed and the user has caching enabled
if attempt_to_read_cache:
try:
results = self._cache[cache_key]
except KeyError:
# This occurs if the cache_key is not in the cache or if the cache_key
# expired, so the cache needs to be updated
cache_needs_update = True
if not attempt_to_read_cache or cache_needs_update:
# parse the provided inventory source
try:
self._credential_setup()
results = self._get_hosts()
except Exception:
raise
if cache_needs_update:
self._cache[cache_key] = results

self._populate(results)

def _populate(self, results):
""" Populate inventory """
constructable_config_strict = boolean(self.get_option('fail_on_template_errors'))
constructable_config_compose = self.get_option('compose')
constructable_config_groups = self.get_option('groups')
constructable_config_keyed_groups = self.get_option('keyed_groups')
constructable_hostnames = self.get_option('hostnames')

for h in results:
hostvars = h.get("hostvars")
inventory_hostname = self._get_hostname(h,
hostnames=constructable_hostnames,
strict=constructable_config_strict)
self.inventory.add_host(inventory_hostname)

for k, v in iteritems(hostvars):
self.inventory.set_variable(inventory_hostname, k, v)

# constructable delegation
self._set_composite_vars(constructable_config_compose,
hostvars,
inventory_hostname,
strict=constructable_config_strict)
self._add_host_to_composed_groups(constructable_config_groups,
hostvars,
inventory_hostname,
strict=constructable_config_strict)
self._add_host_to_keyed_groups(constructable_config_keyed_groups,
hostvars,
inventory_hostname,
strict=constructable_config_strict)

def _get_hostname(self, host, hostnames=None, strict=False):
hostname = None
errors = []

for preference in hostnames:
if preference == 'default':
return host.get("default_inventory_hostname")
try:
hostname = self._compose(preference, host.get("hostvars"))
except Exception as e: # pylint: disable=broad-except
if strict:
raise AnsibleError("Could not compose %s as hostnames - %s" % (preference, to_native(e)))
else:
errors.append(
(preference, str(e))
)
if hostname:
return to_text(hostname)

raise AnsibleError(
'Could not template any hostname for host, errors for each preference: %s' % (
', '.join(['%s: %s' % (pref, err) for pref, err in errors])
)
)

def _credential_setup(self):
auth_source = environ.get('ANSIBLE_AZURE_AUTH_SOURCE', None) or self.get_option('auth_source')
auth_options = dict(
auth_source=auth_source,
profile=self.get_option('profile'),
subscription_id=self.get_option('subscription_id'),
client_id=self.get_option('client_id'),
secret=self.get_option('secret'),
tenant=self.get_option('tenant'),
ad_user=self.get_option('ad_user'),
password=self.get_option('password'),
cloud_environment=self.get_option('cloud_environment'),
cert_validation_mode=self.get_option('cert_validation_mode'),
api_profile=self.get_option('api_profile'),
track1_cred=True,
adfs_authority_url=self.get_option('adfs_authority_url')
)

if self.templar.is_template(auth_options["tenant"]):
auth_options["tenant"] = self.templar.template(variable=auth_options["tenant"], disable_lookups=False)

if self.templar.is_template(auth_options["client_id"]):
auth_options["client_id"] = self.templar.template(variable=auth_options["client_id"], disable_lookups=False)

if self.templar.is_template(auth_options["secret"]):
auth_options["secret"] = self.templar.template(variable=auth_options["secret"], disable_lookups=False)

if self.templar.is_template(auth_options["subscription_id"]):
auth_options["subscription_id"] = self.templar.template(variable=auth_options["subscription_id"], disable_lookups=False)

self.azure_auth = AzureRMAuth(**auth_options)

def execute_kql(self, query, resource_name='VMs'):
""" Execute KQL query """

argClient = arg.ResourceGraphClient(self.azure_auth.azure_credential_track2)
skpToken = 'hasData'
output = []

while skpToken is not None:
if skpToken == 'hasData':
argQueryOptions = arg.models.QueryRequestOptions(result_format="objectArray")
else:
argQueryOptions = arg.models.QueryRequestOptions(result_format="objectArray", skip_token=skpToken)
argQuery = arg.models.QueryRequest(query=query, options=argQueryOptions)
argResults = argClient.resources(argQuery)
output.extend(argResults.data)
skpToken = argResults.skip_token

df_output = pd.DataFrame(output)
return df_output

def _get_hosts(self):
""" Get all hosts via graph_query """

df_vms = self.execute_kql(query=self.get_option('graph_query'))
results = []

for index, row in df_vms.iterrows():
# Convert panda object to dict
row = row.to_dict()
# If no tags are present use an empty dict
tags = row.pop('tags') or {}
# Update row with updated tags
row.update({'tags': tags})
results.append(dict(default_inventory_hostname=row.get('inventory_hostname'),
hostvars=row))

return results
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,7 @@ azure-mgmt-recoveryservicesbackup==9.1.0
azure-mgmt-notificationhubs==8.1.0b1
azure-mgmt-eventhub==11.1.0
azure-mgmt-resourcehealth==1.0.0b6
azure-mgmt-resourcegraph
pandas
oras
netaddr
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
tasks:
- name: Write inventory config file
ansible.builtin.copy:
dest: ../test.azure_rm.yml
dest: "../{{ lookup('ansible.builtin.env', 'ANSIBLE_INVENTORY') }}"
content: "{{ lookup('template', template_name) }}"
mode: preserve
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
tasks:
- name: Write inventory config file
ansible.builtin.copy:
dest: ../test.azure_rm.yml
dest: "../{{ lookup('ansible.builtin.env', 'ANSIBLE_INVENTORY') }}"
content: ""
mode: preserve
2 changes: 2 additions & 0 deletions tests/integration/targets/inventory_azure/playbooks/setup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
sku: 20_04-lts
version: latest
tags:
AAP_Managed: Local
Ansible_OS: ubuntu20
Deployment-Method: Ansible
Automation-Method: Ansible
register: vm_output_2
Loading