initial commit of azure_rm inventory plugin (#44944)

* crusty refactor of azure_rm to support auth from non-modules
This commit is contained in:
Matt Davis 2018-08-31 01:33:23 -07:00 committed by GitHub
parent c2fa0d2c4b
commit 2822fd8d9b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 839 additions and 245 deletions

View file

@ -277,7 +277,6 @@ class AzureRMModuleBase(object):
self.fail("Do you have azure>={1} installed? Try `pip install ansible[azure]`" self.fail("Do you have azure>={1} installed? Try `pip install ansible[azure]`"
"- {0}".format(HAS_AZURE_EXC, AZURE_MIN_RELEASE)) "- {0}".format(HAS_AZURE_EXC, AZURE_MIN_RELEASE))
self._cloud_environment = None
self._network_client = None self._network_client = None
self._storage_client = None self._storage_client = None
self._resource_client = None self._resource_client = None
@ -285,116 +284,23 @@ class AzureRMModuleBase(object):
self._dns_client = None self._dns_client = None
self._web_client = None self._web_client = None
self._marketplace_client = None self._marketplace_client = None
self._containerservice_client = None
self._sql_client = None self._sql_client = None
self._mysql_client = None self._mysql_client = None
self._postgresql_client = None self._postgresql_client = None
self._containerregistry_client = None self._containerregistry_client = None
self._containerinstance_client = None self._containerinstance_client = None
self._containerservice_client = None
self._traffic_manager_management_client = None self._traffic_manager_management_client = None
self._monitor_client = None self._monitor_client = None
self._adfs_authority_url = None
self._resource = None self._resource = None
self.check_mode = self.module.check_mode self.check_mode = self.module.check_mode
self.api_profile = self.module.params.get('api_profile') self.api_profile = self.module.params.get('api_profile')
self.facts_module = facts_module self.facts_module = facts_module
# self.debug = self.module.params.get('debug')
# authenticate # delegate auth to AzureRMAuth class (shared with all plugin types)
self.credentials = self._get_credentials(self.module.params) self.azure_auth = AzureRMAuth(fail_impl=self.fail, **self.module.params)
if not self.credentials:
if HAS_AZURE_CLI_CORE:
self.fail("Failed to get credentials. Either pass as parameters, set environment variables, "
"define a profile in ~/.azure/credentials, or log in with Azure CLI (`az login`).")
else:
self.fail("Failed to get credentials. Either pass as parameters, set environment variables, "
"define a profile in ~/.azure/credentials, or install Azure CLI and log in (`az login`).")
# cert validation mode precedence: module-arg, credential profile, env, "validate"
self._cert_validation_mode = self.module.params['cert_validation_mode'] or self.credentials.get('cert_validation_mode') or \
os.environ.get('AZURE_CERT_VALIDATION_MODE') or 'validate'
if self._cert_validation_mode not in ['validate', 'ignore']:
self.fail('invalid cert_validation_mode: {0}'.format(self._cert_validation_mode))
# if cloud_environment specified, look up/build Cloud object
raw_cloud_env = self.credentials.get('cloud_environment')
if self.credentials.get('credentials') is not None and raw_cloud_env is not None:
self._cloud_environment = raw_cloud_env
elif not raw_cloud_env:
self._cloud_environment = azure_cloud.AZURE_PUBLIC_CLOUD # SDK default
else:
# try to look up "well-known" values via the name attribute on azure_cloud members
all_clouds = [x[1] for x in inspect.getmembers(azure_cloud) if isinstance(x[1], azure_cloud.Cloud)]
matched_clouds = [x for x in all_clouds if x.name == raw_cloud_env]
if len(matched_clouds) == 1:
self._cloud_environment = matched_clouds[0]
elif len(matched_clouds) > 1:
self.fail("Azure SDK failure: more than one cloud matched for cloud_environment name '{0}'".format(raw_cloud_env))
else:
if not urlparse.urlparse(raw_cloud_env).scheme:
self.fail("cloud_environment must be an endpoint discovery URL or one of {0}".format([x.name for x in all_clouds]))
try:
self._cloud_environment = azure_cloud.get_cloud_from_metadata_endpoint(raw_cloud_env)
except Exception as e:
self.fail("cloud_environment {0} could not be resolved: {1}".format(raw_cloud_env, e.message), exception=traceback.format_exc(e))
if self.credentials.get('subscription_id', None) is None and self.credentials.get('credentials') is None:
self.fail("Credentials did not include a subscription_id value.")
self.log("setting subscription_id")
self.subscription_id = self.credentials['subscription_id']
# get authentication authority
# for adfs, user could pass in authority or not.
# for others, use default authority from cloud environment
if self.credentials.get('adfs_authority_url') is None:
self._adfs_authority_url = self._cloud_environment.endpoints.active_directory
else:
self._adfs_authority_url = self.credentials.get('adfs_authority_url')
# get resource from cloud environment
self._resource = self._cloud_environment.endpoints.active_directory_resource_id
if self.credentials.get('credentials') is not None:
# AzureCLI credentials
self.azure_credentials = self.credentials['credentials']
elif self.credentials.get('client_id') is not None and \
self.credentials.get('secret') is not None and \
self.credentials.get('tenant') is not None:
self.azure_credentials = ServicePrincipalCredentials(client_id=self.credentials['client_id'],
secret=self.credentials['secret'],
tenant=self.credentials['tenant'],
cloud_environment=self._cloud_environment,
verify=self._cert_validation_mode == 'validate')
elif self.credentials.get('ad_user') is not None and \
self.credentials.get('password') is not None and \
self.credentials.get('client_id') is not None and \
self.credentials.get('tenant') is not None:
self.azure_credentials = self.acquire_token_with_username_password(
self._adfs_authority_url,
self._resource,
self.credentials['ad_user'],
self.credentials['password'],
self.credentials['client_id'],
self.credentials['tenant'])
elif self.credentials.get('ad_user') is not None and self.credentials.get('password') is not None:
tenant = self.credentials.get('tenant')
if not tenant:
tenant = 'common' # SDK default
self.azure_credentials = UserPassCredentials(self.credentials['ad_user'],
self.credentials['password'],
tenant=tenant,
cloud_environment=self._cloud_environment,
verify=self._cert_validation_mode == 'validate')
else:
self.fail("Failed to authenticate with provided credentials. Some attributes were missing. "
"Credentials must include client_id, secret and tenant or ad_user and password, or "
"ad_user, password, client_id, tenant and adfs_authority_url(optional) for ADFS authentication, or "
"be logged in using AzureCLI.")
# common parameter validation # common parameter validation
if self.module.params.get('tags'): if self.module.params.get('tags'):
@ -404,17 +310,6 @@ class AzureRMModuleBase(object):
res = self.exec_module(**self.module.params) res = self.exec_module(**self.module.params)
self.module.exit_json(**res) self.module.exit_json(**res)
def acquire_token_with_username_password(self, authority, resource, username, password, client_id, tenant):
authority_uri = authority
if tenant is not None:
authority_uri = authority + '/' + tenant
context = AuthenticationContext(authority_uri)
token_response = context.acquire_token_with_username_password(resource, username, password, client_id)
return AADTokenCredentials(token_response)
def check_client_version(self, client_type): def check_client_version(self, client_type):
# Ensure Azure modules are at least 2.0.0rc5. # Ensure Azure modules are at least 2.0.0rc5.
package_version = AZURE_PKG_VERSIONS.get(client_type.__name__, None) package_version = AZURE_PKG_VERSIONS.get(client_type.__name__, None)
@ -541,138 +436,6 @@ class AzureRMModuleBase(object):
except Exception as exc: except Exception as exc:
self.fail("Error retrieving resource group {0} - {1}".format(resource_group, str(exc))) self.fail("Error retrieving resource group {0} - {1}".format(resource_group, str(exc)))
def _get_profile(self, profile="default"):
path = expanduser("~/.azure/credentials")
try:
config = configparser.ConfigParser()
config.read(path)
except Exception as exc:
self.fail("Failed to access {0}. Check that the file exists and you have read "
"access. {1}".format(path, str(exc)))
credentials = dict()
for key in AZURE_CREDENTIAL_ENV_MAPPING:
try:
credentials[key] = config.get(profile, key, raw=True)
except:
pass
if credentials.get('subscription_id'):
return credentials
return None
def _get_msi_credentials(self, subscription_id_param=None):
credentials = MSIAuthentication()
subscription_id = subscription_id_param or os.environ.get(AZURE_CREDENTIAL_ENV_MAPPING['subscription_id'], None)
if not subscription_id:
try:
# use the first subscription of the MSI
subscription_client = SubscriptionClient(credentials)
subscription = next(subscription_client.subscriptions.list())
subscription_id = str(subscription.subscription_id)
except Exception as exc:
self.fail("Failed to get MSI token: {0}. "
"Please check whether your machine enabled MSI or grant access to any subscription.".format(str(exc)))
return {
'credentials': credentials,
'subscription_id': subscription_id
}
def _get_azure_cli_credentials(self):
credentials, subscription_id = get_azure_cli_credentials()
cloud_environment = get_cli_active_cloud()
cli_credentials = {
'credentials': credentials,
'subscription_id': subscription_id,
'cloud_environment': cloud_environment
}
return cli_credentials
def _get_env_credentials(self):
env_credentials = dict()
for attribute, env_variable in AZURE_CREDENTIAL_ENV_MAPPING.items():
env_credentials[attribute] = os.environ.get(env_variable, None)
if env_credentials['profile']:
credentials = self._get_profile(env_credentials['profile'])
return credentials
if env_credentials.get('subscription_id') is not None:
return env_credentials
return None
def _get_credentials(self, params):
# Get authentication credentials.
self.log('Getting credentials')
arg_credentials = dict()
for attribute, env_variable in AZURE_CREDENTIAL_ENV_MAPPING.items():
arg_credentials[attribute] = params.get(attribute, None)
auth_source = params.get('auth_source', None)
if not auth_source:
auth_source = os.environ.get('ANSIBLE_AZURE_AUTH_SOURCE', 'auto')
if auth_source == 'msi':
self.log('Retrieving credenitals from MSI')
return self._get_msi_credentials(arg_credentials['subscription_id'])
if auth_source == 'cli':
if not HAS_AZURE_CLI_CORE:
self.fail("Azure auth_source is `cli`, but azure-cli package is not available. Try `pip install azure-cli --upgrade`")
try:
self.log('Retrieving credentials from Azure CLI profile')
cli_credentials = self._get_azure_cli_credentials()
return cli_credentials
except CLIError as err:
self.fail("Azure CLI profile cannot be loaded - {0}".format(err))
if auth_source == 'env':
self.log('Retrieving credentials from environment')
env_credentials = self._get_env_credentials()
return env_credentials
if auth_source == 'credential_file':
self.log("Retrieving credentials from credential file")
profile = params.get('profile', 'default')
default_credentials = self._get_profile(profile)
return default_credentials
# auto, precedence: module parameters -> environment variables -> default profile in ~/.azure/credentials
# try module params
if arg_credentials['profile'] is not None:
self.log('Retrieving credentials with profile parameter.')
credentials = self._get_profile(arg_credentials['profile'])
return credentials
if arg_credentials['subscription_id']:
self.log('Received credentials from parameters.')
return arg_credentials
# try environment
env_credentials = self._get_env_credentials()
if env_credentials:
self.log('Received credentials from env.')
return env_credentials
# try default profile from ~./azure/credentials
default_credentials = self._get_profile()
if default_credentials:
self.log('Retrieved default profile credentials from ~/.azure/credentials.')
return default_credentials
try:
if HAS_AZURE_CLI_CORE:
self.log('Retrieving credentials from AzureCLI profile')
cli_credentials = self._get_azure_cli_credentials()
return cli_credentials
except CLIError as ce:
self.log('Error getting AzureCLI profile credentials - {0}'.format(ce))
return None
def parse_resource_to_dict(self, resource): def parse_resource_to_dict(self, resource):
''' '''
Return a dict of the give resource, which contains name and resource group. Return a dict of the give resource, which contains name and resource group.
@ -947,9 +710,9 @@ class AzureRMModuleBase(object):
if not base_url: if not base_url:
# most things are resource_manager, don't make everyone specify # most things are resource_manager, don't make everyone specify
base_url = self._cloud_environment.endpoints.resource_manager base_url = self.azure_auth._cloud_environment.endpoints.resource_manager
client_kwargs = dict(credentials=self.azure_credentials, subscription_id=self.subscription_id, base_url=base_url) client_kwargs = dict(credentials=self.azure_auth.azure_credentials, subscription_id=self.azure_auth.subscription_id, base_url=base_url)
api_profile_dict = {} api_profile_dict = {}
@ -992,11 +755,24 @@ class AzureRMModuleBase(object):
if VSCODEEXT_USER_AGENT_KEY in os.environ: if VSCODEEXT_USER_AGENT_KEY in os.environ:
client.config.add_user_agent(os.environ[VSCODEEXT_USER_AGENT_KEY]) client.config.add_user_agent(os.environ[VSCODEEXT_USER_AGENT_KEY])
if self._cert_validation_mode == 'ignore': if self.azure_auth._cert_validation_mode == 'ignore':
client.config.session_configuration_callback = self._validation_ignore_callback client.config.session_configuration_callback = self._validation_ignore_callback
return client return client
# passthru methods to AzureAuth instance for backcompat
@property
def credentials(self):
return self.azure_auth.credentials
@property
def _cloud_environment(self):
return self.azure_auth._cloud_environment
@property
def subscription_id(self):
return self.azure_auth.subscription_id
@property @property
def storage_client(self): def storage_client(self):
self.log('Getting storage client...') self.log('Getting storage client...')
@ -1152,3 +928,281 @@ class AzureRMModuleBase(object):
self._monitor_client = self.get_mgmt_svc_client(MonitorManagementClient, self._monitor_client = self.get_mgmt_svc_client(MonitorManagementClient,
base_url=self._cloud_environment.endpoints.resource_manager) base_url=self._cloud_environment.endpoints.resource_manager)
return self._monitor_client return self._monitor_client
class AzureRMAuthException(Exception):
pass
class AzureRMAuth(object):
def __init__(self, auth_source='auto', profile=None, subscription_id=None, client_id=None, secret=None,
tenant=None, ad_user=None, password=None, cloud_environment='AzureCloud', cert_validation_mode='validate',
api_profile='latest', adfs_authority_url=None, fail_impl=None, **kwargs):
if fail_impl:
self._fail_impl = fail_impl
else:
self._fail_impl = self._default_fail_impl
self._cloud_environment = None
self._adfs_authority_url = None
# authenticate
self.credentials = self._get_credentials(
dict(auth_source=auth_source, profile=profile, subscription_id=subscription_id, client_id=client_id, secret=secret,
tenant=tenant, ad_user=ad_user, password=password, cloud_environment=cloud_environment,
cert_validation_mode=cert_validation_mode, api_profile=api_profile, adfs_authority_url=adfs_authority_url))
if not self.credentials:
if HAS_AZURE_CLI_CORE:
self.fail("Failed to get credentials. Either pass as parameters, set environment variables, "
"define a profile in ~/.azure/credentials, or log in with Azure CLI (`az login`).")
else:
self.fail("Failed to get credentials. Either pass as parameters, set environment variables, "
"define a profile in ~/.azure/credentials, or install Azure CLI and log in (`az login`).")
# cert validation mode precedence: module-arg, credential profile, env, "validate"
self._cert_validation_mode = cert_validation_mode or self.credentials.get('cert_validation_mode') or \
os.environ.get('AZURE_CERT_VALIDATION_MODE') or 'validate'
if self._cert_validation_mode not in ['validate', 'ignore']:
self.fail('invalid cert_validation_mode: {0}'.format(self._cert_validation_mode))
# if cloud_environment specified, look up/build Cloud object
raw_cloud_env = self.credentials.get('cloud_environment')
if self.credentials.get('credentials') is not None and raw_cloud_env is not None:
self._cloud_environment = raw_cloud_env
elif not raw_cloud_env:
self._cloud_environment = azure_cloud.AZURE_PUBLIC_CLOUD # SDK default
else:
# try to look up "well-known" values via the name attribute on azure_cloud members
all_clouds = [x[1] for x in inspect.getmembers(azure_cloud) if isinstance(x[1], azure_cloud.Cloud)]
matched_clouds = [x for x in all_clouds if x.name == raw_cloud_env]
if len(matched_clouds) == 1:
self._cloud_environment = matched_clouds[0]
elif len(matched_clouds) > 1:
self.fail("Azure SDK failure: more than one cloud matched for cloud_environment name '{0}'".format(raw_cloud_env))
else:
if not urlparse.urlparse(raw_cloud_env).scheme:
self.fail("cloud_environment must be an endpoint discovery URL or one of {0}".format([x.name for x in all_clouds]))
try:
self._cloud_environment = azure_cloud.get_cloud_from_metadata_endpoint(raw_cloud_env)
except Exception as e:
self.fail("cloud_environment {0} could not be resolved: {1}".format(raw_cloud_env, e.message), exception=traceback.format_exc(e))
if self.credentials.get('subscription_id', None) is None and self.credentials.get('credentials') is None:
self.fail("Credentials did not include a subscription_id value.")
self.log("setting subscription_id")
self.subscription_id = self.credentials['subscription_id']
# get authentication authority
# for adfs, user could pass in authority or not.
# for others, use default authority from cloud environment
if self.credentials.get('adfs_authority_url') is None:
self._adfs_authority_url = self._cloud_environment.endpoints.active_directory
else:
self._adfs_authority_url = self.credentials.get('adfs_authority_url')
# get resource from cloud environment
self._resource = self._cloud_environment.endpoints.active_directory_resource_id
if self.credentials.get('credentials') is not None:
# AzureCLI credentials
self.azure_credentials = self.credentials['credentials']
elif self.credentials.get('client_id') is not None and \
self.credentials.get('secret') is not None and \
self.credentials.get('tenant') is not None:
self.azure_credentials = ServicePrincipalCredentials(client_id=self.credentials['client_id'],
secret=self.credentials['secret'],
tenant=self.credentials['tenant'],
cloud_environment=self._cloud_environment,
verify=self._cert_validation_mode == 'validate')
elif self.credentials.get('ad_user') is not None and \
self.credentials.get('password') is not None and \
self.credentials.get('client_id') is not None and \
self.credentials.get('tenant') is not None:
self.azure_credentials = self.acquire_token_with_username_password(
self._adfs_authority_url,
self._resource,
self.credentials['ad_user'],
self.credentials['password'],
self.credentials['client_id'],
self.credentials['tenant'])
elif self.credentials.get('ad_user') is not None and self.credentials.get('password') is not None:
tenant = self.credentials.get('tenant')
if not tenant:
tenant = 'common' # SDK default
self.azure_credentials = UserPassCredentials(self.credentials['ad_user'],
self.credentials['password'],
tenant=tenant,
cloud_environment=self._cloud_environment,
verify=self._cert_validation_mode == 'validate')
else:
self.fail("Failed to authenticate with provided credentials. Some attributes were missing. "
"Credentials must include client_id, secret and tenant or ad_user and password, or "
"ad_user, password, client_id, tenant and adfs_authority_url(optional) for ADFS authentication, or "
"be logged in using AzureCLI.")
def fail(self, msg, exception=None, **kwargs):
self._fail_impl(msg)
def _default_fail_impl(self, msg, exception=None, **kwargs):
raise AzureRMAuthException(msg)
def _get_profile(self, profile="default"):
path = expanduser("~/.azure/credentials")
try:
config = configparser.ConfigParser()
config.read(path)
except Exception as exc:
self.fail("Failed to access {0}. Check that the file exists and you have read "
"access. {1}".format(path, str(exc)))
credentials = dict()
for key in AZURE_CREDENTIAL_ENV_MAPPING:
try:
credentials[key] = config.get(profile, key, raw=True)
except:
pass
if credentials.get('subscription_id'):
return credentials
return None
def _get_msi_credentials(self, subscription_id_param=None):
credentials = MSIAuthentication()
subscription_id = subscription_id_param or os.environ.get(AZURE_CREDENTIAL_ENV_MAPPING['subscription_id'], None)
if not subscription_id:
try:
# use the first subscription of the MSI
subscription_client = SubscriptionClient(credentials)
subscription = next(subscription_client.subscriptions.list())
subscription_id = str(subscription.subscription_id)
except Exception as exc:
self.fail("Failed to get MSI token: {0}. "
"Please check whether your machine enabled MSI or grant access to any subscription.".format(str(exc)))
return {
'credentials': credentials,
'subscription_id': subscription_id
}
def _get_azure_cli_credentials(self):
credentials, subscription_id = get_azure_cli_credentials()
cloud_environment = get_cli_active_cloud()
cli_credentials = {
'credentials': credentials,
'subscription_id': subscription_id,
'cloud_environment': cloud_environment
}
return cli_credentials
def _get_env_credentials(self):
env_credentials = dict()
for attribute, env_variable in AZURE_CREDENTIAL_ENV_MAPPING.items():
env_credentials[attribute] = os.environ.get(env_variable, None)
if env_credentials['profile']:
credentials = self._get_profile(env_credentials['profile'])
return credentials
if env_credentials.get('subscription_id') is not None:
return env_credentials
return None
# TODO: use explicit kwargs instead of intermediate dict
def _get_credentials(self, params):
# Get authentication credentials.
self.log('Getting credentials')
arg_credentials = dict()
for attribute, env_variable in AZURE_CREDENTIAL_ENV_MAPPING.items():
arg_credentials[attribute] = params.get(attribute, None)
auth_source = params.get('auth_source', None)
if not auth_source:
auth_source = os.environ.get('ANSIBLE_AZURE_AUTH_SOURCE', 'auto')
if auth_source == 'msi':
self.log('Retrieving credenitals from MSI')
return self._get_msi_credentials(arg_credentials['subscription_id'])
if auth_source == 'cli':
if not HAS_AZURE_CLI_CORE:
self.fail("Azure auth_source is `cli`, but azure-cli package is not available. Try `pip install azure-cli --upgrade`")
try:
self.log('Retrieving credentials from Azure CLI profile')
cli_credentials = self._get_azure_cli_credentials()
return cli_credentials
except CLIError as err:
self.fail("Azure CLI profile cannot be loaded - {0}".format(err))
if auth_source == 'env':
self.log('Retrieving credentials from environment')
env_credentials = self._get_env_credentials()
return env_credentials
if auth_source == 'credential_file':
self.log("Retrieving credentials from credential file")
profile = params.get('profile', 'default')
default_credentials = self._get_profile(profile)
return default_credentials
# auto, precedence: module parameters -> environment variables -> default profile in ~/.azure/credentials
# try module params
if arg_credentials['profile'] is not None:
self.log('Retrieving credentials with profile parameter.')
credentials = self._get_profile(arg_credentials['profile'])
return credentials
if arg_credentials['subscription_id']:
self.log('Received credentials from parameters.')
return arg_credentials
# try environment
env_credentials = self._get_env_credentials()
if env_credentials:
self.log('Received credentials from env.')
return env_credentials
# try default profile from ~./azure/credentials
default_credentials = self._get_profile()
if default_credentials:
self.log('Retrieved default profile credentials from ~/.azure/credentials.')
return default_credentials
try:
if HAS_AZURE_CLI_CORE:
self.log('Retrieving credentials from AzureCLI profile')
cli_credentials = self._get_azure_cli_credentials()
return cli_credentials
except CLIError as ce:
self.log('Error getting AzureCLI profile credentials - {0}'.format(ce))
return None
def acquire_token_with_username_password(self, authority, resource, username, password, client_id, tenant):
authority_uri = authority
if tenant is not None:
authority_uri = authority + '/' + tenant
context = AuthenticationContext(authority_uri)
token_response = context.acquire_token_with_username_password(resource, username, password, client_id)
return AADTokenCredentials(token_response)
def log(self, msg, pretty_print=False):
pass
# Use only during module development
# if self.debug:
# log_file = open('azure_rm.log', 'a')
# if pretty_print:
# log_file.write(json.dumps(msg, indent=4, sort_keys=True))
# else:
# log_file.write(msg + u'\n')

View file

@ -286,7 +286,7 @@ class Constructable(object):
composite = self._compose(compose[varname], variables) composite = self._compose(compose[varname], variables)
except Exception as e: except Exception as e:
if strict: if strict:
raise AnsibleError("Could not set %s: %s" % (varname, to_native(e))) raise AnsibleError("Could not set %s for host %s: %s" % (varname, host, to_native(e)))
continue continue
self.inventory.set_variable(host, varname, composite) self.inventory.set_variable(host, varname, composite)

View file

@ -0,0 +1,540 @@
# 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_rm
plugin_type: inventory
short_description: Azure Resource Manager inventory plugin
extends_documentation_fragment:
- azure
description:
- Query VM details from Azure Resource Manager
- Requires a YAML configuration file whose name ends with '.azure_rm.yaml'
- By default, sets C(ansible_host) to the first public IP address found (preferring the primary NIC). If no
public IPs are found, the first private IP (also preferring the primary NIC). The default may be overridden
via C(hostvar_expressions); see examples.
options:
plugin:
description: marks this as an instance of the 'azure_rm' plugin
required: true
choices: ['azure_rm']
include_vm_resource_groups:
description: A list of resource group names to search for virtual machines. '\*' will include all resource
groups in the subscription.
default: ['*']
include_vmss_resource_groups:
description: A list of resource group names to search for virtual machine scale sets (VMSSs). '\*' will
include all resource groups in the subscription.
default: []
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(_))
conditional_groups:
description: A mapping of group names to Jinja2 expressions. When the mapped expression is true, the host
is added to the named group.
hostvar_expressions:
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).
exclude_host_filters:
description: Excludes hosts from the inventory with a list of Jinja2 conditional expressions. Each
expression in the list is evaluated for each host; when the expression is true, the host is excluded
from the inventory.
default: []
batch_fetch:
description: To improve performance, results are fetched using an unsupported batch API. Disabling
C(batch_fetch) uses a much slower serial fetch, resulting in many more round-trips. Generally only
useful for troubleshooting.
default: true
default_host_filters:
description: A default set of filters that is applied in addition to the conditions in
C(exclude_host_filters) to exclude powered-off and not-fully-provisioned hosts. Set this to a different
value or empty list if you need to include hosts in these states.
default: ['powerstate != "running"', 'provisioning_state != "succeeded"']
'''
EXAMPLES = '''
# The following host variables are always available:
# public_ipv4_addresses: all public IP addresses, with the primary IP config from the primary NIC first
# public_dns_hostnames: all public DNS hostnames, with the primary IP config from the primary NIC first
# private_ipv4_addresses: all private IP addressses, with the primary IP config from the primary NIC first
# id: the VM's Azure resource ID, eg /subscriptions/00000000-0000-0000-1111-1111aaaabb/resourceGroups/my_rg/providers/Microsoft.Compute/virtualMachines/my_vm
# location: the VM's Azure location, eg 'westus', 'eastus'
# name: the VM's resource name, eg 'myvm'
# powerstate: the VM's current power state, eg: 'running', 'stopped', 'deallocated'
# provisioning_state: the VM's current provisioning state, eg: 'succeeded'
# tags: dictionary of the VM's defined tag values
# resource_type: the VM's resource type, eg: 'Microsoft.Compute/virtualMachine', 'Microsoft.Compute/virtualMachineScaleSets/virtualMachines'
# vmid: the VM's internal SMBIOS ID, eg: '36bca69d-c365-4584-8c06-a62f4a1dc5d2'
# vmss: if the VM is a member of a scaleset (vmss), a dictionary including the id and name of the parent scaleset
# sample 'myazuresub.azure_rm.yaml'
# required for all azure_rm inventory plugin configs
plugin: azure_rm
# forces this plugin to use a CLI auth session instead of the automatic auth source selection (eg, prevents the
# presence of 'ANSIBLE_AZURE_RM_X' environment variables from overriding CLI auth)
auth_source: cli
# fetches VMs from an explicit list of resource groups instead of default all (- '*')
include_vm_resource_groups:
- myrg1
- myrg2
# fetches VMs from VMSSs in all resource groups (defaults to no VMSS fetch)
include_vmss_resource_groups:
- '*'
# places a host in the named group if the associated condition evaluates to true
conditional_groups:
# since this will be true for every host, every host sourced from this inventory plugin config will be in the
# group 'all_the_hosts'
all_the_hosts: true
# if the VM's "name" variable contains "dbserver", it will be placed in the 'db_hosts' group
db_hosts: "'dbserver' in name"
# adds variables to each host found by this inventory plugin, whose values are the result of the associated expression
hostvar_expressions:
my_host_var:
# A statically-valued expression has to be both single and double-quoted, or use escaped quotes, since the outer
# layer of quotes will be consumed by YAML. Without the second set of quotes, it interprets 'staticvalue' as a
# variable instead of a string literal.
some_statically_valued_var: "'staticvalue'"
# overrides the default ansible_host value with a custom Jinja2 expression, in this case, the first DNS hostname, or
# if none are found, the first public IP address.
ansible_host: (public_dns_hostnames + public_ipv4_addresses) | first
# places hosts in dynamically-created groups based on a variable value.
keyed_groups:
# places each host in a group named 'tag_(tag name)_(tag value)' for each tag on a VM.
- prefix: tag
key: tags
# places each host in a group named 'azure_loc_(location name)', depending on the VM's location
- prefix: azure_loc
key: location
# places host in a group named 'some_tag_X' using the value of the 'sometag' tag on a VM as X, and defaulting to the
# value 'none' (eg, the group 'some_tag_none') if the 'sometag' tag is not defined for a VM.
- prefix: some_tag
key: tags.sometag | default('none')
# excludes a host from the inventory when any of these expressions is true, can refer to any vars defined on the host
exclude_host_filters:
# excludes hosts in the eastus region
- location in ['eastus']
# excludes hosts that are powered off
- powerstate != 'running'
'''
# FUTURE: do we need a set of sane default filters, separate from the user-defineable ones?
# eg, powerstate==running, provisioning_state==succeeded
import hashlib
import json
import re
try:
from queue import Queue, Empty
except ImportError:
from Queue import Queue, Empty
from collections import namedtuple
from ansible import release
from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable
from ansible.module_utils.six import iteritems
from ansible.module_utils.azure_rm_common import AzureRMAuth
from ansible.errors import AnsibleParserError, AnsibleError
from ansible.module_utils.parsing.convert_bool import boolean
from ansible.module_utils._text import to_native
from itertools import chain
from msrest import ServiceClient, Serializer, Deserializer
from msrestazure import AzureConfiguration
from msrestazure.polling.arm_polling import ARMPolling
class AzureRMRestConfiguration(AzureConfiguration):
def __init__(self, credentials, subscription_id, base_url=None):
if credentials is None:
raise ValueError("Parameter 'credentials' must not be None.")
if subscription_id is None:
raise ValueError("Parameter 'subscription_id' must not be None.")
if not base_url:
base_url = 'https://management.azure.com'
super(AzureRMRestConfiguration, self).__init__(base_url)
self.add_user_agent('ansible-dynamic-inventory/{0}'.format(release.__version__))
self.credentials = credentials
self.subscription_id = subscription_id
UrlAction = namedtuple('UrlAction', ['url', 'api_version', 'handler', 'handler_args'])
# FUTURE: add Cacheable support once we have a sane serialization format
class InventoryModule(BaseInventoryPlugin, Constructable):
NAME = 'azure_rm'
def __init__(self):
super(InventoryModule, self).__init__()
self._serializer = Serializer()
self._deserializer = Deserializer()
self._hosts = []
self._filters = None
# FUTURE: use API profiles with defaults
self._compute_api_version = '2017-03-30'
self._network_api_version = '2015-06-15'
self._default_header_parameters = {'Content-Type': 'application/json; charset=utf-8'}
self._request_queue = Queue()
self.azure_auth = None
self._batch_fetch = False
def verify_file(self, path):
'''
:param loader: an ansible.parsing.dataloader.DataLoader object
:param path: the path to the inventory config file
:return the contents of the config file
'''
if super(InventoryModule, self).verify_file(path):
if re.match(r'.+\.azure_rm\.y(a)?ml$', path):
return True
# display.debug("azure_rm inventory filename must match '*.azure_rm.yml' or '*.azure_rm.yaml'")
return False
def parse(self, inventory, loader, path, cache=True):
super(InventoryModule, self).parse(inventory, loader, path)
self._read_config_data(path)
self._batch_fetch = self.get_option('batch_fetch')
self._filters = self.get_option('exclude_host_filters') + self.get_option('default_host_filters')
try:
self._credential_setup()
self._get_hosts()
except Exception as ex:
raise
def _credential_setup(self):
auth_options = dict(
auth_source=self.get_option('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'),
adfs_authority_url=self.get_option('adfs_authority_url')
)
self.azure_auth = AzureRMAuth(**auth_options)
self._clientconfig = AzureRMRestConfiguration(self.azure_auth.azure_credentials, self.azure_auth.subscription_id,
self.azure_auth._cloud_environment.endpoints.resource_manager)
self._client = ServiceClient(self._clientconfig.credentials, self._clientconfig)
def _enqueue_get(self, url, api_version, handler, handler_args=None):
if not handler_args:
handler_args = {}
self._request_queue.put_nowait(UrlAction(url=url, api_version=api_version, handler=handler, handler_args=handler_args))
def _enqueue_vm_list(self, rg='*'):
if not rg or rg == '*':
url = '/subscriptions/{subscriptionId}/providers/Microsoft.Compute/virtualMachines'
else:
url = '/subscriptions/{subscriptionId}/resourceGroups/{rg}/providers/Microsoft.Compute/virtualMachines'
url = url.format(subscriptionId=self._clientconfig.subscription_id, rg=rg)
self._enqueue_get(url=url, api_version=self._compute_api_version, handler=self._on_vm_page_response)
def _enqueue_vmss_list(self, rg=None):
if not rg or rg == '*':
url = '/subscriptions/{subscriptionId}/providers/Microsoft.Compute/virtualMachineScaleSets'
else:
url = '/subscriptions/{subscriptionId}/resourceGroups/{rg}/providers/Microsoft.Compute/virtualMachineScaleSets'
url = url.format(subscriptionId=self._clientconfig.subscription_id, rg=rg)
self._enqueue_get(url=url, api_version=self._compute_api_version, handler=self._on_vmss_page_response)
def _get_hosts(self):
for vm_rg in self.get_option('include_vm_resource_groups'):
self._enqueue_vm_list(vm_rg)
for vmss_rg in self.get_option('include_vmss_resource_groups'):
self._enqueue_vmss_list(vmss_rg)
if self._batch_fetch:
self._process_queue_batch()
else:
self._process_queue_serial()
constructable_config_strict = boolean(self.get_option('fail_on_template_errors'))
constructable_config_compose = self.get_option('hostvar_expressions')
constructable_config_groups = self.get_option('conditional_groups')
constructable_config_keyed_groups = self.get_option('keyed_groups')
for h in self._hosts:
inventory_hostname = self._get_hostname(h)
if self._filter_host(inventory_hostname, h.hostvars):
continue
self.inventory.add_host(inventory_hostname)
# FUTURE: configurable default IP list? can already do this via hostvar_expressions
self.inventory.set_variable(inventory_hostname, "ansible_host",
next(chain(h.hostvars['public_ipv4_addresses'], h.hostvars['private_ipv4_addresses']), None))
for k, v in iteritems(h.hostvars):
# FUTURE: configurable hostvar prefix? Makes docs harder...
self.inventory.set_variable(inventory_hostname, k, v)
# constructable delegation
self._set_composite_vars(constructable_config_compose, h.hostvars, inventory_hostname, strict=constructable_config_strict)
self._add_host_to_composed_groups(constructable_config_groups, h.hostvars, inventory_hostname, strict=constructable_config_strict)
self._add_host_to_keyed_groups(constructable_config_keyed_groups, h.hostvars, inventory_hostname, strict=constructable_config_strict)
# FUTURE: fix underlying inventory stuff to allow us to quickly access known groupvars from reconciled host
def _filter_host(self, inventory_hostname, hostvars):
self.templar.set_available_variables(hostvars)
for condition in self._filters:
# FUTURE: should warn/fail if conditional doesn't return True or False
conditional = "{{% if {0} %}} True {{% else %}} False {{% endif %}}".format(condition)
try:
if boolean(self.templar.template(conditional)):
return True
except Exception as e:
if boolean(self.get_option('fail_on_template_errors')):
raise AnsibleParserError("Error evaluating filter condition '{0}' for host {1}: {2}".format(condition, inventory_hostname, to_native(e)))
continue
return False
def _get_hostname(self, host):
# FUTURE: configurable hostname sources
return host.default_inventory_hostname
def _process_queue_serial(self):
try:
while True:
item = self._request_queue.get_nowait()
resp = self.send_request(item.url, item.api_version)
item.handler(resp, **item.handler_args)
except Empty:
pass
def _on_vm_page_response(self, response, vmss=None):
next_link = response.get('nextLink')
if next_link:
self._enqueue_get(url=next_link, api_version=self._compute_api_version, handler=self._on_vm_page_response)
for h in response['value']:
# FUTURE: add direct VM filtering by tag here (performance optimization)?
self._hosts.append(AzureHost(h, self, vmss=vmss))
def _on_vmss_page_response(self, response):
next_link = response.get('nextLink')
if next_link:
self._enqueue_get(url=next_link, api_version=self._compute_api_version, handler=self._on_vmss_page_response)
# FUTURE: add direct VMSS filtering by tag here (performance optimization)?
for vmss in response['value']:
url = '{0}/virtualMachines'.format(vmss['id'])
# VMSS instances look close enough to regular VMs that we can share the handler impl...
self._enqueue_get(url=url, api_version=self._compute_api_version, handler=self._on_vm_page_response, handler_args=dict(vmss=vmss))
# use the undocumented /batch endpoint to bulk-send up to 500 requests in a single round-trip
#
def _process_queue_batch(self):
while True:
batch_requests = []
batch_item_index = 0
batch_response_handlers = []
try:
while batch_item_index < 500:
item = self._request_queue.get_nowait()
query_parameters = {'api-version': item.api_version}
req = self._client.get(item.url, query_parameters)
batch_requests.append(dict(httpMethod="GET", url=req.url))
batch_response_handlers.append(item)
batch_item_index += 1
except Empty:
pass
if not batch_requests:
break
batch_resp = self._send_batch(batch_requests)
for idx, r in enumerate(batch_resp['responses']):
status_code = r.get('httpStatusCode')
if status_code != 200:
# FUTURE: error-tolerant operation mode (eg, permissions)
raise AnsibleError("a batched request failed with status code {0}, url {1}".format(status_code, batch_requests[idx].get('url')))
item = batch_response_handlers[idx]
# FUTURE: store/handle errors from individual handlers
item.handler(r['content'], **item.handler_args)
def _send_batch(self, batched_requests):
url = '/batch'
query_parameters = {'api-version': '2015-11-01'}
body_obj = dict(requests=batched_requests)
body_content = self._serializer.body(body_obj, 'object')
request = self._client.post(url, query_parameters)
initial_response = self._client.send(request, self._default_header_parameters, body_content)
# FUTURE: configurable timeout?
poller = ARMPolling(timeout=2)
poller.initialize(client=self._client,
initial_response=initial_response,
deserialization_callback=lambda r: self._deserializer('object', r))
poller.run()
return poller.resource()
def send_request(self, url, api_version):
query_parameters = {'api-version': api_version}
req = self._client.get(url, query_parameters)
resp = self._client.send(req, self._default_header_parameters, stream=False)
resp.raise_for_status()
content = resp.content
return json.loads(content)
# VM list (all, N resource groups): VM -> InstanceView, N NICs, N PublicIPAddress)
# VMSS VMs (all SS, N specific SS, N resource groups?): SS -> VM -> InstanceView, N NICs, N PublicIPAddress)
class AzureHost(object):
_powerstate_regex = re.compile('^PowerState/(?P<powerstate>.+)$')
def __init__(self, vm_model, inventory_client, vmss=None):
self._inventory_client = inventory_client
self._vm_model = vm_model
self._vmss = vmss
self._instanceview = None
self._powerstate = "unknown"
self.nics = []
# Azure often doesn't provide a globally-unique filename, so use resource name + a chunk of ID hash
self.default_inventory_hostname = '{0}_{1}'.format(vm_model['name'], hashlib.sha1(vm_model['id']).hexdigest()[0:4])
self._hostvars = {}
inventory_client._enqueue_get(url="{0}/instanceView".format(vm_model['id']),
api_version=self._inventory_client._compute_api_version,
handler=self._on_instanceview_response)
nic_refs = vm_model['properties']['networkProfile']['networkInterfaces']
for nic in nic_refs:
# single-nic instances don't set primary, so figure it out...
is_primary = nic.get('properties', {}).get('primary', len(nic_refs) == 1)
inventory_client._enqueue_get(url=nic['id'], api_version=self._inventory_client._network_api_version,
handler=self._on_nic_response,
handler_args=dict(is_primary=is_primary))
@property
def hostvars(self):
if self._hostvars != {}:
return self._hostvars
new_hostvars = dict(
public_ipv4_addresses=[],
public_dns_hostnames=[],
private_ipv4_addresses=[],
id=self._vm_model['id'],
location=self._vm_model['location'],
name=self._vm_model['name'],
powerstate=self._powerstate,
provisioning_state=self._vm_model['properties']['provisioningState'].lower(),
tags=self._vm_model.get('tags', {}),
resource_type=self._vm_model.get('type', "unknown"),
vmid=self._vm_model['properties']['vmId'],
vmss=dict(
id=self._vmss['id'],
name=self._vmss['name'],
) if self._vmss else {}
)
# set nic-related values from the primary NIC first
for nic in sorted(self.nics, key=lambda n: n.is_primary, reverse=True):
# and from the primary IP config per NIC first
for ipc in sorted(nic._nic_model['properties']['ipConfigurations'], key=lambda i: i['properties']['primary'], reverse=True):
private_ip = ipc['properties'].get('privateIPAddress')
if private_ip:
new_hostvars['private_ipv4_addresses'].append(private_ip)
pip_id = ipc['properties'].get('publicIPAddress', {}).get('id')
if pip_id:
pip = nic.public_ips[pip_id]
new_hostvars['public_ipv4_addresses'].append(pip._pip_model['properties']['ipAddress'])
pip_fqdn = pip._pip_model['properties'].get('dnsSettings', {}).get('fqdn')
if pip_fqdn:
new_hostvars['public_dns_hostnames'].append(pip_fqdn)
self._hostvars = new_hostvars
return self._hostvars
def _on_instanceview_response(self, vm_instanceview_model):
self._instanceview = vm_instanceview_model
self._powerstate = next((self._powerstate_regex.match(s.get('code', '')).group('powerstate')
for s in vm_instanceview_model.get('statuses', []) if self._powerstate_regex.match(s.get('code', ''))), 'unknown')
def _on_nic_response(self, nic_model, is_primary=False):
nic = AzureNic(nic_model=nic_model, inventory_client=self._inventory_client, is_primary=is_primary)
self.nics.append(nic)
class AzureNic(object):
def __init__(self, nic_model, inventory_client, is_primary=False):
self._nic_model = nic_model
self.is_primary = is_primary
self._inventory_client = inventory_client
self.public_ips = {}
for ipc in nic_model['properties']['ipConfigurations']:
pip = ipc['properties'].get('publicIPAddress')
if pip:
self._inventory_client._enqueue_get(url=pip['id'], api_version=self._inventory_client._network_api_version, handler=self._on_pip_response)
def _on_pip_response(self, pip_model):
self.public_ips[pip_model['id']] = AzurePip(pip_model)
class AzurePip(object):
def __init__(self, pip_model):
self._pip_model = pip_model