diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index 3c052facc7..45db25a7bf 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -425,6 +425,11 @@ def load_inventory_source(source, all_group=None, group_filter_re=None, ''' Load inventory from given source directory or file. ''' + # Sanity check: We need the "azure" module to be titled "windows_azure.py", + # because it depends on the "azure" package from PyPI, and naming the + # module the same way makes the importer sad. + source = source.replace('azure', 'windows_azure') + logger.debug('Analyzing type of source: %s', source) original_all_group = all_group if not os.path.exists(source): diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index 95603a84cb..f41f50c98b 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -201,15 +201,39 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): return password def _validate_ssh_private_key(self, data): + """Validate that the given SSH private key or certificate is, + in fact, valid. + """ + cert = '' + data = data.strip() validation_error = ValidationError('Invalid SSH private key') - begin_re = re.compile(r'^(-{4,})\s*BEGIN\s+([A-Z0-9]+)?\s*PRIVATE\sKEY\s*(-{4,})$') - header_re = re.compile(r'^(.+?):\s*?(.+?)(\\??)$') - end_re = re.compile(r'^(-{4,})\s*END\s+([A-Z0-9]+)?\s*PRIVATE\sKEY\s*(-{4,})$') - lines = data.strip().splitlines() + + # Set up the valid private key header and footer. + begin_re = r'^(-{4,})\s*BEGIN\s+([A-Z0-9]+)?\s*PRIVATE\sKEY\s*(-{4,})$' + end_re = r'^(-{4,})\s*END\s+([A-Z0-9]+)?\s*PRIVATE\sKEY\s*(-{4,})$' + + # Sanity check: We may potentially receive a full PEM certificate, + # and we want to accept these. + cert_re = r'^(-{4,})\s*BEGIN\s+CERTIFICATE\s*(-{4,})' + cert_match = re.search(cert_re, data) + if cert_match: + private_key_begin = re.search(begin_re[1:-1], data) + if not private_key_begin: + raise validation_error + boundary = private_key_begin.start() + cert = data[:boundary].strip() + data = data[boundary:].strip() + + # Split the SSH key into individual lines. + # If we have no content at all, then this is not a valid SSH key. + lines = data.splitlines() if not lines: raise validation_error - begin_match = begin_re.match(lines[0]) - end_match = end_re.match(lines[-1]) + + # Match the beginning and ending against what we expect, and also + # ensure that they match one another. + begin_match = re.match(begin_re, lines[0]) + end_match = re.match(end_re, lines[-1]) if not begin_match or not end_match: raise validation_error dashes = set([begin_match.groups()[0], begin_match.groups()[2], @@ -219,25 +243,40 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): if begin_match.groups()[1] != end_match.groups()[1]: raise validation_error line_continues = False + + # Establish that we are able to base64 decode the private key; + # if we can't, then it's not a valid key. + # + # If we got a certificate, validate that also, in the same way. + header_re = re.compile(r'^(.+?):\s*?(.+?)(\\??)$') base64_data = '' - for line in lines[1:-1]: - line = line.strip() - if not line: + for segment_to_validate in (cert, data): + # If we have nothing; skip this one. + # We've already validated that we have a private key above, + # so we don't need to do it again. + if not segment_to_validate: continue - if line_continues: - line_continues = line.endswith('\\') - continue - line_match = header_re.match(line) - if line_match: - line_continues = line.endswith('\\') - continue - base64_data += line - try: - decoded_data = base64.b64decode(base64_data) - if not decoded_data: + + # Ensure that this segment is valid base64 data. + lines = segment_to_validate.splitlines() + for line in lines[1:-1]: + line = line.strip() + if not line: + continue + if line_continues: + line_continues = line.endswith('\\') + continue + line_match = header_re.match(line) + if line_match: + line_continues = line.endswith('\\') + continue + base64_data += line + try: + decoded_data = base64.b64decode(base64_data) + if not decoded_data: + raise validation_error + except TypeError: raise validation_error - except TypeError: - raise validation_error def clean_ssh_key_data(self): if self.pk: diff --git a/awx/plugins/inventory/windows_azure.py b/awx/plugins/inventory/windows_azure.py index 310d67d80c..156072e5b2 100755 --- a/awx/plugins/inventory/windows_azure.py +++ b/awx/plugins/inventory/windows_azure.py @@ -85,7 +85,9 @@ class AzureInventory(object): elif not self.is_cache_valid(): self.do_api_calls_update_cache() - if self.args.list_images: + if self.args.host: + data_to_print = self.get_host(self.args.host) + elif self.args.list_images: data_to_print = self.json_format_dict(self.get_images(), True) elif self.args.list: # Display list of nodes for inventory @@ -96,6 +98,23 @@ class AzureInventory(object): print data_to_print + def get_host(self, hostname): + """Return information about the given hostname, based on what + the Windows Azure API provides. + """ + # Strip ".cloudapp.net" off of the end of the hostname if + # it is present. + if hostname.endswith('.cloudapp.net'): + hostname = hostname.replace('.cloudapp.net', '') + + # Retrieve information about the host. + host = self.sms.get_hosted_service_properties(hostname) + hsp = host.hosted_service_properties # Because reasons. + return json.dumps({ + 'label': hsp.label, + 'status': hsp.status.lower(), + }) + def get_images(self): images = [] for image in self.sms.list_os_images(): @@ -142,13 +161,20 @@ class AzureInventory(object): def parse_cli_args(self): """Command line argument processing""" - parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on Azure') + parser = argparse.ArgumentParser( + description='Produce an Ansible Inventory file based on Azure', + ) parser.add_argument('--list', action='store_true', default=True, - help='List nodes (default: True)') + help='List nodes (default: True)') parser.add_argument('--list-images', action='store', - help='Get all available images.') - parser.add_argument('--refresh-cache', action='store_true', default=False, - help='Force refresh of cache by making API requests to Azure (default: False - use cache files)') + help='Get all available images.') + parser.add_argument('--refresh-cache', + action='store_true', default=False, + help='Force refresh of thecache by making API requests to Azure ' + '(default: False - use cache files)', + ) + parser.add_argument('--host', action='store', + help='Get all information about an instance.') self.args = parser.parse_args() def do_api_calls_update_cache(self): diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 446ea322bd..eea3b5476c 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -443,7 +443,7 @@ GCE_INSTANCE_ID_VAR = None # It's not possible to get zones in Azure without authenticating, so we # provide a list here. -WA_REGION_CHOICES = [ +AZURE_REGION_CHOICES = [ ('Central_US', 'US Central'), ('East_US_1', 'US East'), ('East_US_2', 'US East 2'), @@ -458,19 +458,19 @@ WA_REGION_CHOICES = [ ('West_Japan', 'Japan West'), ('South_Brazil', 'Brazil South'), ] -WA_REGIONS_BLACKLIST = [] +AZURE_REGIONS_BLACKLIST = [] # Inventory variable name/value for determining whether a host is active -# in Google Compute Engine. -WA_ENABLED_VAR = 'status' -WA_ENABLED_VALUE = 'running' +# in Windows Azure. +AZURE_ENABLED_VAR = 'status' +AZURE_ENABLED_VALUE = 'created' # Filter for allowed group and host names when importing inventory from -# Google Compute Engine. -WA_GROUP_FILTER = r'^.+$' -WA_HOST_FILTER = r'^.+$' -WA_EXCLUDE_EMPTY_GROUPS = True -WA_INSTANCE_ID_VAR = None +# Windows Azure. +AZURE_GROUP_FILTER = r'^.+$' +AZURE_HOST_FILTER = r'^.+$' +AZURE_EXCLUDE_EMPTY_GROUPS = True +AZURE_INSTANCE_ID_VAR = None # ---------------------