from __future__ import absolute_import, division, print_function __metaclass__ = type from ansible.module_utils.basic import AnsibleModule, env_fallback from ansible.module_utils.urls import Request, SSLValidationError, ConnectionError from ansible.module_utils.six import PY2 from ansible.module_utils.six.moves import StringIO from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode from ansible.module_utils.six.moves.urllib.error import HTTPError from ansible.module_utils.six.moves.http_cookiejar import CookieJar from ansible.module_utils.six.moves.configparser import ConfigParser, NoOptionError from socket import gethostbyname import re from json import loads, dumps from os.path import isfile, expanduser, split, join, exists, isdir from os import access, R_OK, getcwd from distutils.util import strtobool try: import yaml HAS_YAML = True except ImportError: HAS_YAML = False class ConfigFileException(Exception): pass class ItemNotDefined(Exception): pass class TowerModule(AnsibleModule): url = None honorred_settings = ['host', 'username', 'password', 'verify_ssl', 'oauth_token'] host = '127.0.0.1' username = None password = None verify_ssl = True oauth_token = None oauth_token_id = None session = None cookie_jar = CookieJar() authenticated = False json_output = {'changed': False} config_name = 'tower_cli.cfg' def __init__(self, argument_spec, **kwargs): args = dict( tower_host=dict(required=False, fallback=(env_fallback, ['TOWER_HOST'])), tower_username=dict(required=False, fallback=(env_fallback, ['TOWER_USERNAME'])), tower_password=dict(no_log=True, required=False, fallback=(env_fallback, ['TOWER_PASSWORD'])), validate_certs=dict(type='bool', aliases=['tower_verify_ssl'], required=False, fallback=(env_fallback, ['TOWER_VERIFY_SSL'])), tower_oauthtoken=dict(type='str', no_log=True, required=False, fallback=(env_fallback, ['TOWER_OAUTH_TOKEN'])), tower_config_file=dict(type='path', required=False, default=None), ) args.update(argument_spec) kwargs['supports_check_mode'] = True # We have to take off mutually_exclusive_if in order to init with Ansible mutually_exclusive_if = kwargs.pop('mutually_exclusive_if', None) super(TowerModule, self).__init__(argument_spec=args, **kwargs) # Eventually, we would like to push this as a feature to Ansible core for others to use... # Test mutually_exclusive if if mutually_exclusive_if: for (var_name, var_value, exclusive_names) in mutually_exclusive_if: if self.params.get(var_name) == var_value: for excluded_param_name in exclusive_names: if self.params.get(excluded_param_name) is not None: self.fail_json(msg='Arguments {0} can not be set if source is {1}'.format(', '.join(exclusive_names), var_value)) self.load_config_files() # Parameters specified on command line will override settings in any config if self.params.get('tower_host'): self.host = self.params.get('tower_host') if self.params.get('tower_username'): self.username = self.params.get('tower_username') if self.params.get('tower_password'): self.password = self.params.get('tower_password') if self.params.get('validate_certs') is not None: self.verify_ssl = self.params.get('validate_certs') if self.params.get('tower_oauthtoken'): self.oauth_token = self.params.get('tower_oauthtoken') # Perform some basic validation if not re.match('^https{0,1}://', self.host): self.host = "https://{0}".format(self.host) # Try to parse the hostname as a url try: self.url = urlparse(self.host) except Exception as e: self.fail_json(msg="Unable to parse tower_host as a URL ({1}): {0}".format(self.host, e)) # Try to resolve the hostname hostname = self.url.netloc.split(':')[0] try: gethostbyname(hostname) except Exception as e: self.fail_json(msg="Unable to resolve tower_host ({1}): {0}".format(hostname, e)) self.session = Request(cookies=CookieJar(), validate_certs=self.verify_ssl) def load_config_files(self): # Load configs like TowerCLI would have from least import to most config_files = ['/etc/tower/tower_cli.cfg', join(expanduser("~"), ".{0}".format(self.config_name))] local_dir = getcwd() config_files.append(join(local_dir, self.config_name)) while split(local_dir)[1]: local_dir = split(local_dir)[0] config_files.insert(2, join(local_dir, ".{0}".format(self.config_name))) for config_file in config_files: if exists(config_file) and not isdir(config_file): # Only throw a formatting error if the file exists and is not a directory try: self.load_config(config_file) except ConfigFileException: self.fail_json('The config file {0} is not properly formatted'.format(config_file)) # If we have a specified tower config, load it if self.params.get('tower_config_file'): try: self.load_config(self.params.get('tower_config_file')) except ConfigFileException as cfe: # Since we were told specifically to load this we want it to fail if we have an error self.fail_json(msg=cfe) def load_config(self, config_path): # Validate the config file is an actual file if not isfile(config_path): raise ConfigFileException('The specified config file does not exist') if not access(config_path, R_OK): raise ConfigFileException("The specified config file can not be read") # Read in the file contents: with open(config_path, 'r') as f: config_string = f.read() # First try to yaml load the content (which will also load json) try: config_data = yaml.load(config_string, Loader=yaml.SafeLoader) # If this is an actual ini file, yaml will return the whole thing as a string instead of a dict if type(config_data) is not dict: raise AssertionError("The yaml config file is not properly formatted as a dict.") except(AttributeError, yaml.YAMLError, AssertionError): # TowerCLI used to support a config file with a missing [general] section by prepending it if missing if '[general]' not in config_string: config_string = '[general]{0}'.format(config_string) config = ConfigParser() try: placeholder_file = StringIO(config_string) # py2 ConfigParser has readfp, that has been deprecated in favor of read_file in py3 # This "if" removes the deprecation warning if hasattr(config, 'read_file'): config.read_file(placeholder_file) else: config.readfp(placeholder_file) # If we made it here then we have values from reading the ini file, so let's pull them out into a dict config_data = {} for honorred_setting in self.honorred_settings: try: config_data[honorred_setting] = config.get('general', honorred_setting) except (NoOptionError): pass except Exception as e: raise ConfigFileException("An unknown exception occured trying to ini load config file: {0}".format(e)) except Exception as e: raise ConfigFileException("An unknown exception occured trying to load config file: {0}".format(e)) # If we made it here, we have a dict which has values in it from our config, any final settings logic can be performed here for honorred_setting in self.honorred_settings: if honorred_setting in config_data: # Veriffy SSL must be a boolean if honorred_setting == 'verify_ssl': if type(config_data[honorred_setting]) is str: setattr(self, honorred_setting, strtobool(config_data[honorred_setting])) else: setattr(self, honorred_setting, bool(config_data[honorred_setting])) else: setattr(self, honorred_setting, config_data[honorred_setting]) def head_endpoint(self, endpoint, *args, **kwargs): return self.make_request('HEAD', endpoint, **kwargs) def get_endpoint(self, endpoint, *args, **kwargs): return self.make_request('GET', endpoint, **kwargs) def patch_endpoint(self, endpoint, *args, **kwargs): # Handle check mode if self.check_mode: self.json_output['changed'] = True self.exit_json(**self.json_output) return self.make_request('PATCH', endpoint, **kwargs) def post_endpoint(self, endpoint, *args, **kwargs): # Handle check mode if self.check_mode: self.json_output['changed'] = True self.exit_json(**self.json_output) return self.make_request('POST', endpoint, **kwargs) def delete_endpoint(self, endpoint, *args, **kwargs): # Handle check mode if self.check_mode: self.json_output['changed'] = True self.exit_json(**self.json_output) return self.make_request('DELETE', endpoint, **kwargs) def get_all_endpoint(self, endpoint, *args, **kwargs): response = self.get_endpoint(endpoint, *args, **kwargs) next_page = response['json']['next'] if response['json']['count'] > 10000: self.fail_json(msg='The number of items being queried for is higher than 10,000.') while next_page is not None: next_response = self.get_endpoint(next_page) response['json']['results'] = response['json']['results'] + next_response['json']['results'] next_page = next_response['json']['next'] return response def get_one(self, endpoint, *args, **kwargs): response = self.get_endpoint(endpoint, *args, **kwargs) if response['status_code'] != 200: self.fail_json(msg="Got a {0} response when trying to get one from {1}".format(response['status_code'], endpoint)) if 'count' not in response['json'] or 'results' not in response['json']: self.fail_json(msg="The endpoint did not provide count and results") if response['json']['count'] == 0: return None elif response['json']['count'] > 1: self.fail_json(msg="An unexpected number of items was returned from the API ({0})".format(response['json']['count'])) return response['json']['results'][0] def resolve_name_to_id(self, endpoint, name_or_id): # Try to resolve the object by name response = self.get_endpoint(endpoint, **{'data': {'name': name_or_id}}) if response['json']['count'] == 1: return response['json']['results'][0]['id'] elif response['json']['count'] == 0: try: int(name_or_id) # If we got 0 items by name, maybe they gave us an ID, let's try looking it up by ID response = self.head_endpoint("{0}/{1}".format(endpoint, name_or_id), **{'return_none_on_404': True}) if response is not None: return name_or_id except ValueError: # If we got a value error than we didn't have an integer so we can just pass and fall down to the fail pass self.fail_json(msg="The {0} {1} was not found on the Tower server".format(endpoint, name_or_id)) else: self.fail_json(msg="Found too many names {0} at endpoint {1} try using an ID instead of a name".format(name_or_id, endpoint)) def make_request(self, method, endpoint, *args, **kwargs): # In case someone is calling us directly; make sure we were given a method, let's not just assume a GET if not method: raise Exception("The HTTP method must be defined") # Make sure we start with /api/vX if not endpoint.startswith("/"): endpoint = "/{0}".format(endpoint) if not endpoint.startswith("/api/"): endpoint = "/api/v2{0}".format(endpoint) if not endpoint.endswith('/') and '?' not in endpoint: endpoint = "{0}/".format(endpoint) # Extract the headers, this will be used in a couple of places headers = kwargs.get('headers', {}) # Authenticate to Tower (if we've not already done so) if not self.authenticated: # This method will set a cookie in the cookie jar for us self.authenticate(**kwargs) if self.oauth_token: # If we have a oauth token, we just use a bearer header headers['Authorization'] = 'Bearer {0}'.format(self.oauth_token) # Update the URL path with the endpoint self.url = self.url._replace(path=endpoint) if method in ['POST', 'PUT', 'PATCH']: headers.setdefault('Content-Type', 'application/json') kwargs['headers'] = headers elif kwargs.get('data'): self.url = self.url._replace(query=urlencode(kwargs.get('data'))) data = {} if headers.get('Content-Type', '') == 'application/json': data = dumps(kwargs.get('data', {})) try: response = self.session.open(method, self.url.geturl(), headers=headers, validate_certs=self.verify_ssl, follow_redirects=True, data=data) self.url = self.url._replace(query=None) except(SSLValidationError) as ssl_err: self.fail_json(msg="Could not establish a secure connection to your host ({1}): {0}.".format(self.url.netloc, ssl_err)) except(ConnectionError) as con_err: self.fail_json(msg="There was a network error of some kind trying to connect to your host ({1}): {0}.".format(self.url.netloc, con_err)) except(HTTPError) as he: # Sanity check: Did the server send back some kind of internal error? if he.code >= 500: self.fail_json(msg='The host sent back a server error ({1}): {0}. Please check the logs and try again later'.format(self.url.path, he)) # Sanity check: Did we fail to authenticate properly? If so, fail out now; this is always a failure. elif he.code == 401: self.fail_json(msg='Invalid Tower authentication credentials for {0} (HTTP 401).'.format(self.url.path)) # Sanity check: Did we get a forbidden response, which means that the user isn't allowed to do this? Report that. elif he.code == 403: self.fail_json(msg="You don't have permission to {1} to {0} (HTTP 403).".format(self.url.path, method)) # Sanity check: Did we get a 404 response? # Requests with primary keys will return a 404 if there is no response, and we want to consistently trap these. elif he.code == 404: if kwargs.get('return_none_on_404', False): return None self.fail_json(msg='The requested object could not be found at {0}.'.format(self.url.path)) # Sanity check: Did we get a 405 response? # A 405 means we used a method that isn't allowed. Usually this is a bad request, but it requires special treatment because the # API sends it as a logic error in a few situations (e.g. trying to cancel a job that isn't running). elif he.code == 405: self.fail_json(msg="The Tower server says you can't make a request with the {0} method to this endpoing {1}".format(method, self.url.path)) # Sanity check: Did we get some other kind of error? If so, write an appropriate error message. elif he.code >= 400: # We are going to return a 400 so the module can decide what to do with it page_data = he.read() try: return {'status_code': he.code, 'json': loads(page_data)} # JSONDecodeError only available on Python 3.5+ except ValueError: return {'status_code': he.code, 'text': page_data} elif he.code == 204 and method == 'DELETE': # A 204 is a normal response for a delete function pass else: self.fail_json(msg="Unexpected return code when calling {0}: {1}".format(self.url.geturl(), he)) except(Exception) as e: self.fail_json(msg="There was an unknown error when trying to connect to {2}: {0} {1}".format(type(e).__name__, e, self.url.geturl())) response_body = '' try: response_body = response.read() except(Exception) as e: self.fail_json(msg="Failed to read response body: {0}".format(e)) response_json = {} if response_body and response_body != '': try: response_json = loads(response_body) except(Exception) as e: self.fail_json(msg="Failed to parse the response json: {0}".format(e)) if PY2: status_code = response.getcode() else: status_code = response.status return {'status_code': status_code, 'json': response_json} def authenticate(self, **kwargs): if self.username and self.password: # Attempt to get a token from /api/v2/tokens/ by giving it our username/password combo # If we have a username and password, we need to get a session cookie login_data = { "description": "Ansible Tower Module Token", "application": None, "scope": "write", } # Post to the tokens endpoint with baisc auth to try and get a token api_token_url = (self.url._replace(path='/api/v2/tokens/')).geturl() try: response = self.session.open( 'POST', api_token_url, validate_certs=self.verify_ssl, follow_redirects=True, force_basic_auth=True, url_username=self.username, url_password=self.password, data=dumps(login_data), headers={'Content-Type': 'application/json'} ) except(Exception) as e: # Sanity check: Did the server send back some kind of internal error? self.fail_json(msg='Failed to get token: {0}'.format(e)) token_response = None try: token_response = response.read() response_json = loads(token_response) self.oauth_token_id = response_json['id'] self.oauth_token = response_json['token'] except(Exception) as e: self.fail_json(msg="Failed to extract token information from login response: {0}".format(e), **{'response': token_response}) # If we have neither of these, then we can try un-authenticated access self.authenticated = True def default_check_mode(self): '''Execute check mode logic for Ansible Tower modules''' if self.check_mode: try: result = self.get_endpoint('ping') self.exit_json(**{'changed': True, 'tower_version': '{0}'.format(result['json']['version'])}) except(Exception) as excinfo: self.fail_json(changed=False, msg='Failed check mode: {0}'.format(excinfo)) def delete_if_needed(self, existing_item, handle_response=True, on_delete=None): # This will exit from the module on its own unless handle_response is False. # If handle_response is True and the method successfully deletes an item and on_delete param is defined, # the on_delete parameter will be called as a method pasing in this object and the json from the response # If you pass handle_response=False, it will return one of two things: # 1. None if the existing_item is not defined (so no delete needs to happen) # 2. The response from Tower from calling the delete on the endpont. It's up to you to process the response and exit from the module # Note: common error codes from the Tower API can cause the module to fail even if handle_response is set to False if existing_item: # If we have an item, we can try to delete it try: item_url = existing_item['url'] item_type = existing_item['type'] item_id = existing_item['id'] except KeyError as ke: self.fail_json(msg="Unable to process delete of item due to missing data {0}".format(ke)) if 'name' in existing_item: item_name = existing_item['name'] elif 'username' in existing_item: item_name = existing_item['username'] else: self.fail_json(msg="Unable to process delete of {0} due to missing name".format(item_type)) response = self.delete_endpoint(item_url) if not handle_response: return response elif response['status_code'] in [202, 204]: if on_delete: on_delete(self, response['json']) self.json_output['changed'] = True self.json_output['id'] = item_id self.exit_json(**self.json_output) else: if 'json' in response and '__all__' in response['json']: self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['__all__'][0])) elif 'json' in response: # This is from a project delete (if there is an active job against it) if 'error' in response['json']: self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['error'])) else: self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json'])) else: self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['status_code'])) else: if not handle_response: return None else: self.exit_json(**self.json_output) def create_if_needed(self, existing_item, new_item, endpoint, handle_response=True, on_create=None, item_type='unknown'): # # This will exit from the module on its own unless handle_response is False. # If handle_response is True and the method successfully creates an item and on_create param is defined, # the on_create parameter will be called as a method pasing in this object and the json from the response # If you pass handle_response=False it will return one of two things: # 1. None if the existing_item is already defined (so no create needs to happen) # 2. The response from Tower from calling the patch on the endpont. It's up to you to process the response and exit from the module # Note: common error codes from the Tower API can cause the module to fail even if handle_response is set to False # if not endpoint: self.fail_json(msg="Unable to create new {0} due to missing endpoint".format(item_type)) if existing_item: try: existing_item['url'] except KeyError as ke: self.fail_json(msg="Unable to process delete of item due to missing data {0}".format(ke)) if not handle_response: return None else: self.exit_json(**self.json_output) else: # If we don't have an exisitng_item, we can try to create it # We have to rely on item_type being passed in since we don't have an existing item that declares its type # We will pull the item_name out from the new_item, if it exists item_name = new_item.get('name', 'unknown') response = self.post_endpoint(endpoint, **{'data': new_item}) if not handle_response: return response elif response['status_code'] == 201: self.json_output['name'] = 'unknown' if 'name' in response['json']: self.json_output['name'] = response['json']['name'] elif 'username' in response['json']: # User objects return username instead of name self.json_output['name'] = response['json']['username'] self.json_output['id'] = response['json']['id'] self.json_output['changed'] = True if on_create is None: self.exit_json(**self.json_output) else: on_create(self, response['json']) else: if 'json' in response and '__all__' in response['json']: self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['json']['__all__'][0])) elif 'json' in response: self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['json'])) else: self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['status_code'])) def update_if_needed(self, existing_item, new_item, handle_response=True, on_update=None): # This will exit from the module on its own unless handle_response is False. # If handle_response is True and the method successfully updates an item and on_update param is defined, # the on_update parameter will be called as a method pasing in this object and the json from the response # If you pass handle_response=False it will return one of three things: # 1. None if the existing_item does not need to be updated # 2. The response from Tower from patching to the endpoint. It's up to you to process the response and exit from the module. # 3. An ItemNotDefined exception, if the existing_item does not exist # Note: common error codes from the Tower API can cause the module to fail even if handle_response is set to False if existing_item: # If we have an item, we can see if it needs an update try: item_url = existing_item['url'] item_name = existing_item['name'] item_type = existing_item['url'] item_id = existing_item['id'] except KeyError as ke: self.fail_json(msg="Unable to process update of item due to missing data {0}".format(ke)) needs_update = False for field in new_item: existing_field = existing_item.get(field, None) new_field = new_item.get(field, None) # If the two items don't match and we are not comparing '' to None if existing_field != new_field and not (existing_field in (None, '') and new_field == ''): # Something doesn't match so let's update it needs_update = True break if needs_update: response = self.patch_endpoint(item_url, **{'data': new_item}) if not handle_response: return response elif response['status_code'] == 200: self.json_output['changed'] = True self.json_output['id'] = item_id if on_update is None: self.exit_json(**self.json_output) else: on_update(self, response['json']) elif 'json' in response and '__all__' in response['json']: self.fail_json(msg=response['json']['__all__']) else: self.fail_json(**{'msg': "Unable to update {0} {1}, see response".format(item_type, item_name), 'response': response}) else: if not handle_response: return None # Since we made it here, we don't need to update, status ok self.json_output['changed'] = False self.json_output['id'] = item_id self.exit_json(**self.json_output) else: if handle_response: self.fail_json(msg="The exstiing item is not defined and thus cannot be updated") else: raise ItemNotDefined("Not given an existing item to update") def create_or_update_if_needed(self, existing_item, new_item, endpoint=None, handle_response=True, item_type='unknown', on_create=None, on_update=None): if existing_item: return self.update_if_needed(existing_item, new_item, handle_response=handle_response, on_update=on_update) else: return self.create_if_needed(existing_item, new_item, endpoint, handle_response=handle_response, on_create=on_create, item_type=item_type) def logout(self): if self.oauth_token_id is not None and self.username and self.password: # Attempt to delete our current token from /api/v2/tokens/ # Post to the tokens endpoint with baisc auth to try and get a token api_token_url = (self.url._replace(path='/api/v2/tokens/{0}/'.format(self.oauth_token_id))).geturl() try: self.session.open( 'DELETE', api_token_url, validate_certs=self.verify_ssl, follow_redirects=True, force_basic_auth=True, url_username=self.username, url_password=self.password ) self.oauth_token_id = None self.authenticated = False except(Exception) as e: # Sanity check: Did the server send back some kind of internal error? self.warn('Failed to release tower token {0}: {1}'.format(self.oauth_token_id, e)) def fail_json(self, **kwargs): # Try to log out if we are authenticated self.logout() super(TowerModule, self).fail_json(**kwargs) def exit_json(self, **kwargs): # Try to log out if we are authenticated self.logout() super(TowerModule, self).exit_json(**kwargs) def is_job_done(self, job_status): if job_status in ['new', 'pending', 'waiting', 'running']: return False else: return True def load_variables_if_file_specified(self, vars_value, var_name): if not vars_value.startswith('@'): return vars_value if not HAS_YAML: self.fail_json(msg=self.missing_required_lib('yaml')) file_name = None file_content = None try: file_name = expanduser(vars_value[1:]) with open(file_name, 'r') as f: file_content = f.read() except Exception as e: self.fail_json(msg="Failed to load file {0} for {1} : {2}".format(file_name, var_name, e)) try: vars_value = yaml.safe_load(file_content) except yaml.YAMLError: # Maybe it wasn't a YAML structure... lets try JSON try: vars_value = loads(file_content) except ValueError: self.fail_json(msg="Failed to load file {0} specifed by {1} as yaml or json".format(file_name, var_name)) return dumps(vars_value)