def main(args): # Retrieve the intersphinx information from the sphinx config file conf_dir = pathlib.Path(args.conf_file).parent conf_module_spec = importlib.util.spec_from_file_location( 'sphinxconf', args.conf_file) conf_module = importlib.util.module_from_spec(conf_module_spec) conf_module_spec.loader.exec_module(conf_module) intersphinx_mapping = conf_module.intersphinx_mapping for intersphinx_name, inventory in intersphinx_mapping.items(): if not is_iterable(inventory) or len(inventory) != 2: print('WARNING: The intersphinx entry for {0} must be' ' a two-tuple.\n{1}'.format(intersphinx_name, EXAMPLE_CONF)) continue url = cache_file = None for inv_source in inventory: if isinstance(inv_source, str) and url is None: url = inv_source elif is_iterable(inv_source) and cache_file is None: if len(inv_source) != 2: print( 'WARNING: The fallback entry for {0} should be a tuple of (None,' ' filename).\n{1}'.format(intersphinx_name, EXAMPLE_CONF)) continue cache_file = inv_source[1] else: print( 'WARNING: The configuration for {0} should be a tuple of one url and one' ' tuple for a fallback filename.\n{1}'.format( intersphinx_name, EXAMPLE_CONF)) continue if url is None or cache_file is None: print('WARNING: Could not figure out the url or fallback' ' filename for {0}.\n{1}'.format(intersphinx_name, EXAMPLE_CONF)) continue url = urllib.parse.urljoin(url, 'objects.inv') # Resolve any relative cache files to be relative to the conf file cache_file = conf_dir / cache_file # Retrieve the inventory and cache it # The jinja CDN seems to be blocking the default urllib User-Agent requestor = Request( headers={'User-Agent': 'Definitely Not Python ;-)'}) with requestor.open('GET', url) as source_file: with open(cache_file, 'wb') as f: f.write(source_file.read()) print( 'Download of new cache files complete. Remember to git commit -a the changes' ) return 0
def test_Request_fallback(urlopen_mock, install_opener_mock, mocker): cookies = cookiejar.CookieJar() request = Request( headers={'foo': 'bar'}, use_proxy=False, force=True, timeout=100, validate_certs=False, url_username='******', url_password='******', http_agent='ansible-tests', force_basic_auth=True, follow_redirects='all', client_cert='/tmp/client.pem', client_key='/tmp/client.key', cookies=cookies, unix_socket='/foo/bar/baz.sock', ca_path='/foo/bar/baz.pem', ) fallback_mock = mocker.spy(request, '_fallback') r = request.open('GET', 'https://ansible.com') calls = [ call(None, False), # use_proxy call(None, True), # force call(None, 100), # timeout call(None, False), # validate_certs call(None, 'user'), # url_username call(None, 'passwd'), # url_password call(None, 'ansible-tests'), # http_agent call(None, True), # force_basic_auth call(None, 'all'), # follow_redirects call(None, '/tmp/client.pem'), # client_cert call(None, '/tmp/client.key'), # client_key call(None, cookies), # cookies call(None, '/foo/bar/baz.sock'), # unix_socket call(None, '/foo/bar/baz.pem'), # ca_path call(None, None), # unredirected_headers call(None, True), # auto_decompress ] fallback_mock.assert_has_calls(calls) assert fallback_mock.call_count == 16 # All but headers use fallback args = urlopen_mock.call_args[0] assert args[1] is None # data, this is handled in the Request not urlopen assert args[2] == 100 # timeout req = args[0] assert req.headers == { 'Authorization': b'Basic dXNlcjpwYXNzd2Q=', 'Cache-control': 'no-cache', 'Foo': 'bar', 'User-agent': 'ansible-tests' } assert req.data is None assert req.get_method() == 'GET'
class Connection: def __init__(self, address): self._address = address.rstrip("/") self._headers = {} self._client = Request() def _request(self, method, path, payload=None): headers = self._headers.copy() data = None if payload: data = json.dumps(payload) headers["Content-Type"] = "application/json" url = self._address + path try: r = self._client.open(method, url, data=data, headers=headers) r_status = r.getcode() r_headers = dict(r.headers) data = r.read().decode("utf-8") r_data = json.loads(data) if data else {} except HTTPError as e: r_status = e.code r_headers = {} r_data = dict(msg=str(e.reason)) except (ConnectionError, URLError) as e: raise AnsibleConnectionFailure( "Could not connect to {0}: {1}".format(url, e.reason)) return r_status, r_headers, r_data def get(self, path): return self._request("GET", path) def post(self, path, payload=None): return self._request("POST", path, payload) def delete(self, path): return self._request("DELETE", path) def login(self, username, password): status, headers, _ = self.post( "/tokens", dict(username=username, password=password), ) self._headers["x-auth-token"] = headers["x-auth-token"] def logout(self): if "x-auth-token" in self._headers: self.delete("/tokens/" + self._headers["x-auth-token"]) del self._headers["x-auth-token"]
class GalaxyModule(AnsibleModule): url = None session = None AUTH_ARGSPEC = dict( galaxy_server=dict(required=False, fallback=(env_fallback, ['GALAXY_SERVER'])), validate_certs=dict(type='bool', aliases=['galaxy_verify_ssl'], required=False, fallback=(env_fallback, ['GALAXY_VERIFY_SSL'])), galaxy_token=dict(type='str', no_log=True, required=False, fallback=(env_fallback, ['GALAXY_API_TOKEN'])), ) ENCRYPTED_STRING = "$encrypted$" short_params = { 'host': 'galaxy_server', 'verify_ssl': 'validate_certs', 'oauth_token': 'galaxy_token', } IDENTITY_FIELDS = { } ENCRYPTED_STRING = "$encrypted$" host = '127.0.0.1' verify_ssl = True oauth_token = None error_callback = None warn_callback = None def __init__(self, argument_spec=None, direct_params=None, error_callback=None, warn_callback=None, **kwargs): full_argspec = {} full_argspec.update(GalaxyModule.AUTH_ARGSPEC) full_argspec.update(argument_spec) kwargs['supports_check_mode'] = True self.error_callback = error_callback self.warn_callback = warn_callback self.json_output = {'changed': False} if direct_params is not None: self.params = direct_params # else: super(GalaxyModule, self).__init__(argument_spec=full_argspec, **kwargs) self.session = Request(cookies=CookieJar(), validate_certs=self.verify_ssl) # Parameters specified on command line will override settings in any config for short_param, long_param in self.short_params.items(): direct_value = self.params.get(long_param) if direct_value is not None: setattr(self, short_param, direct_value) # Perform magic checking whether galaxy_token is a string if self.params.get('galaxy_token'): token_param = self.params.get('galaxy_token') if isinstance(token_param, string_types): self.oauth_token = self.params.get('galaxy_token') else: error_msg = "The provided galaxy_token type was not valid ({0}). Valid options are str or dict.".format(type(token_param).__name__) self.fail_json(msg=error_msg) # 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 galaxy 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 galaxy host ({1}): {0}".format(hostname, e)) if 'update_secrets' in self.params: self.update_secrets = self.params.pop('update_secrets') else: self.update_secrets = True def build_url(self, endpoint, query_params=None): # Make sure we start with /api/vX if not endpoint.startswith("/"): endpoint = "/{0}".format(endpoint) if not endpoint.startswith("/api/"): endpoint = "api/automation-hub/v3{0}".format(endpoint) if not endpoint.endswith('/') and '?' not in endpoint: endpoint = "{0}/".format(endpoint) # Update the URL path with the endpoint url = self.url._replace(path=endpoint) if query_params: url = url._replace(query=urlencode(query_params)) return url def fail_json(self, **kwargs): # Try to log out if we are authenticated if self.error_callback: self.error_callback(**kwargs) else: super(GalaxyModule, self).fail_json(**kwargs) def exit_json(self, **kwargs): # Try to log out if we are authenticated super(GalaxyModule, self).exit_json(**kwargs) def warn(self, warning): if self.warn_callback is not None: self.warn_callback(warning) else: super(GalaxyModule, self).warn(warning) @staticmethod def get_name_field_from_endpoint(endpoint): return GalaxyModule.IDENTITY_FIELDS.get(endpoint, 'name') def get_endpoint(self, endpoint, *args, **kwargs): return self.make_request('GET', endpoint, **kwargs) 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") if method in ['POST', 'PUT', 'PATCH']: url = self.build_url(endpoint) else: url = self.build_url(endpoint, query_params=kwargs.get('data')) # Extract the headers, this will be used in a couple of places headers = kwargs.get('headers', {}) # Authenticate to Automation Hub if self.oauth_token: # If we have a oauth token, we just use a token header headers['Authorization'] = 'token {0}'.format(self.oauth_token) if method in ['POST', 'PUT', 'PATCH']: headers.setdefault('Content-Type', 'application/json') kwargs['headers'] = headers data = None # Important, if content type is not JSON, this should not be dict type if headers.get('Content-Type', '') == 'application/json': data = dumps(kwargs.get('data', {})) try: response = self.session.open(method, url.geturl(), headers=headers, validate_certs=self.verify_ssl, follow_redirects=True, data=data) except(SSLValidationError) as ssl_err: self.fail_json(msg="Could not establish a secure connection to your host ({1}): {0}.".format(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(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(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 Automation Hub authentication credentials for {0} (HTTP 401).'.format(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(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(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 Automation Hub server says you can't make a request with the {0} method to this endpoing {1}".format(method, 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(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, 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 get_one(self, endpoint, name_or_id=None, allow_none=True, **kwargs): new_kwargs = kwargs.copy() if name_or_id: name_field = self.get_name_field_from_endpoint(endpoint) new_data = kwargs.get('data', {}).copy() if name_field in new_data: self.fail_json(msg="You can't specify the field {0} in your search data if using the name_or_id field".format(name_field)) try: new_data['or__id'] = int(name_or_id) new_data['or__{0}'.format(name_field)] = name_or_id except ValueError: # If we get a value error, then we didn't have an integer so we can just pass and fall down to the fail new_data[name_field] = name_or_id new_kwargs['data'] = new_data response = self.get_endpoint(endpoint, **new_kwargs) if response['status_code'] != 200: fail_msg = "Got a {0} response when trying to get one from {1}".format(response['status_code'], endpoint) if 'detail' in response.get('json', {}): fail_msg += ', detail: {0}'.format(response['json']['detail']) self.fail_json(msg=fail_msg) if 'count' not in response['json']['meta'] or 'data' not in response['json']: self.fail_json(msg="The endpoint did not provide count and results.") if response['json']['meta']['count'] == 0: if allow_none: return None else: self.fail_wanted_one(response, endpoint, new_kwargs.get('data')) elif response['json']['meta']['count'] > 1: if name_or_id: # Since we did a name or ID search and got > 1 return something if the id matches for asset in response['json']['data']: if str(asset['id']) == name_or_id: return self.existing_item_add_url(asset, endpoint) # We got > 1 and either didn't find something by ID (which means multiple names) # Or we weren't running with a or search and just got back too many to begin with. self.fail_wanted_one(response, endpoint, new_kwargs.get('data')) return self.existing_item_add_url(response['json']['data'][0], endpoint) def existing_item_add_url(self, existing_item, endpoint): # Add url and type to response as its missing in current iteration of Automation Hub. existing_item['url'] = "{0}{1}/".format(self.build_url(endpoint).geturl()[len(self.host):], existing_item['name']) existing_item['type'] = endpoint return existing_item def delete_if_needed(self, existing_item, on_delete=None, auto_exit=True): # This will exit from the module on its own. # If 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 # This 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 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'] item_name = self.get_item_name(existing_item, allow_unknown=True) except KeyError as ke: self.fail_json(msg="Unable to process delete of item due to missing data {0}".format(ke)) response = self.delete_endpoint(item_url) if 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) if auto_exit: self.exit_json(**self.json_output) else: return 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 auto_exit: self.exit_json(**self.json_output) else: return self.json_output def get_item_name(self, item, allow_unknown=False): if item: if 'name' in item: return item['name'] if allow_unknown: return 'unknown' if item: self.exit_json(msg='Cannot determine identity field for {0} object.'.format(item.get('type', 'unknown'))) else: self.exit_json(msg='Cannot determine identity field for Undefined object.') 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 create_or_update_if_needed( self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, auto_exit=True, associations=None ): if existing_item: return self.update_if_needed(existing_item, new_item, on_update=on_update, auto_exit=auto_exit, associations=associations) else: return self.create_if_needed( existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, auto_exit=auto_exit, associations=associations) def create_if_needed(self, existing_item, new_item, endpoint, on_create=None, auto_exit=True, item_type='unknown', associations=None): # This will exit from the module on its own # If 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 # This 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 Galaxy API can cause the module to fail if not endpoint: self.fail_json(msg="Unable to create new {0} due to missing endpoint".format(item_type)) item_url = None if existing_item: try: item_url = existing_item['url'] except KeyError as ke: self.fail_json(msg="Unable to process create of item due to missing data {0}".format(ke)) 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 = self.get_item_name(new_item, allow_unknown=True) response = self.post_endpoint(endpoint, **{'data': new_item}) if response['status_code'] in [201]: self.json_output['name'] = 'unknown' for key in ('name', 'username', 'identifier', 'hostname'): if key in response['json']: self.json_output['name'] = response['json'][key] self.json_output['id'] = response['json']['id'] self.json_output['changed'] = True item_url = "{0}{1}/".format(self.build_url(endpoint).geturl()[len(self.host):], new_item['name']) 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'])) # Process any associations with this item if associations is not None: for association_type in associations: sub_endpoint = '{0}{1}/'.format(item_url, association_type) self.modify_associations(sub_endpoint, associations[association_type]) # If we have an on_create method and we actually changed something we can call on_create if on_create is not None and self.json_output['changed']: on_create(self, response['json']) elif auto_exit: self.exit_json(**self.json_output) else: last_data = response['json'] return last_data def update_if_needed(self, existing_item, new_item, on_update=None, auto_exit=True, associations=None): # This will exit from the module on its own # If 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 # This will return one of two 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. # Note: common error codes from the Tower API can cause the module to fail response = None if existing_item: # If we have an item, we can see if it needs an update try: item_url = existing_item['url'] item_type = existing_item['type'] item_name = existing_item['name'] 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)) # Check to see if anything within the item requires the item to be updated needs_patch = self.objects_could_be_different(existing_item, new_item) # If we decided the item needs to be updated, update it self.json_output['id'] = item_id self.json_output['name'] = item_name self.json_output['type'] = item_type if needs_patch: response = self.put_endpoint(item_url, **{'data': new_item}) if response['status_code'] == 200: # compare apples-to-apples, old API data to new API data # but do so considering the fields given in parameters self.json_output['changed'] = self.objects_could_be_different( existing_item, response['json'], field_set=new_item.keys(), warning=True) 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: raise RuntimeError('update_if_needed called incorrectly without existing_item') # Process any associations with this item if associations is not None: for association_type, id_list in associations.items(): endpoint = '{0}{1}/'.format(item_url, association_type) self.modify_associations(endpoint, id_list) # If we change something and have an on_change call it if on_update is not None and self.json_output['changed']: if response is None: last_data = existing_item else: last_data = response['json'] on_update(self, last_data) elif auto_exit: self.exit_json(**self.json_output) else: if response is None: last_data = existing_item else: last_data = response['json'] return last_data def modify_associations(self, association_endpoint, new_association_list): # if we got None instead of [] we are not modifying the association_list if new_association_list is None: return # First get the existing associations response = self.get_all_endpoint(association_endpoint) existing_associated_ids = [association['id'] for association in response['json']['results']] # Disassociate anything that is in existing_associated_ids but not in new_association_list ids_to_remove = list(set(existing_associated_ids) - set(new_association_list)) for an_id in ids_to_remove: response = self.post_endpoint(association_endpoint, **{'data': {'id': int(an_id), 'disassociate': True}}) if response['status_code'] == 204: self.json_output['changed'] = True else: self.fail_json(msg="Failed to disassociate item {0}".format(response['json'].get('detail', response['json']))) # Associate anything that is in new_association_list but not in `association` for an_id in list(set(new_association_list) - set(existing_associated_ids)): response = self.post_endpoint(association_endpoint, **{'data': {'id': int(an_id)}}) if response['status_code'] == 204: self.json_output['changed'] = True else: self.fail_json(msg="Failed to associate item {0}".format(response['json'].get('detail', response['json']))) 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 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 put_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('PUT', endpoint, **kwargs) def get_all_endpoint(self, endpoint, *args, **kwargs): response = self.get_endpoint(endpoint, *args, **kwargs) if 'next' not in response['json']: raise RuntimeError('Expected list from API at {0}, got: {1}'.format(endpoint, response)) 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'] response['json']['next'] = next_page return response def fail_wanted_one(self, response, endpoint, query_params): sample = response.copy() if len(sample['json']['data']) > 1: sample['json']['data'] = sample['json']['data'][:2] + ['...more results snipped...'] url = self.build_url(endpoint, query_params) display_endpoint = url.geturl()[len(self.host):] # truncate to not include the base URL self.fail_json( msg="Request to {0} returned {1} items, expected 1".format( display_endpoint, response['json']['meta']['count'] ), query=query_params, response=sample, total_results=response['json']['meta']['count'] ) def objects_could_be_different(self, old, new, field_set=None, warning=False): if field_set is None: field_set = set(fd for fd in new.keys() if fd not in ('modified', 'related', 'summary_fields')) for field in field_set: new_field = new.get(field, None) old_field = old.get(field, None) if old_field != new_field: if self.update_secrets or (not self.fields_could_be_same(old_field, new_field)): return True # Something doesn't match, or something might not match elif self.has_encrypted_values(new_field) or field not in new: if self.update_secrets or (not self.fields_could_be_same(old_field, new_field)): # case of 'field not in new' - user password write-only field that API will not display self._encrypted_changed_warning(field, old, warning=warning) return True return False @staticmethod def has_encrypted_values(obj): """Returns True if JSON-like python content in obj has $encrypted$ anywhere in the data as a value """ if isinstance(obj, dict): for val in obj.values(): if GalaxyModule.has_encrypted_values(val): return True elif isinstance(obj, list): for val in obj: if GalaxyModule.has_encrypted_values(val): return True elif obj == GalaxyModule.ENCRYPTED_STRING: return True return False def _encrypted_changed_warning(self, field, old, warning=False): if not warning: return self.warn( 'The field {0} of {1} {2} has encrypted data and may inaccurately report task is changed.'.format( field, old.get('type', 'unknown'), old.get('id', 'unknown') ))
class iControlRestSession(object): """Represents a session that communicates with a BigIP. This acts as a loose wrapper around Ansible's ``Request`` class. We're doing this as interim work until we move to the httpapi connector. """ def __init__(self, headers=None, use_proxy=True, force=False, timeout=120, validate_certs=True, url_username=None, url_password=None, http_agent=None, force_basic_auth=False, follow_redirects='urllib2', client_cert=None, client_key=None, cookies=None): self.request = Request(headers=headers, use_proxy=use_proxy, force=force, timeout=timeout, validate_certs=validate_certs, url_username=url_username, url_password=url_password, http_agent=http_agent, force_basic_auth=force_basic_auth, follow_redirects=follow_redirects, client_cert=client_cert, client_key=client_key, cookies=cookies) self.last_url = None def get_headers(self, result): try: return dict(result.getheaders()) except AttributeError: return result.headers def update_response(self, response, result): response.headers = self.get_headers(result) response._content = result.read() response.status = result.getcode() response.url = result.geturl() response.msg = "OK (%s bytes)" % response.headers.get( 'Content-Length', 'unknown') def send(self, method, url, **kwargs): response = Response() # Set the last_url called # # This is used by the object destructor to erase the token when the # ModuleManager exits and destroys the iControlRestSession object self.last_url = url body = None data = kwargs.pop('data', None) json = kwargs.pop('json', None) if not data and json is not None: self.request.headers['Content-Type'] = 'application/json' body = _json.dumps(json) if not isinstance(body, bytes): body = body.encode('utf-8') if data: body = data if body: kwargs['data'] = body try: result = self.request.open(method, url, **kwargs) except HTTPError as e: # Catch HTTPError delivered from Ansible # # The structure of this object, in Ansible 2.8 is # # HttpError { # args # characters_written # close # code # delete # errno # file # filename # filename2 # fp # getcode # geturl # hdrs # headers # info # msg # name # reason # strerror # url # with_traceback # } self.update_response(response, e) return response self.update_response(response, result) return response def delete(self, url, **kwargs): return self.send('DELETE', url, **kwargs) def get(self, url, **kwargs): return self.send('GET', url, **kwargs) def patch(self, url, data=None, **kwargs): return self.send('PATCH', url, data=data, **kwargs) def post(self, url, data=None, **kwargs): return self.send('POST', url, data=data, **kwargs) def put(self, url, data=None, **kwargs): return self.send('PUT', url, data=data, **kwargs) def __del__(self): if self.last_url is None: return token = self.request.headers.get('X-F5-Auth-Token', None) if not token: return try: p = generic_urlparse(urlparse(self.last_url)) uri = "https://{0}:{1}/mgmt/shared/authz/tokens/{2}".format( p['hostname'], p['port'], token) self.delete(uri) except ValueError: pass
class TowerAPIModule(TowerModule): # TODO: Move the collection version check into tower_module.py # This gets set by the make process so whatever is in here is irrelevant _COLLECTION_VERSION = "0.0.1-devel" _COLLECTION_TYPE = "awx" # This maps the collections type (awx/tower) to the values returned by the API # Those values can be found in awx/api/generics.py line 204 collection_to_version = { 'awx': 'AWX', 'tower': 'Red Hat Ansible Tower', } session = None cookie_jar = CookieJar() def __init__(self, argument_spec, direct_params=None, error_callback=None, warn_callback=None, **kwargs): kwargs['supports_check_mode'] = True super(TowerAPIModule, self).__init__(argument_spec=argument_spec, direct_params=direct_params, error_callback=error_callback, warn_callback=warn_callback, **kwargs) self.session = Request(cookies=CookieJar(), validate_certs=self.verify_ssl) @staticmethod def param_to_endpoint(name): exceptions = { 'inventory': 'inventories', 'target_team': 'teams', 'workflow': 'workflow_job_templates' } return exceptions.get(name, '{0}s'.format(name)) 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) if 'next' not in response['json']: raise RuntimeError( 'Expected list from API at {0}, got: {1}'.format( endpoint, response)) 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'] response['json']['next'] = next_page return response def get_one(self, endpoint, *args, **kwargs): response = self.get_endpoint(endpoint, *args, **kwargs) if response['status_code'] != 200: fail_msg = "Got a {0} response when trying to get one from {1}".format( response['status_code'], endpoint) if 'detail' in response.get('json', {}): fail_msg += ', detail: {0}'.format(response['json']['detail']) self.fail_json(msg=fail_msg) 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 get_one_by_name_or_id(self, endpoint, name_or_id): name_field = 'name' if endpoint == 'users': name_field = 'username' query_params = {'or__{0}'.format(name_field): name_or_id} try: query_params['or__id'] = int(name_or_id) except ValueError: # If we get a value error, then we didn't have an integer so we can just pass and fall down to the fail pass response = self.get_endpoint(endpoint, **{'data': query_params}) if response['status_code'] != 200: self.fail_json( msg="Failed to query endpoint {0} for {1} {2} ({3}), see results" .format(endpoint, name_field, name_or_id, response['status_code']), resuls=response) if response['json']['count'] == 1: return response['json']['results'][0] elif response['json']['count'] > 1: for tower_object in response['json']['results']: # ID takes priority, so we match on that first if str(tower_object['id']) == name_or_id: return tower_object # We didn't match on an ID but we found more than 1 object, therefore the results are ambiguous self.fail_json( msg= "The requested name or id was ambiguous and resulted in too many items" ) elif response['json']['count'] == 0: self.fail_json( msg="The {0} {1} was not found on the Tower server".format( endpoint, name_or_id)) def resolve_name_to_id(self, endpoint, name_or_id): return self.get_one_by_name_or_id(endpoint, name_or_id)['id'] 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 don't have a token and if not already done so) if not self.oauth_token and not self.authenticated: # This method will set a cookie in the cookie jar for us and also an oauth_token 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 = None # Important, if content type is not JSON, this should not be dict type 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) 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())) finally: self.url = self.url._replace(query=None) if not self.version_checked: # In PY2 we get back an HTTPResponse object but PY2 is returning an addinfourl # First try to get the headers in PY3 format and then drop down to PY2. try: tower_type = response.getheader('X-API-Product-Name', None) tower_version = response.getheader('X-API-Product-Version', None) except Exception: tower_type = response.info().getheader('X-API-Product-Name', None) tower_version = response.info().getheader( 'X-API-Product-Version', None) if self._COLLECTION_TYPE not in self.collection_to_version or self.collection_to_version[ self._COLLECTION_TYPE] != tower_type: self.warn( "You are using the {0} version of this collection but connecting to {1}" .format(self._COLLECTION_TYPE, tower_type)) elif self._COLLECTION_VERSION != tower_version: self.warn( "You are running collection version {0} but connecting to tower version {1}" .format(self._COLLECTION_VERSION, tower_version)) self.version_checked = True 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 HTTPError as he: try: resp = he.read() except Exception as e: resp = 'unknown {0}'.format(e) self.fail_json(msg='Failed to get token: {0}'.format(he), response=resp) 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 delete_if_needed(self, existing_item, on_delete=None): # This will exit from the module on its own. # If 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 # This 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 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'] elif 'identifier' in existing_item: item_name = existing_item['identifier'] elif item_type == 'o_auth2_access_token': # An oauth2 token has no name, instead we will use its id for any of the messages item_name = existing_item['id'] elif item_type == 'credential_input_source': # An credential_input_source has no name, instead we will use its id for any of the messages item_name = existing_item['id'] 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 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: self.exit_json(**self.json_output) def modify_associations(self, association_endpoint, new_association_list): # if we got None instead of [] we are not modifying the association_list if new_association_list is None: return # First get the existing associations response = self.get_all_endpoint(association_endpoint) existing_associated_ids = [ association['id'] for association in response['json']['results'] ] # Disassociate anything that is in existing_associated_ids but not in new_association_list ids_to_remove = list( set(existing_associated_ids) - set(new_association_list)) for an_id in ids_to_remove: response = self.post_endpoint( association_endpoint, **{'data': { 'id': int(an_id), 'disassociate': True }}) if response['status_code'] == 204: self.json_output['changed'] = True else: self.fail_json(msg="Failed to disassociate item {0}".format( response['json']['detail'])) # Associate anything that is in new_association_list but not in `association` for an_id in list( set(new_association_list) - set(existing_associated_ids)): response = self.post_endpoint(association_endpoint, **{'data': { 'id': int(an_id) }}) if response['status_code'] == 204: self.json_output['changed'] = True else: self.fail_json(msg="Failed to associate item {0}".format( response['json']['detail'])) def create_if_needed(self, existing_item, new_item, endpoint, on_create=None, item_type='unknown', associations=None): # This will exit from the module on its own # If 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 # This 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 if not endpoint: self.fail_json( msg="Unable to create new {0} due to missing endpoint".format( item_type)) item_url = None if existing_item: try: item_url = existing_item['url'] except KeyError as ke: self.fail_json( msg= "Unable to process create of item due to missing data {0}". format(ke)) 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 for key in ('name', 'username', 'identifier', 'hostname'): if key in new_item: item_name = new_item[key] break else: item_name = 'unknown' response = self.post_endpoint(endpoint, **{'data': new_item}) if response['status_code'] == 201: self.json_output['name'] = 'unknown' for key in ('name', 'username', 'identifier', 'hostname'): if key in response['json']: self.json_output['name'] = response['json'][key] self.json_output['id'] = response['json']['id'] self.json_output['changed'] = True item_url = response['json']['url'] 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'])) # Process any associations with this item if associations is not None: for association_type in associations: sub_endpoint = '{0}{1}/'.format(item_url, association_type) self.modify_associations(sub_endpoint, associations[association_type]) # If we have an on_create method and we actually changed something we can call on_create if on_create is not None and self.json_output['changed']: on_create(self, response['json']) else: self.exit_json(**self.json_output) def _encrypted_changed_warning(self, field, old, warning=False): if not warning: return self.warn( 'The field {0} of {1} {2} has encrypted data and may inaccurately report task is changed.' .format(field, old.get('type', 'unknown'), old.get('id', 'unknown'))) @staticmethod def has_encrypted_values(obj): """Returns True if JSON-like python content in obj has $encrypted$ anywhere in the data as a value """ if isinstance(obj, dict): for val in obj.values(): if TowerAPIModule.has_encrypted_values(val): return True elif isinstance(obj, list): for val in obj: if TowerAPIModule.has_encrypted_values(val): return True elif obj == TowerAPIModule.ENCRYPTED_STRING: return True return False def objects_could_be_different(self, old, new, field_set=None, warning=False): if field_set is None: field_set = set(fd for fd in new.keys() if fd not in ('modified', 'related', 'summary_fields')) for field in field_set: new_field = new.get(field, None) old_field = old.get(field, None) if old_field != new_field: return True # Something doesn't match elif self.has_encrypted_values(new_field) or field not in new: # case of 'field not in new' - user password write-only field that API will not display self._encrypted_changed_warning(field, old, warning=warning) return True return False def update_if_needed(self, existing_item, new_item, on_update=None, associations=None): # This will exit from the module on its own # If 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 # This will return one of two 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. # Note: common error codes from the Tower API can cause the module to fail response = None if existing_item: # If we have an item, we can see if it needs an update try: item_url = existing_item['url'] item_type = existing_item['type'] if item_type == 'user': item_name = existing_item['username'] elif item_type == 'workflow_job_template_node': item_name = existing_item['identifier'] elif item_type == 'credential_input_source': item_name = existing_item['id'] else: item_name = existing_item['name'] 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)) # Check to see if anything within the item requires the item to be updated needs_patch = self.objects_could_be_different( existing_item, new_item) # If we decided the item needs to be updated, update it self.json_output['id'] = item_id if needs_patch: response = self.patch_endpoint(item_url, **{'data': new_item}) if response['status_code'] == 200: # compare apples-to-apples, old API data to new API data # but do so considering the fields given in parameters self.json_output[ 'changed'] = self.objects_could_be_different( existing_item, response['json'], field_set=new_item.keys(), warning=True) 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: raise RuntimeError( 'update_if_needed called incorrectly without existing_item') # Process any associations with this item if associations is not None: for association_type, id_list in associations.items(): endpoint = '{0}{1}/'.format(item_url, association_type) self.modify_associations(endpoint, id_list) # If we change something and have an on_change call it if on_update is not None and self.json_output['changed']: if response is None: last_data = existing_item else: last_data = response['json'] on_update(self, last_data) else: self.exit_json(**self.json_output) def create_or_update_if_needed(self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, associations=None): if existing_item: return self.update_if_needed(existing_item, new_item, on_update=on_update, associations=associations) else: return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, associations=associations) def logout(self): if self.authenticated and self.oauth_token_id: # 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), query= None # in error cases, fail_json exists before exception handling )).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 HTTPError as he: try: resp = he.read() except Exception as e: resp = 'unknown {0}'.format(e) self.warn( 'Failed to release tower token: {0}, response: {1}'.format( he, resp)) 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 is_job_done(self, job_status): if job_status in ['new', 'pending', 'waiting', 'running']: return False else: return True
class BorgBaseClient: LOGIN = ''' mutation login( $email: String! $password: String! $otp: String ) { login( username: $email password: $password otp: $otp ) { user { id } } } ''' SSH_LIST = ''' query data { sshList { id name keyData } } ''' SSH_ADD = ''' mutation sshAdd( $name: String! $keyData: String! ) { sshAdd( name: $name keyData: $keyData ) { keyAdded { id name hashMd5 keyType bits } } } ''' SSH_DELETE = ''' mutation sshDelete($id: Int!) { sshDelete(id: $id) { ok } } ''' REPO_LIST = ''' query repoList { repoList { id name quota quotaEnabled alertDays region borgVersion appendOnly appendOnlyKeys fullAccessKeys } } ''' REPO_ADD = ''' mutation repoAdd( $name: String $quota: Int $quotaEnabled: Boolean $appendOnlyKeys: [String] $fullAccessKeys: [String] $alertDays: Int $region: String $borgVersion: String ) { repoAdd( name: $name quota: $quota quotaEnabled: $quotaEnabled appendOnlyKeys: $appendOnlyKeys fullAccessKeys: $fullAccessKeys alertDays: $alertDays region: $region borgVersion: $borgVersion ) { repoAdded { id name } } } ''' REPO_EDIT = ''' mutation repoEdit( $id: String $name: String $quota: Int $quotaEnabled: Boolean $appendOnlyKeys: [String] $fullAccessKeys: [String] $alertDays: Int $region: String $borgVersion: String ) { repoEdit( id: $id name: $name quota: $quota quotaEnabled: $quotaEnabled appendOnlyKeys: $appendOnlyKeys fullAccessKeys: $fullAccessKeys alertDays: $alertDays region: $region borgVersion: $borgVersion ) { repoEdited { id name } } } ''' REPO_DELETE = ''' mutation repoDelete($id: String!) { repoDelete(id: $id) { ok } } ''' def __init__(self, endpoint='https://api.borgbase.com/graphql'): self.endpoint = endpoint self.session = Request() def login(self, **kwargs): return self._send(self.LOGIN, kwargs) def execute(self, query, variables=None): return self._send(query, variables) def _send(self, query, variables): data = {'query': query, 'variables': variables} headers = { 'Accept': 'application/json', 'Content-Type': 'application/json' } request = self.session.open('POST', self.endpoint, data=json.dumps(data), headers=headers) if request.getcode() != 200: raise Exception( "Query failed to run by returning code of {}. {}".format( request.getcode(), query)) return json.loads(request.read())
class Client: def __init__(self, host, username, password, client_id=None, client_secret=None, timeout=None): self.host = host self.username = username self.password = password self.client_id = client_id self.client_secret = client_secret self.timeout = timeout self._auth_header = None self._client = Request() @property def auth_header(self): if not self._auth_header: self._auth_header = self._login() return self._auth_header def _login(self): if self.client_id and self.client_secret: return self._login_oauth() return self._login_username_password() def _login_username_password(self): return dict( Authorization=basic_auth_header(self.username, self.password)) def _login_oauth(self): auth_data = urlencode( dict( grant_type="password", username=self.username, password=self.password, client_id=self.client_id, client_secret=self.client_secret, )) resp = self._request( "POST", "{0}/oauth_token.do".format(self.host), data=auth_data, headers=dict(Accept="application/json"), ) if resp.status != 200: raise UnexpectedAPIResponse(resp.status, resp.data) access_token = resp.json["access_token"] return dict(Authorization="Bearer {0}".format(access_token)) def _request(self, method, path, data=None, headers=None): try: raw_resp = self._client.open(method, path, data=data, headers=headers, timeout=self.timeout) except HTTPError as e: # Wrong username/password, or expired access token if e.code == 401: raise AuthError( "Failed to authenticate with the instance: {0} {1}".format( e.code, e.reason), ) # Other HTTP error codes do not necessarily mean errors. # This is for the caller to decide. return Response(e.code, e.read(), e.headers) except URLError as e: raise ServiceNowError(e.reason) if PY2: return Response(raw_resp.getcode(), raw_resp.read(), raw_resp.info()) return Response(raw_resp.status, raw_resp.read(), raw_resp.headers) def request(self, method, path, query=None, data=None): escaped_path = quote(path.rstrip("/")) url = "{0}/api/now/{1}".format(self.host, escaped_path) if query: url = "{0}?{1}".format(url, urlencode(query)) headers = dict(Accept="application/json", **self.auth_header) if data is not None: data = json.dumps(data, separators=(",", ":")) headers["Content-type"] = "application/json" return self._request(method, url, data=data, headers=headers) def get(self, path, query=None): resp = self.request("GET", path, query=query) if resp.status in (200, 404): return resp raise UnexpectedAPIResponse(resp.status, resp.data) def post(self, path, data, query=None): resp = self.request("POST", path, data=data, query=query) if resp.status == 201: return resp raise UnexpectedAPIResponse(resp.status, resp.data) def patch(self, path, data, query=None): resp = self.request("PATCH", path, data=data, query=query) if resp.status == 200: return resp raise UnexpectedAPIResponse(resp.status, resp.data) def put(self, path, data, query=None): resp = self.request("PUT", path, data=data, query=query) if resp.status == 200: return resp raise UnexpectedAPIResponse(resp.status, resp.data) def delete(self, path, query=None): resp = self.request("DELETE", path, query=query) if resp.status != 204: raise UnexpectedAPIResponse(resp.status, resp.data)
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 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 self.json_output = {'changed': False} super(TowerModule, self).__init__(argument_spec=args, **kwargs) 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'): duplicated_params = [] for direct_field in ('tower_host', 'tower_username', 'tower_password', 'validate_certs', 'tower_oauthtoken'): if self.params.get(direct_field): duplicated_params.append(direct_field) if duplicated_params: self.warn(( 'The parameter(s) {0} were provided at the same time as tower_config_file. ' 'Precedence may be unstable, we suggest either using config file or params.' ).format(', '.join(duplicated_params))) try: # TODO: warn if there are conflicts with other params 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 cannot 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) if 'next' not in response['json']: raise RuntimeError( 'Expected list from API at {0}, got: {1}'.format( endpoint, response)) 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'] response['json']['next'] = next_page 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, on_delete=None): # This will exit from the module on its own. # If 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 # This 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 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 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: self.exit_json(**self.json_output) def modify_associations(self, association_endpoint, new_association_list): # First get the existing associations response = self.get_all_endpoint(association_endpoint) existing_associated_ids = [ association['id'] for association in response['json']['results'] ] # Disassociate anything that is in existing_associated_ids but not in new_association_list ids_to_remove = list( set(existing_associated_ids) - set(new_association_list)) for an_id in ids_to_remove: response = self.post_endpoint( association_endpoint, **{'data': { 'id': int(an_id), 'disassociate': True }}) if response['status_code'] == 204: self.json_output['changed'] = True else: self.fail_json(msg="Failed to disassociate item {0}".format( response['json']['detail'])) # Associate anything that is in new_association_list but not in `association` for an_id in list( set(new_association_list) - set(existing_associated_ids)): response = self.post_endpoint(association_endpoint, **{'data': { 'id': int(an_id) }}) if response['status_code'] == 204: self.json_output['changed'] = True else: self.fail_json(msg="Failed to associate item {0}".format( response['json']['detail'])) def create_if_needed(self, existing_item, new_item, endpoint, on_create=None, item_type='unknown', associations=None): # This will exit from the module on its own # If 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 # This 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 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 create of item due to missing data {0}". format(ke)) 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 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 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'])) # Process any associations with this item if associations is not None: for association_type in associations: self.modify_associations(response, associations[association_type]) # If we have an on_create method and we actually changed something we can call on_create if on_create is not None and self.json_output['changed']: on_create(self, response['json']) else: self.exit_json(**self.json_output) def update_if_needed(self, existing_item, new_item, on_update=None, associations=None): # This will exit from the module on its own # If 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 # This 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 if existing_item: # If we have an item, we can see if it needs an update try: item_url = existing_item['url'] item_type = existing_item['type'] if item_type == 'user': item_name = existing_item['username'] else: item_name = existing_item['name'] 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)) # Check to see if anything within the item requires the item to be updated 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 we decided the item needs to be updated, update it self.json_output['id'] = item_id if needs_update: response = self.patch_endpoint(item_url, **{'data': new_item}) if response['status_code'] == 200: self.json_output['changed'] = True 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: raise RuntimeError( 'update_if_needed called incorrectly without existing_item') # Process any associations with this item if associations is not None: for association_type, id_list in associations.items(): endpoint = '{0}{1}/'.format(item_url, association_type) self.modify_associations(endpoint, id_list) # If we change something and have an on_change call it if on_update is not None and self.json_output['changed']: on_update(self, response['json']) else: self.exit_json(**self.json_output) def create_or_update_if_needed(self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, associations=None): if existing_item: return self.update_if_needed(existing_item, new_item, on_update=on_update, associations=associations) else: return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, associations=associations) 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
class Client: def __init__(self, host, token, timeout=None, validate_certs=True): self.host = host self.token = token self.timeout = timeout self.validate_certs = validate_certs self._auth_header = None self._client = Request() @property def auth_header(self): if not self._auth_header: self._auth_header = self._login() return self._auth_header def _login(self): if self.token: return {"X-API-Token": self.token} def _request(self, method, path, data=None, headers=None): try: raw_resp = self._client.open( method, path, data=data, headers=headers, timeout=self.timeout, validate_certs=self.validate_certs, ) except HTTPError as e: if e.code == 401: raise AuthError( "Failed to authenticate with IPFabric: {0} {1}" " (check token)".format( e.code, e.reason, ), ) elif e.code == 403: raise AuthError( "Insufficient API Rights Check Permissions: " "{0} {1}".format( e.code, e.reason, ), ) return Response(e.code, e.read(), e.headers) except URLError as e: raise IPFabricError(e.reason) return Response(raw_resp.status, raw_resp.read(), raw_resp.headers) def request(self, method, path, query=None, data=None): url = "{0}/api/v1/{1}".format(self.host, path) headers = dict(Accept="application/json", **self.auth_header) if data is not None: data = json.dumps(data, separators=(",", ":")) headers["Content-Type"] = "application/json" return self._request(method, url, data=data, headers=headers) def get(self, path): resp = self.request("GET", path) if resp.status in (200, 404): return resp raise UnexpectedAPIResponse(resp.status, resp.data) def post(self, path, data): resp = self.request("POST", path, data=data) if resp.status in (200, 201): return resp raise UnexpectedAPIResponse(resp.status, resp.data) def get_snapshots(self, snapshot_id=None): resp = self.request("GET", "snapshots") if resp.status == 200: if snapshot_id: single_snapshot = [ snapshot for snapshot in resp.json if snapshot_id == snapshot["id"] # noqa E501 ] if len(single_snapshot) == 0: raise IPFabricError("Snapshot not found.") return single_snapshot return resp.json raise UnexpectedAPIResponse(resp.status, resp.data) def rediscover_existing_snapshot( self, snapshot_id, devices, ): data = {"snList": devices} url = "snapshots/{0}/devices".format(snapshot_id) resp = self.request("POST", url, data=data) return resp def rediscover_new_snapshot(self, ips): data = { "networks": { "include": ["{0}/32".format(ip) for ip in ips], }, "seedList": ips, } resp = self.request("POST", "snapshots", data=data) return resp def create_snapshot(self, snapshot_id=None, devices=None, ips=None): if snapshot_id and devices: resp = self.rediscover_snapshot(snapshot_id, devices) elif ips: resp = self.rediscover_new_snapshot(ips) else: resp = self.request("POST", "snapshots") if resp.status == 200 and resp.json["success"]: time.sleep(1) iterations = 0 snapshot = self.get_snapshots()[0] while snapshot["state"] != "discovering" and iterations >= 10: snapshot = self.get_snapshots()[0]["state"] iterations += 1 return snapshot raise IPFabricError("Failed to create snapshot.") def delete_snapshot(self, snapshot_id): if self.get_snapshots(snapshot_id=snapshot_id): resp = self.request("DELETE", "snapshots/{0}".format(snapshot_id)) if resp.status == 204: return True raise IPFabricError("Snapshot failed to delete.") def snapshot_load(self, snapshot_id, state): if self.get_snapshots(snapshot_id): url = "snapshots/{0}/{1}".format(snapshot_id, state) resp = self.request("POST", url) if resp.status == 204: return resp raise IPFabricError("Snapshot failed to {0}.".format(state))
class BaseApi(object): PLUGIN = None def __init__(self, module, module_name): self._module = module self._module_name = module_name self._es_url = self._module.params.get('elasticsearch_url') self._connect() def put(self, ressource, name=None, data=None): return self._open('PUT', self._url(ressource, name), data=data) def get(self, ressource, name=None): return self._open('GET', self._url(ressource, name)) def patch(self, ressource, name=None, data=None): return self._open('PATCH', self._url(ressource, name), data=data) def delete(self, ressource, name=None): return self._open('DELETE', self._url(ressource, name)) def _connect(self): self.request = Request( headers={'Accept': 'application/json'}, http_agent=self._http_agent(), url_username=self._module.params.get('elasticsearch_user', None), url_password=self._module.params.get('elasticsearch_password', None), client_cert=self._module.params.get('elasticsearch_cert', None), client_key=self._module.params.get('elasticsearch_key', None), ca_path=self._module.params.get('elasticsearch_cacert', None), force_basic_auth=True, validate_certs=self._module.params.get('validate_certs'), ) self._server_info() def _server_info(self): code, data = self._open('GET', '{0}/_nodes/_local/plugins'.format(self._es_url)) if code != 200 or 'nodes' not in data: self._module.fail_json(msg='Error talking to Elasticsearch {0}'.format(self._es_url), http_code=code, http_body=data) self.server = {} def _http_agent(self): return 'ansible-{0}/jiuka.opendistro.{1}'.format(self._module.ansible_version, self._module_name) def _url(self, ressource, name=None): if name: return '{0}/_opendistro/_{1}/api/{2}/{3}'.format(self._es_url, self.PLUGIN, ressource, name) return '{0}/_opendistro/_{1}/api/{2}'.format(self._es_url, self.PLUGIN, ressource) def _open(self, method, url, data=None): headers = None if data: headers = {'Content-Type': 'application/json'} data = json.dumps(data) try: resp = self.request.open(method, url, data=data, headers=headers) code = resp.code body = resp.read() except urllib_error.HTTPError as e: code = e.code try: body = e.read() except AttributeError: body = '' except urllib_error.URLError as e: self._module.fail_json(msg=str(e.reason), method=method, url=url, data=data) try: data = json.loads(body) except Exception: data = body return code, data
class iControlRestSession(object): """Represents a session that communicates with a BigIP. This acts as a loose wrapper around Ansible's ``Request`` class. We're doing this as interim work until we move to the httpapi connector. """ def __init__(self, headers=None, use_proxy=True, force=False, timeout=10, validate_certs=True, url_username=None, url_password=None, http_agent=None, force_basic_auth=False, follow_redirects='urllib2', client_cert=None, client_key=None, cookies=None): self.request = Request(headers=headers, use_proxy=use_proxy, force=force, timeout=timeout, validate_certs=validate_certs, url_username=url_username, url_password=url_password, http_agent=http_agent, force_basic_auth=force_basic_auth, follow_redirects=follow_redirects, client_cert=client_cert, client_key=client_key, cookies=cookies) self.last_url = None def send(self, method, url, **kwargs): response = Response() # Set the last_url called # # This is used by the object destructor to erase the token when the # ModuleManager exits and destroys the iControlRestSession object self.last_url = url body = None data = kwargs.pop('data', None) json = kwargs.pop('json', None) if not data and json is not None: self.request.headers['Content-Type'] = 'application/json' body = _json.dumps(json) if not isinstance(body, bytes): body = body.encode('utf-8') if data: body = data kwargs['data'] = body try: result = self.request.open(method, url, **kwargs) response._content = result.read() response.status = result.getcode() response.url = result.geturl() response.msg = "OK (%s bytes)" % result.headers.get( 'Content-Length', 'unknown') except Exception as e: try: response._content = e.read() response.status_code = e.code except AttributeError: response._content = '' response.status_code = '-1' response.reason = to_native(e) return response def delete(self, url, **kwargs): return self.send('DELETE', url, **kwargs) def get(self, url, **kwargs): return self.send('GET', url, **kwargs) def patch(self, url, data=None, **kwargs): return self.send('PATCH', url, data=data, **kwargs) def post(self, url, data=None, **kwargs): return self.send('POST', url, data=data, **kwargs) def put(self, url, data=None, **kwargs): return self.send('PUT', url, data=data, **kwargs) def __del__(self): if self.last_url is None: return token = self.request.headers.get('X-F5-Auth-Token', None) if not token: return p = generic_urlparse(urlparse(self.last_url)) uri = "https://{0}:{1}/mgmt/shared/authz/tokens/{2}".format( p['hostname'], p['port'], token) self.delete(uri)
class Client: VALID_PREFIXES = "http://", "https://", "unix:///" def __init__(self, endpoint, username, password, verify, ca_path): valid_prefix = any(endpoint.startswith(p) for p in self.VALID_PREFIXES) if not valid_prefix: raise UnitError( "Endpoint should start with one of the following: {0}".format( ", ".join(self.VALID_PREFIXES), )) if endpoint.startswith("unix://"): self._client = Request(unix_socket=endpoint[7:]) self._host = "http://localhost" else: self._client = Request( force_basic_auth=True, validate_certs=verify, ca_path=ca_path, url_username=username, url_password=password, ) self._host = endpoint.rstrip("/") def request(self, method, path, data=None): url = (self._host + "/" + "/".join(quote(s, safe="") for s in path)).rstrip("/") if data is not None: data = json.dumps(data, separators=(",", ":")) try: raw_resp = self._client.open(method=method, url=url, data=data) return Response(raw_resp.getcode(), raw_resp.read()) except HTTPError as e: # This is not an error, since client consumers might be able to # work around/expect non 20x codes. return Response(e.code, e.reason) except URLError as e: raise UnitError("{0} request failed: {1}".format(method, e.reason)) def get(self, path): r = self.request("GET", path) if r.status == 200: return r.json if r.status == 404: return {} raise UnitError("Invalid response: ({0}) - {1}".format( r.status, r.data)) def put(self, path, data): # Any of the parrent sections might be missing at this point, so do # not fail on 404. Instead, incrementally build the payload until we # get a non-404 response back. # # Example: If the following request # # POST /config/listeners/127.0.0.1:80 # {"pass": "******"127.0.0.1:80": {"pass": "******"listeners": {"127.0.0.1:80": {"pass": "******"PUT", path, data) if r.status == 200: # Success, we managed to get our data pushed to the server. return if r.status != 404: # Something bad happened. Stop being smart and bail. raise UnitError("Invalid response: ({0}) - {1}".format( r.status, r.data)) if not path: # We ran out of parent path segments. Bail. raise UnitError( "Ran out of parent path segments. This probaly indicates " "a bug in NGINX Unit Ansible Collection or in the NGINX " "Unit itself. Please file an issue in the collection's " "issue tracker. Thank you in advance for providing as " "much relevant data in the issue as possible ;)") # Missing parent category. Retry push with expanded data on # parent path. data = {path[-1]: data} path = path[:-1] def delete(self, path): r = self.request("DELETE", path) # Yes, unit returns 200 on DELETE ... if r.status != 200: raise UnitError("Invalid response: ({0}) - {1}".format( r.status, r.data))
class vsz_api: api_endpoint_url = '' __api_user = '' __api_password = '' __request = None __ignore_ssl_validation = True __ansible_module = None def __init__(self, server, user, password, server_port=8443, use_ssl=True, ignore_ssl_validation=True): # Set api endpoint url if use_ssl: self.api_endpoint_url = self.api_endpoint_url + 'https://' else: self.api_endpoint_url = self.api_endpoint_url + 'http://' self.__api_password = password self.__api_user = user self.__ignore_ssl_validation = ignore_ssl_validation self.__request = Request() api_fetch_result, api_version_string = self.get_latest_api_version if not api_fetch_result: # TODO:: What we do if we got an error on the API Request # should we throw exception, but then it will not be pretty else: self.api_endpoint_url = self.api_endpoint_url + \ server + ':' + str(server_port) + "/wsg/api/public/" + api_version_string def get_latest_api_version(self): apiInfo_result, apiInfo_response = self.__api_call('GET', '/wsg/api/public/apiInfo') if apiInfo_result: current_version_string = "" current_version_number = 0 for version in apiInfo_response.apiSupportVersion: if current_version_number == 0: current_version_string = version current_version_number = version.split('_')[0].replace('v','') else: version_number = version.split('_')[0].replace('v','') if version_number > current_version_number: current_version_string = version current_version_number = version_number return True, current_version_string else: # Error on apiInfo request return False, apiInfo_response def get_domain_id(self, domain_name): fetch_domain_result, data = self.get_domains() if not fetch_domain_result: return False, data for domain in data: if domain_name == domain['name']: return True, domain['id'] # No domain found return False, "domain with name '" + domain_name + "' not found!" def get_domains(self): return self.__return_api_list( *(self.__api_call('GET','/domains?listSize=9999&recusively=True')) ) def get_rkszone_id(self, zone_name, domain_id): fetch_zone_result, data = self.get_rkszones(domain_id) if not fetch_zone_result: return False, data for zone in data: if zone_name == zone['name']: return True, zone['id'] # Zone not found return False, "zone with name '" + zone_name + "' not found!" def get_rkszones(self, domain_id): return self.__return_api_list( *(self.__api_call('GET','/rkszones?listSize=9999&domainId=' + domain_id)) ) def set_wlan_encryption(self, zone_id, wlan_id, wlan_encryption_settings): data = { "method" : wlan_encryption_settings['method'], "passphrase" : wlan_encryption_settings['passphrase'] } return self.__api_call('PATCH','/rkszones/' + zone_id + '/wlans/' + wlan_id + '/encryption', data=json.dumps(data)) def get_wlan_settings(self, zone_id, wlan_id): result, data = self.__api_call('GET','/rkszones/' + zone_id + '/wlans/' + wlan_id) return result, data def get_wlan_id(self, zone_id, wlan_name): fetch_wlan_result, data = self.get_wlans(zone_id) if not fetch_wlan_result: return False, data for wlan in data: if wlan_name == wlan['name']: return True, wlan['id'] # Wlan not found return False, "wlan with name '" + wlan_name + "' not found!" def get_wlans(self, zone_id): return self.__return_api_list( *(self.__api_call('GET','/rkszones/' + zone_id + '/wlans?listSize=99999')) ) def login(self): data = dict( username=self.__api_user, password=self.__api_password, timeZoneUtcOffset="+01:00" ) return self.__api_call( 'POST','/session', data=json.dumps(data), ) def logout(self): return self.__api_call( 'DELETE','/session' ) # return list object of json response or pass error def __return_api_list(self,result,data): if not result: return result, data return result, data['list'] # execute api calls def __api_call(self, http_method, api_method, data=None): if(type(data) is dict): data = json.dumps(data) try: request_result = self.__request.open( http_method, (self.api_endpoint_url + api_method), data = data, headers= { "Content-Type": "application/json", "Accept":"application/json" }, validate_certs = not self.__ignore_ssl_validation ) except urllib2.HTTPError as err: return False, err.msg except urllib2.URLError as err: return False, err.reason.strerror except SSLValidationError as err: return False, err.message except: return False, "unknown urllib2 exception" data = request_result.read() if data: return True, json.loads(data) return True, request_result.msg
class TowerRestClient: def __init__(self, address, username, password, validate_certs=False, force_basic_auth=True): self._address = address self._username = username self._password = password self._validate_certs = validate_certs self._force_basic_auth = force_basic_auth self._headers = {} self._client = Request() def _request(self, method, path, payload=None): headers = self._headers.copy() data = None if payload: data = json.dumps(payload) headers["Content-Type"] = "application/json" url = self._address + path try: r = self._client.open(method, url, data=data, headers=headers, validate_certs=self._validate_certs, url_username=self._username, url_password=self._password, force_basic_auth=self._force_basic_auth) r_status = r.getcode() r_headers = dict(r.headers) data = r.read().decode("utf-8") r_data = json.loads(data) if data else {} except HTTPError as e: r_status = e.code r_headers = {} r_data = dict(msg=str(e.reason)) except (ConnectionError, URLError) as e: raise AnsibleConnectionFailure( "Could not connect to {0}: {1}".format(url, e.reason)) return r_status, r_headers, r_data def get(self, path): return self._request("GET", path) def post(self, path, payload=None): return self._request("POST", path, payload) def patch(self, path, payload=None): return self._request("PATCH", path, payload) def delete(self, path): return self._request("DELETE", path) def asset_exists(self, asset_name, asset_type): _status, _headers, _data = self.get('/api/v2/{}/?name={}'.format( asset_type, asset_name)) if _data.get('count') != 0: return _data else: return None # EXPORTS def _export_project(self, name, resolve_dependencies): _status, _headers, _data = self.get( '/api/v2/projects/?name={}'.format(name)) # Check HTTP status code for request if _status != 200: raise TowerConnectionError( "Ansible expected an HTTP response code of {} but got {}". format(200, _status)) else: # Check if we found an asset matching provided name if _data["count"] == 0: raise TowerResourceNotFound( "Project named {} was not found".format(name)) else: # Store local copy of project json project = _data["results"][0] # Remove fields that cannot be imported for field in PROJECT_FIELDS_TO_REMOVE: del (project[field]) return project def _export_job_template(self, name, resolve_dependencies): pass def _export_workflow_job_template(self, name, resolve_dependencies): pass def export_asset(self, asset_type, asset_name, resolve_dependencies): if asset_type == "project": return self._export_project(asset_name, resolve_dependencies) elif asset_type == "job_template": return self._export_job_template(asset_name, resolve_dependencies) elif asset_type == "workflow_job_template": return self._export_workflow_job_template(asset_name, resolve_dependencies) # IMPORTS def _import_project(self, project, update_asset): result = dict(imported=False, request=None) success_codes = [200, 201, 202, 203] asset_exists = self.asset_exists(project["name"], 'projects') if asset_exists: asset_id = asset_exists["results"][0]["id"] if update_asset: _status, _headers, _data = self.patch( '/api/v2/projects/{}/'.format(asset_id), project) else: # raise error that asset already exists, try using update_asset if you want to override it raise TowerAssetExists( message= "An asset named '{}' already exists. If you'd like to overwrite this assets variables, set 'update_asset: true' in your playbook" .format(project["name"])) else: _status, _headers, _data = self.post('/api/v2/projects/', project) result["request"] = _status, _headers, _data if _status in success_codes: # Successful import result["imported"] = True return result else: return result # return error here def import_asset(self, asset, update_asset): if asset["type"] == "project": return self._import_project(asset, update_asset)
class AHModule(AnsibleModule): url = None session = None AUTH_ARGSPEC = dict( ah_host=dict(required=False, aliases=["ah_hostname"], fallback=(env_fallback, ["AH_HOST"])), ah_username=dict(required=False, fallback=(env_fallback, ["AH_USERNAME"])), ah_password=dict(no_log=True, required=False, fallback=(env_fallback, ["AH_PASSWORD"])), ah_path_prefix=dict(required=False, fallback=(env_fallback, ["GALAXY_API_PATH_PREFIX"])), validate_certs=dict(type="bool", aliases=["ah_verify_ssl"], required=False, fallback=(env_fallback, ["AH_VERIFY_SSL"])), ah_token=dict(type="raw", no_log=True, required=False, fallback=(env_fallback, ["AH_API_TOKEN"])), ) ENCRYPTED_STRING = "$encrypted$" short_params = { "host": "ah_host", "username": "******", "password": "******", "verify_ssl": "validate_certs", "path_prefix": "ah_path_prefix", "oauth_token": "ah_token", } IDENTITY_FIELDS = {} ENCRYPTED_STRING = "$encrypted$" host = "127.0.0.1" path_prefix = "galaxy" username = None password = None verify_ssl = True oauth_token = None authenticated = False error_callback = None warn_callback = None def __init__(self, argument_spec=None, direct_params=None, error_callback=None, warn_callback=None, require_auth=True, **kwargs): full_argspec = {} if require_auth: full_argspec.update(AHModule.AUTH_ARGSPEC) full_argspec.update(argument_spec) kwargs["supports_check_mode"] = True self.error_callback = error_callback self.warn_callback = warn_callback self.json_output = {"changed": False} if direct_params is not None: self.params = direct_params # else: super(AHModule, self).__init__(argument_spec=full_argspec, **kwargs) self.session = Request(cookies=CookieJar(), validate_certs=self.verify_ssl) # Parameters specified on command line will override settings in any config for short_param, long_param in self.short_params.items(): direct_value = self.params.get(long_param) if direct_value is not None: setattr(self, short_param, direct_value) # Perform magic depending on whether ah_token is a string or a dict if self.params.get("ah_token"): token_param = self.params.get("ah_token") if type(token_param) is dict: if "token" in token_param: self.oauth_token = self.params.get("ah_token")["token"] else: self.fail_json(msg="The provided dict in ah_token did not properly contain the token entry") elif isinstance(token_param, string_types): self.oauth_token = self.params.get("ah_token") else: error_msg = "The provided ah_token type was not valid ({0}). Valid options are str or dict.".format(type(token_param).__name__) self.fail_json(msg=error_msg) # 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 ah 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 ah host ({1}): {0}".format(hostname, e)) if "update_secrets" in self.params: self.update_secrets = self.params.pop("update_secrets") else: self.update_secrets = True def build_url(self, endpoint, query_params=None): # Make sure we start with /api/vX if not endpoint.startswith("/"): endpoint = "/{0}".format(endpoint) if not endpoint.startswith("/api/"): if self.path_prefix == "galaxy": endpoint = "api/galaxy/v3{0}".format(endpoint) elif self.path_prefix == "galaxy": endpoint = "api/automation-hub/v3{0}".format(endpoint) else: endpoint = "api/{0}/v3{1}".format(self.path_prefix, endpoint) if not endpoint.endswith("/") and "?" not in endpoint: endpoint = "{0}/".format(endpoint) # Update the URL path with the endpoint url = self.url._replace(path=endpoint) if query_params: url = url._replace(query=urlencode(query_params)) return url def fail_json(self, **kwargs): # Try to log out if we are authenticated if self.error_callback: self.error_callback(**kwargs) else: super(AHModule, self).fail_json(**kwargs) def exit_json(self, **kwargs): # Try to log out if we are authenticated super(AHModule, self).exit_json(**kwargs) def warn(self, warning): if self.warn_callback is not None: self.warn_callback(warning) else: super(AHModule, self).warn(warning) @staticmethod def get_name_field_from_endpoint(endpoint): return AHModule.IDENTITY_FIELDS.get(endpoint, "name") def get_endpoint(self, endpoint, *args, **kwargs): return self.make_request("GET", endpoint, **kwargs) 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") # Extract the headers, this will be used in a couple of places headers = kwargs.get("headers", {}) # Authenticate to Automation Hub (if we don't have a token and if not already done so) if not self.oauth_token and not self.authenticated: # This method will set a cookie in the cookie jar for us and also an oauth_token self.authenticate(**kwargs) if self.oauth_token: # If we have a oauth token, we just use a bearer header headers["Authorization"] = "Token {0}".format(self.oauth_token) if method in ["POST", "PUT", "PATCH"]: headers.setdefault("Content-Type", "application/json") kwargs["headers"] = headers url = self.build_url(endpoint) else: url = self.build_url(endpoint, query_params=kwargs.get("data")) data = None # Important, if content type is not JSON, this should not be dict type if headers.get("Content-Type", "") == "application/json": data = dumps(kwargs.get("data", {})) elif kwargs.get("binary", False): data = kwargs.get("data", None) try: response = self.session.open(method, url.geturl(), headers=headers, validate_certs=self.verify_ssl, follow_redirects=True, data=data) except (SSLValidationError) as ssl_err: self.fail_json(msg="Could not establish a secure connection to your host ({1}): {0}.".format(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(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(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 Automation Hub authentication credentials for {0} (HTTP 401).".format(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(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 if kwargs.get("return_errors_on_404", False): 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} self.fail_json(msg="The requested object could not be found at {0}.".format(url.path), response=he) # 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 Automation Hub server says you can't make a request with the {0} method to this endpoing {1}".format(method, 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(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, 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 get_one(self, endpoint, name_or_id=None, allow_none=True, **kwargs): new_kwargs = kwargs.copy() if name_or_id: name_field = self.get_name_field_from_endpoint(endpoint) new_data = kwargs.get("data", {}).copy() if name_field in new_data: self.fail_json(msg="You can't specify the field {0} in your search data if using the name_or_id field".format(name_field)) try: new_data["or__id"] = int(name_or_id) new_data["or__{0}".format(name_field)] = name_or_id except ValueError: # If we get a value error, then we didn't have an integer so we can just pass and fall down to the fail new_data[name_field] = name_or_id new_kwargs["data"] = new_data response = self.get_endpoint(endpoint, **new_kwargs) if response["status_code"] != 200: fail_msg = "Got a {0} response when trying to get one from {1}".format(response["status_code"], endpoint) if "detail" in response.get("json", {}): fail_msg += ", detail: {0}".format(response["json"]["detail"]) self.fail_json(msg=fail_msg) if "count" not in response["json"]["meta"] or "data" not in response["json"]: self.fail_json(msg="The endpoint did not provide count and results.") if response["json"]["meta"]["count"] == 0: if allow_none: return None else: self.fail_wanted_one(response, endpoint, new_kwargs.get("data")) elif response["json"]["meta"]["count"] > 1: if name_or_id: # Since we did a name or ID search and got > 1 return something if the id matches for asset in response["json"]["data"]: if str(asset["id"]) == name_or_id: return self.existing_item_add_url(asset, endpoint) # We got > 1 and either didn't find something by ID (which means multiple names) # Or we weren't running with a or search and just got back too many to begin with. self.fail_wanted_one(response, endpoint, new_kwargs.get("data")) return self.existing_item_add_url(response["json"]["data"][0], endpoint) def get_only(self, endpoint, name_or_id=None, allow_none=True, key="url", **kwargs): new_kwargs = kwargs.copy() if name_or_id: name_field = self.get_name_field_from_endpoint(endpoint) new_data = kwargs.get("data", {}).copy() if name_field in new_data: self.fail_json(msg="You can't specify the field {0} in your search data if using the name_or_id field".format(name_field)) try: new_data["or__id"] = int(name_or_id) new_data["or__{0}".format(name_field)] = name_or_id except ValueError: # If we get a value error, then we didn't have an integer so we can just pass and fall down to the fail new_data[name_field] = name_or_id new_kwargs["data"] = new_data response = self.get_endpoint(endpoint, **new_kwargs) if response["status_code"] != 200: fail_msg = "Got a {0} response when trying to get from {1}".format(response["status_code"], endpoint) if "detail" in response.get("json", {}): fail_msg += ", detail: {0}".format(response["json"]["detail"]) self.fail_json(msg=fail_msg) return self.existing_item_add_url(response["json"], endpoint, key=key) def authenticate(self, **kwargs): if self.username and self.password: # Attempt to get a token from /v3/auth/token/ by giving it our username/password combo # If we have a username and password, we need to get a session cookie # Post to the tokens endpoint with baisc auth to try and get a token if self.path_prefix == "galaxy": api_token_url = (self.url._replace(path="/api/galaxy/v3/auth/token/")).geturl() elif self.path_prefix == "automation-hub": api_token_url = (self.url._replace(path="/api/automation-hub/v3/auth/token/")).geturl() else: token_path = "api/{0}/v3/auth/token/".format(self.path_prefix) api_token_url = (self.url._replace(path=token_path)).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, headers={"Content-Type": "application/json"}, ) except HTTPError as he: try: resp = he.read() except Exception as e: resp = "unknown {0}".format(e) self.fail_json(msg="Failed to get token: {0}".format(he), response=resp) 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 = 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 existing_item_add_url(self, existing_item, endpoint, key="url"): # Add url and type to response as its missing in current iteration of Automation Hub. existing_item[key] = "{0}{1}/".format(self.build_url(endpoint).geturl()[len(self.host) :], existing_item["name"]) existing_item["type"] = endpoint return existing_item def delete_if_needed(self, existing_item, on_delete=None, auto_exit=True): # This will exit from the module on its own. # If 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 # This 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 Automation Hub 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 Automation Hub API can cause the module to fail if existing_item: if existing_item["type"] == "token": response = self.delete_endpoint(existing_item["endpoint"]) elif 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"] item_name = self.get_item_name(existing_item, allow_unknown=True) except KeyError as ke: self.fail_json(msg="Unable to process delete of item due to missing data {0}".format(ke)) response = self.delete_endpoint(item_url) else: if auto_exit: self.exit_json(**self.json_output) else: return self.json_output if response["status_code"] in [202, 204]: if on_delete: on_delete(self, response["json"]) self.json_output["changed"] = True if existing_item["type"] == "token": self.json_output["msg"] = "Token Revoked" self.exit_json(**self.json_output) else: self.json_output["id"] = item_id self.exit_json(**self.json_output) if auto_exit: self.exit_json(**self.json_output) else: return 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"])) def get_item_name(self, item, allow_unknown=False): if item: if "name" in item: return item["name"] if allow_unknown: return "unknown" if item: self.exit_json(msg="Cannot determine identity field for {0} object.".format(item.get("type", "unknown"))) else: self.exit_json(msg="Cannot determine identity field for Undefined object.") 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 create_or_update_if_needed( self, existing_item, new_item, endpoint=None, item_type="unknown", on_create=None, on_update=None, auto_exit=True, associations=None, require_id=True, fixed_url=None, ): if existing_item: return self.update_if_needed( existing_item, new_item, on_update=on_update, auto_exit=auto_exit, associations=associations, require_id=require_id, fixed_url=fixed_url ) else: return self.create_if_needed( existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, auto_exit=auto_exit, associations=associations ) def create_if_needed(self, existing_item, new_item, endpoint, on_create=None, auto_exit=True, item_type="unknown", associations=None): # This will exit from the module on its own # If 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 # This 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 Automation Hub 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 Automation Hub API can cause the module to fail if not endpoint: self.fail_json(msg="Unable to create new {0} due to missing endpoint".format(item_type)) item_url = None if existing_item: try: item_url = existing_item["url"] except KeyError as ke: self.fail_json(msg="Unable to process create of item due to missing data {0}".format(ke)) 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 = self.get_item_name(new_item, allow_unknown=True) response = self.post_endpoint(endpoint, **{"data": new_item}) if response["status_code"] in [200, 201]: self.json_output["name"] = "unknown" for key in ("name", "username", "identifier", "hostname"): if key in response["json"]: self.json_output["name"] = response["json"][key] if item_type != "token": self.json_output["id"] = response["json"]["id"] item_url = "{0}{1}/".format(self.build_url(endpoint).geturl()[len(self.host) :], new_item["name"]) self.json_output["changed"] = True 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"])) # Process any associations with this item if associations is not None: for association_type in associations: sub_endpoint = "{0}{1}/".format(item_url, association_type) self.modify_associations(sub_endpoint, associations[association_type]) # If we have an on_create method and we actually changed something we can call on_create if on_create is not None and self.json_output["changed"]: on_create(self, response["json"]) elif auto_exit: self.exit_json(**self.json_output) else: last_data = response["json"] return last_data def approve(self, endpoint, auto_exit=True): approvalEndpoint = "move/staging/published" if not endpoint: self.fail_json(msg="Unable to approve due to missing endpoint") response = self.post_endpoint("{0}/{1}".format(endpoint, approvalEndpoint), None, **{"return_none_on_404": True}) i = 0 while i < 5: if not response: time.sleep(1) response = self.post_endpoint("{0}/{1}".format(endpoint, approvalEndpoint), None, **{"return_none_on_404": True}) i += 1 else: break if response and response["status_code"] in [202]: self.json_output["changed"] = True else: # Do a check to see if the version exists if not response: self.fail_json(msg="Unable to approve at {0}: Awaiting approval not found".format(endpoint)) elif "json" in response and "__all__" in response["json"]: self.fail_json(msg="Unable to approve at {0}: {1}".format(endpoint, response["json"]["__all__"][0])) elif "json" in response: self.fail_json(msg="Unable to create {0}: {1}".format(endpoint, response["json"])) else: self.fail_json(msg="Unable to create {0}: {1}".format(endpoint, response["status_code"])) if auto_exit: self.exit_json(**self.json_output) else: last_data = response["json"] return last_data def prepare_multipart(self, filename): mime = "application/x-gzip" m = email.mime.multipart.MIMEMultipart("form-data") main_type, sep, sub_type = mime.partition("/") with open(to_bytes(filename, errors="surrogate_or_strict"), "rb") as f: part = email.mime.application.MIMEApplication(f.read()) del part["Content-Type"] part.add_header("Content-Type", "%s/%s" % (main_type, sub_type)) part.add_header("Content-Disposition", "form-data") del part["MIME-Version"] part.set_param("name", "file", header="Content-Disposition") if filename: part.set_param("filename", to_native(os.path.basename(filename)), header="Content-Disposition") m.attach(part) if PY3: # Ensure headers are not split over multiple lines # The HTTP policy also uses CRLF by default b_data = m.as_bytes(policy=email.policy.HTTP) else: # Py2 # We cannot just call ``as_string`` since it provides no way # to specify ``maxheaderlen`` # cStringIO seems to be required here fp = cStringIO() # noqa: F821 # Ensure headers are not split over multiple lines g = email.generator.Generator(fp, maxheaderlen=0) g.flatten(m) # ``fix_eols`` switches from ``\n`` to ``\r\n`` b_data = email.utils.fix_eols(fp.getvalue()) del m headers, sep, b_content = b_data.partition(b"\r\n\r\n") del b_data if PY3: parser = email.parser.BytesHeaderParser().parsebytes else: # Py2 parser = email.parser.HeaderParser().parsestr return (parser(headers)["content-type"], b_content) # Message converts to native strings def getFileContent(self, path): with open(to_bytes(path, errors="surrogate_or_strict"), "rb") as f: b_file_data = f.read() return to_text(b_file_data) def wait_for_complete(self, task_url): endpoint = task_url state = "running" while state == "running": response = self.get_endpoint(endpoint) state = response["json"]["state"] time.sleep(1) self.json_output["state"] = state if state == "failed": self.fail_json(msg="Upload of collection failed: {0}".format(response["json"]["error"]["description"])) else: time.sleep(1) return def upload(self, path, endpoint, wait=True, item_type="unknown"): if "://" in path: tmppath = fetch_file(self, path) path = ".".join(tmppath.split(".")[:-2]) + ".tar.gz" os.rename(tmppath, path) self.add_cleanup_file(path) ct, body = self.prepare_multipart(path) response = self.make_request("POST", endpoint, **{"data": body, "headers": {"Content-Type": str(ct)}, "binary": True, "return_errors_on_404": True}) if response["status_code"] in [202]: self.json_output["path"] = path self.json_output["changed"] = True if wait: self.wait_for_complete(response["json"]["task"]) return else: if "json" in response and "__all__" in response["json"]: self.fail_json(msg="Unable to create {0} from {1}: {2}".format(item_type, path, response["json"]["__all__"][0])) elif "json" in response and "errors" in response["json"] and "detail" in response["json"]["errors"][0]: self.fail_json(msg="Unable to create {0} from {1}: {2}".format(item_type, path, response["json"]["errors"][0]["detail"])) elif "json" in response: self.fail_json(msg="Unable to create {0} from {1}: {2}".format(item_type, path, response["json"])) else: self.fail_json(msg="Unable to create {0} from {1}: {2}".format(item_type, path, response["status_code"])) def update_if_needed(self, existing_item, new_item, on_update=None, auto_exit=True, associations=None, require_id=True, fixed_url=None): # This will exit from the module on its own # If 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 # This will return one of two things: # 1. None if the existing_item does not need to be updated # 2. The response from Automation Hub from patching to the endpoint. It's up to you to process the response and exit from the module. # Note: common error codes from the Automation Hub API can cause the module to fail response = None if existing_item: # If we have an item, we can see if it needs an update try: item_url = fixed_url or existing_item["url"] item_type = existing_item["type"] item_name = existing_item["name"] item_id = require_id and existing_item["id"] except KeyError as ke: self.fail_json(msg="Unable to process update of item due to missing data {0}".format(ke)) # Check to see if anything within the item requires the item to be updated needs_patch = self.objects_could_be_different(existing_item, new_item) # If we decided the item needs to be updated, update it self.json_output["id"] = item_id self.json_output["name"] = item_name self.json_output["type"] = item_type if needs_patch: response = self.put_endpoint(item_url, **{"data": new_item}) if response["status_code"] == 200: # compare apples-to-apples, old API data to new API data # but do so considering the fields given in parameters self.json_output["changed"] = self.objects_could_be_different(existing_item, response["json"], field_set=new_item.keys(), warning=True) 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, "input": new_item}) else: raise RuntimeError("update_if_needed called incorrectly without existing_item") # Process any associations with this item if associations is not None: for association_type, id_list in associations.items(): endpoint = "{0}{1}/".format(item_url, association_type) self.modify_associations(endpoint, id_list) # If we change something and have an on_change call it if on_update is not None and self.json_output["changed"]: if response is None: last_data = existing_item else: last_data = response["json"] on_update(self, last_data) elif auto_exit: self.exit_json(**self.json_output) else: if response is None: last_data = existing_item else: last_data = response["json"] return last_data def modify_associations(self, association_endpoint, new_association_list): # if we got None instead of [] we are not modifying the association_list if new_association_list is None: return # First get the existing associations response = self.get_all_endpoint(association_endpoint) existing_associated_ids = [association["id"] for association in response["json"]["results"]] # Disassociate anything that is in existing_associated_ids but not in new_association_list ids_to_remove = list(set(existing_associated_ids) - set(new_association_list)) for an_id in ids_to_remove: response = self.post_endpoint(association_endpoint, **{"data": {"id": int(an_id), "disassociate": True}}) if response["status_code"] == 204: self.json_output["changed"] = True else: self.fail_json(msg="Failed to disassociate item {0}".format(response["json"].get("detail", response["json"]))) # Associate anything that is in new_association_list but not in `association` for an_id in list(set(new_association_list) - set(existing_associated_ids)): response = self.post_endpoint(association_endpoint, **{"data": {"id": int(an_id)}}) if response["status_code"] == 204: self.json_output["changed"] = True else: self.fail_json(msg="Failed to associate item {0}".format(response["json"].get("detail", response["json"]))) 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 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 put_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("PUT", endpoint, **kwargs) def get_all_endpoint(self, endpoint, *args, **kwargs): response = self.get_endpoint(endpoint, *args, **kwargs) if "next" not in response["json"]: raise RuntimeError("Expected list from API at {0}, got: {1}".format(endpoint, response)) 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"] response["json"]["next"] = next_page return response def fail_wanted_one(self, response, endpoint, query_params): sample = response.copy() if len(sample["json"]["data"]) > 1: sample["json"]["data"] = sample["json"]["data"][:2] + ["...more results snipped..."] url = self.build_url(endpoint, query_params) display_endpoint = url.geturl()[len(self.host) :] # truncate to not include the base URL self.fail_json( msg="Request to {0} returned {1} items, expected 1".format(display_endpoint, response["json"]["meta"]["count"]), query=query_params, response=sample, total_results=response["json"]["meta"]["count"], ) def get_exactly_one(self, endpoint, name_or_id=None, **kwargs): return self.get_one(endpoint, name_or_id=name_or_id, allow_none=False, **kwargs) def resolve_name_to_id(self, endpoint, name_or_id): return self.get_exactly_one(endpoint, name_or_id)["id"] def objects_could_be_different(self, old, new, field_set=None, warning=False): if field_set is None: field_set = set(fd for fd in new.keys() if fd not in ("modified", "related", "summary_fields")) for field in field_set: new_field = new.get(field, None) old_field = old.get(field, None) if old_field != new_field: if self.update_secrets or (not self.fields_could_be_same(old_field, new_field)): return True # Something doesn't match, or something might not match elif self.has_encrypted_values(new_field) or field not in new: if self.update_secrets or (not self.fields_could_be_same(old_field, new_field)): # case of 'field not in new' - user password write-only field that API will not display self._encrypted_changed_warning(field, old, warning=warning) return True return False def execute_build(self, path, force, output_path): path = self._resolve_path(path) output_path = self._resolve_path(output_path) b_output_path = to_bytes(output_path, errors="surrogate_or_strict") if not os.path.exists(b_output_path): os.makedirs(b_output_path) elif os.path.isfile(b_output_path): self.fail_json(msg="the output collection directory {0} is a file - aborting".format(to_native(output_path))) output_build = self.run_command(["ansible-galaxy", "collection", "build", path, "--output-path", output_path, (None, "--force")[force]]) if output_build[0] == 0: self.json_output["path"] = "/" + "/".join(output_build[1].split("/")[1:])[:-1] self.json_output["changed"] = True self.exit_json(**self.json_output) else: self.fail_json(msg=output_build[2]) def wait_sync_output(self, response): for k in ("task_id", "state", "started_at", "finished_at"): self.json_output[k] = response["last_sync_task"].get(k) @staticmethod def _resolve_path(path): return os.path.abspath(os.path.expanduser(os.path.expandvars(path))) @staticmethod def has_encrypted_values(obj): """Returns True if JSON-like python content in obj has $encrypted$ anywhere in the data as a value """ if isinstance(obj, dict): for val in obj.values(): if AHModule.has_encrypted_values(val): return True elif isinstance(obj, list): for val in obj: if AHModule.has_encrypted_values(val): return True elif obj == AHModule.ENCRYPTED_STRING: return True return False def _encrypted_changed_warning(self, field, old, warning=False): if not warning: return self.warn( "The field {0} of {1} {2} has encrypted data and may inaccurately report task is changed.".format( field, old.get("type", "unknown"), old.get("id", "unknown") ) )
class Client: def __init__( self, host, username=None, password=None, grant_type=None, refresh_token=None, client_id=None, client_secret=None, timeout=None, ): if not (host or "").startswith(("https://", "http://")): raise ServiceNowError( "Invalid instance host value: '{0}'. " "Value must start with 'https://' or 'http://'".format(host)) self.host = host self.username = username self.password = password self.grant_type = grant_type self.client_id = client_id self.client_secret = client_secret self.refresh_token = refresh_token self.timeout = timeout self._auth_header = None self._client = Request() @property def auth_header(self): if not self._auth_header: self._auth_header = self._login() return self._auth_header def _login(self): if self.client_id and self.client_secret: return self._login_oauth() return self._login_username_password() def _login_username_password(self): return dict( Authorization=basic_auth_header(self.username, self.password)) def _login_oauth(self): if self.grant_type == "refresh_token": auth_data = urlencode( dict( grant_type=self.grant_type, refresh_token=self.refresh_token, client_id=self.client_id, client_secret=self.client_secret, )) # Only other possible value for grant_type is "password" else: auth_data = urlencode( dict( grant_type=self.grant_type, username=self.username, password=self.password, client_id=self.client_id, client_secret=self.client_secret, )) resp = self._request( "POST", "{0}/oauth_token.do".format(self.host), data=auth_data, headers=dict(Accept="application/json"), ) if resp.status != 200: raise UnexpectedAPIResponse(resp.status, resp.data) access_token = resp.json["access_token"] return dict(Authorization="Bearer {0}".format(access_token)) def _request(self, method, path, data=None, headers=None): try: raw_resp = self._client.open(method, path, data=data, headers=headers, timeout=self.timeout) except HTTPError as e: # Wrong username/password, or expired access token if e.code == 401: raise AuthError( "Failed to authenticate with the instance: {0} {1}".format( e.code, e.reason), ) # Other HTTP error codes do not necessarily mean errors. # This is for the caller to decide. return Response(e.code, e.read(), e.headers) except URLError as e: raise ServiceNowError(e.reason) if PY2: return Response(raw_resp.getcode(), raw_resp.read(), raw_resp.info()) return Response(raw_resp.status, raw_resp.read(), raw_resp.headers) def request(self, method, path, query=None, data=None, headers=None, bytes=None): # Make sure we only have one kind of payload if data is not None and bytes is not None: raise AssertionError( "Cannot have JSON and binary payload in a single request.") escaped_path = quote(path.strip("/")) if escaped_path: escaped_path = "/" + escaped_path url = "{0}{1}".format(self.host, escaped_path) if query: url = "{0}?{1}".format(url, urlencode(query)) headers = dict(headers or DEFAULT_HEADERS, **self.auth_header) if data is not None: data = json.dumps(data, separators=(",", ":")) headers["Content-type"] = "application/json" elif bytes is not None: data = bytes return self._request(method, url, data=data, headers=headers) def get(self, path, query=None): resp = self.request("GET", path, query=query) if resp.status in (200, 404): return resp raise UnexpectedAPIResponse(resp.status, resp.data) def post(self, path, data, query=None): resp = self.request("POST", path, data=data, query=query) if resp.status == 201: return resp raise UnexpectedAPIResponse(resp.status, resp.data) def patch(self, path, data, query=None): resp = self.request("PATCH", path, data=data, query=query) if resp.status == 200: return resp raise UnexpectedAPIResponse(resp.status, resp.data) def put(self, path, data, query=None): resp = self.request("PUT", path, data=data, query=query) if resp.status == 200: return resp raise UnexpectedAPIResponse(resp.status, resp.data) def delete(self, path, query=None): resp = self.request("DELETE", path, query=query) if resp.status != 204: raise UnexpectedAPIResponse(resp.status, resp.data)
class TowerAPIModule(TowerModule): # TODO: Move the collection version check into tower_module.py # This gets set by the make process so whatever is in here is irrelevant _COLLECTION_VERSION = "0.0.1-devel" _COLLECTION_TYPE = "awx" # This maps the collections type (awx/tower) to the values returned by the API # Those values can be found in awx/api/generics.py line 204 collection_to_version = { 'awx': 'AWX', 'tower': 'Red Hat Ansible Tower', } session = None IDENTITY_FIELDS = { 'users': 'username', 'workflow_job_template_nodes': 'identifier', 'instances': 'hostname' } ENCRYPTED_STRING = "$encrypted$" def __init__(self, argument_spec, direct_params=None, error_callback=None, warn_callback=None, **kwargs): kwargs['supports_check_mode'] = True super(TowerAPIModule, self).__init__(argument_spec=argument_spec, direct_params=direct_params, error_callback=error_callback, warn_callback=warn_callback, **kwargs) self.session = Request(cookies=CookieJar(), validate_certs=self.verify_ssl) if 'update_secrets' in self.params: self.update_secrets = self.params.pop('update_secrets') else: self.update_secrets = True @staticmethod def param_to_endpoint(name): exceptions = { 'inventory': 'inventories', 'target_team': 'teams', 'workflow': 'workflow_job_templates' } return exceptions.get(name, '{0}s'.format(name)) @staticmethod def get_name_field_from_endpoint(endpoint): return TowerAPIModule.IDENTITY_FIELDS.get(endpoint, 'name') def get_item_name(self, item, allow_unknown=False): if item: if 'name' in item: return item['name'] for field_name in TowerAPIModule.IDENTITY_FIELDS.values(): if field_name in item: return item[field_name] if item.get('type', None) in ('o_auth2_access_token', 'credential_input_source'): return item['id'] if allow_unknown: return 'unknown' if item: self.exit_json( msg='Cannot determine identity field for {0} object.'.format( item.get('type', 'unknown'))) else: self.exit_json( msg='Cannot determine identity field for Undefined object.') 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) if 'next' not in response['json']: raise RuntimeError( 'Expected list from API at {0}, got: {1}'.format( endpoint, response)) 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'] response['json']['next'] = next_page return response def get_one(self, endpoint, name_or_id=None, allow_none=True, **kwargs): new_kwargs = kwargs.copy() if name_or_id: name_field = self.get_name_field_from_endpoint(endpoint) new_data = kwargs.get('data', {}).copy() if name_field in new_data: self.fail_json( msg= "You can't specify the field {0} in your search data if using the name_or_id field" .format(name_field)) try: new_data['or__id'] = int(name_or_id) new_data['or__{0}'.format(name_field)] = name_or_id except ValueError: # If we get a value error, then we didn't have an integer so we can just pass and fall down to the fail new_data[name_field] = name_or_id new_kwargs['data'] = new_data response = self.get_endpoint(endpoint, **new_kwargs) if response['status_code'] != 200: fail_msg = "Got a {0} response when trying to get one from {1}".format( response['status_code'], endpoint) if 'detail' in response.get('json', {}): fail_msg += ', detail: {0}'.format(response['json']['detail']) self.fail_json(msg=fail_msg) 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: if allow_none: return None else: self.fail_wanted_one(response, endpoint, new_kwargs.get('data')) elif response['json']['count'] > 1: if name_or_id: # Since we did a name or ID search and got > 1 return something if the id matches for asset in response['json']['results']: if str(asset['id']) == name_or_id: return asset # We got > 1 and either didn't find something by ID (which means multiple names) # Or we weren't running with a or search and just got back too many to begin with. self.fail_wanted_one(response, endpoint, new_kwargs.get('data')) return response['json']['results'][0] def fail_wanted_one(self, response, endpoint, query_params): sample = response.copy() if len(sample['json']['results']) > 1: sample['json']['results'] = sample['json']['results'][:2] + [ '...more results snipped...' ] url = self.build_url(endpoint, query_params) display_endpoint = url.geturl()[len( self.host):] # truncate to not include the base URL self.fail_json( msg="Request to {0} returned {1} items, expected 1".format( display_endpoint, response['json']['count']), query=query_params, response=sample, total_results=response['json']['count'], ) def get_exactly_one(self, endpoint, name_or_id=None, **kwargs): return self.get_one(endpoint, name_or_id=name_or_id, allow_none=False, **kwargs) def resolve_name_to_id(self, endpoint, name_or_id): return self.get_exactly_one(endpoint, name_or_id)['id'] 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") if method in ['POST', 'PUT', 'PATCH']: url = self.build_url(endpoint) else: url = self.build_url(endpoint, query_params=kwargs.get('data')) # Extract the headers, this will be used in a couple of places headers = kwargs.get('headers', {}) # Authenticate to Tower (if we don't have a token and if not already done so) if not self.oauth_token and not self.authenticated: # This method will set a cookie in the cookie jar for us and also an oauth_token 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) if method in ['POST', 'PUT', 'PATCH']: headers.setdefault('Content-Type', 'application/json') kwargs['headers'] = headers data = None # Important, if content type is not JSON, this should not be dict type if headers.get('Content-Type', '') == 'application/json': data = dumps(kwargs.get('data', {})) try: response = self.session.open(method, url.geturl(), headers=headers, validate_certs=self.verify_ssl, follow_redirects=True, data=data) except (SSLValidationError) as ssl_err: self.fail_json( msg= "Could not establish a secure connection to your host ({1}): {0}." .format(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(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(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(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(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(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, 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( 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, url.geturl())) if not self.version_checked: # In PY2 we get back an HTTPResponse object but PY2 is returning an addinfourl # First try to get the headers in PY3 format and then drop down to PY2. try: tower_type = response.getheader('X-API-Product-Name', None) tower_version = response.getheader('X-API-Product-Version', None) except Exception: tower_type = response.info().getheader('X-API-Product-Name', None) tower_version = response.info().getheader( 'X-API-Product-Version', None) parsed_collection_version = Version( self._COLLECTION_VERSION).version parsed_tower_version = Version(tower_version).version if tower_type == 'AWX': collection_compare_ver = parsed_collection_version[0] tower_compare_ver = parsed_tower_version[0] else: collection_compare_ver = "{}.{}".format( parsed_collection_version[0], parsed_collection_version[1]) tower_compare_ver = '{}.{}'.format(parsed_tower_version[0], parsed_tower_version[1]) if self._COLLECTION_TYPE not in self.collection_to_version or self.collection_to_version[ self._COLLECTION_TYPE] != tower_type: self.warn( "You are using the {0} version of this collection but connecting to {1}" .format(self._COLLECTION_TYPE, tower_type)) elif collection_compare_ver != tower_compare_ver: self.warn( "You are running collection version {0} but connecting to tower version {1}" .format(self._COLLECTION_VERSION, tower_version)) self.version_checked = True 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 HTTPError as he: try: resp = he.read() except Exception as e: resp = 'unknown {0}'.format(e) self.fail_json(msg='Failed to get token: {0}'.format(he), response=resp) 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 delete_if_needed(self, existing_item, on_delete=None, auto_exit=True): # This will exit from the module on its own. # If 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 # This 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 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'] item_name = self.get_item_name(existing_item, allow_unknown=True) except KeyError as ke: self.fail_json( msg= "Unable to process delete of item due to missing data {0}". format(ke)) response = self.delete_endpoint(item_url) if 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) if auto_exit: self.exit_json(**self.json_output) else: return 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 auto_exit: self.exit_json(**self.json_output) else: return self.json_output def modify_associations(self, association_endpoint, new_association_list): # if we got None instead of [] we are not modifying the association_list if new_association_list is None: return # First get the existing associations response = self.get_all_endpoint(association_endpoint) existing_associated_ids = [ association['id'] for association in response['json']['results'] ] # Disassociate anything that is in existing_associated_ids but not in new_association_list ids_to_remove = list( set(existing_associated_ids) - set(new_association_list)) for an_id in ids_to_remove: response = self.post_endpoint( association_endpoint, **{'data': { 'id': int(an_id), 'disassociate': True }}) if response['status_code'] == 204: self.json_output['changed'] = True else: self.fail_json(msg="Failed to disassociate item {0}".format( response['json'].get('detail', response['json']))) # Associate anything that is in new_association_list but not in `association` for an_id in list( set(new_association_list) - set(existing_associated_ids)): response = self.post_endpoint(association_endpoint, **{'data': { 'id': int(an_id) }}) if response['status_code'] == 204: self.json_output['changed'] = True else: self.fail_json(msg="Failed to associate item {0}".format( response['json'].get('detail', response['json']))) def copy_item(self, existing_item, copy_from_name_or_id, new_item_name, endpoint=None, item_type='unknown', copy_lookup_data=None): if existing_item is not None: self.warn(msg="A {0} with the name {1} already exists.".format( item_type, new_item_name)) self.json_output['changed'] = False self.json_output['copied'] = False return existing_item # Lookup existing item to copy from copy_from_lookup = self.get_one(endpoint, name_or_id=copy_from_name_or_id, **{'data': copy_lookup_data}) # Fail if the copy_from_lookup is empty if copy_from_lookup is None: self.fail_json( msg="A {0} with the name {1} was not able to be found.".format( item_type, copy_from_name_or_id)) # Do checks for copy permisions if warrented if item_type == 'workflow_job_template': copy_get_check = self.get_endpoint( copy_from_lookup['related']['copy']) if copy_get_check['status_code'] in [200]: if (copy_get_check['json']['can_copy'] and copy_get_check['json']['can_copy_without_user_input'] and not copy_get_check['json']['templates_unable_to_copy'] and not copy_get_check['json'] ['credentials_unable_to_copy'] and not copy_get_check['json'] ['inventories_unable_to_copy']): # Because checks have passed self.json_output['copy_checks'] = 'passed' else: self.fail_json( msg="Unable to copy {0} {1} error: {2}".format( item_type, copy_from_name_or_id, copy_get_check)) else: self.fail_json( msg="Error accessing {0} {1} error: {2} ".format( item_type, copy_from_name_or_id, copy_get_check)) response = self.post_endpoint(copy_from_lookup['related']['copy'], **{'data': { 'name': new_item_name }}) if response['status_code'] in [201]: self.json_output['id'] = response['json']['id'] self.json_output['changed'] = True self.json_output['copied'] = True new_existing_item = 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, new_item_name, response['json']['__all__'][0])) elif 'json' in response: self.fail_json(msg="Unable to create {0} {1}: {2}".format( item_type, new_item_name, response['json'])) else: self.fail_json(msg="Unable to create {0} {1}: {2}".format( item_type, new_item_name, response['status_code'])) return new_existing_item def create_if_needed(self, existing_item, new_item, endpoint, on_create=None, auto_exit=True, item_type='unknown', associations=None): # This will exit from the module on its own # If 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 # This 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 if not endpoint: self.fail_json( msg="Unable to create new {0} due to missing endpoint".format( item_type)) item_url = None if existing_item: try: item_url = existing_item['url'] except KeyError as ke: self.fail_json( msg= "Unable to process create of item due to missing data {0}". format(ke)) 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 = self.get_item_name(new_item, allow_unknown=True) response = self.post_endpoint(endpoint, **{'data': new_item}) # 200 is response from approval node creation on tower 3.7.3 or awx 15.0.0 or earlier. if response['status_code'] in [200, 201]: self.json_output['name'] = 'unknown' for key in ('name', 'username', 'identifier', 'hostname'): if key in response['json']: self.json_output['name'] = response['json'][key] self.json_output['id'] = response['json']['id'] self.json_output['changed'] = True item_url = response['json']['url'] 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'])) # Process any associations with this item if associations is not None: for association_type in associations: sub_endpoint = '{0}{1}/'.format(item_url, association_type) self.modify_associations(sub_endpoint, associations[association_type]) # If we have an on_create method and we actually changed something we can call on_create if on_create is not None and self.json_output['changed']: on_create(self, response['json']) elif auto_exit: self.exit_json(**self.json_output) else: last_data = response['json'] return last_data def _encrypted_changed_warning(self, field, old, warning=False): if not warning: return self.warn( 'The field {0} of {1} {2} has encrypted data and may inaccurately report task is changed.' .format(field, old.get('type', 'unknown'), old.get('id', 'unknown'))) @staticmethod def has_encrypted_values(obj): """Returns True if JSON-like python content in obj has $encrypted$ anywhere in the data as a value """ if isinstance(obj, dict): for val in obj.values(): if TowerAPIModule.has_encrypted_values(val): return True elif isinstance(obj, list): for val in obj: if TowerAPIModule.has_encrypted_values(val): return True elif obj == TowerAPIModule.ENCRYPTED_STRING: return True return False @staticmethod def fields_could_be_same(old_field, new_field): """Treating $encrypted$ as a wild card, return False if the two values are KNOWN to be different return True if the two values are the same, or could potentially be the same, depending on the unknown $encrypted$ value or sub-values """ if isinstance(old_field, dict) and isinstance(new_field, dict): if set(old_field.keys()) != set(new_field.keys()): return False for key in new_field.keys(): if not TowerAPIModule.fields_could_be_same( old_field[key], new_field[key]): return False return True # all sub-fields are either equal or could be equal else: if old_field == TowerAPIModule.ENCRYPTED_STRING: return True return bool(new_field == old_field) def objects_could_be_different(self, old, new, field_set=None, warning=False): if field_set is None: field_set = set(fd for fd in new.keys() if fd not in ('modified', 'related', 'summary_fields')) for field in field_set: new_field = new.get(field, None) old_field = old.get(field, None) if old_field != new_field: if self.update_secrets or (not self.fields_could_be_same( old_field, new_field)): return True # Something doesn't match, or something might not match elif self.has_encrypted_values(new_field) or field not in new: if self.update_secrets or (not self.fields_could_be_same( old_field, new_field)): # case of 'field not in new' - user password write-only field that API will not display self._encrypted_changed_warning(field, old, warning=warning) return True return False def update_if_needed(self, existing_item, new_item, on_update=None, auto_exit=True, associations=None): # This will exit from the module on its own # If 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 # This will return one of two 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. # Note: common error codes from the Tower API can cause the module to fail response = None if existing_item: # If we have an item, we can see if it needs an update try: item_url = existing_item['url'] item_type = existing_item['type'] if item_type == 'user': item_name = existing_item['username'] elif item_type == 'workflow_job_template_node': item_name = existing_item['identifier'] elif item_type == 'credential_input_source': item_name = existing_item['id'] else: item_name = existing_item['name'] 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)) # Check to see if anything within the item requires the item to be updated needs_patch = self.objects_could_be_different( existing_item, new_item) # If we decided the item needs to be updated, update it self.json_output['id'] = item_id if needs_patch: response = self.patch_endpoint(item_url, **{'data': new_item}) if response['status_code'] == 200: # compare apples-to-apples, old API data to new API data # but do so considering the fields given in parameters self.json_output[ 'changed'] = self.objects_could_be_different( existing_item, response['json'], field_set=new_item.keys(), warning=True) 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: raise RuntimeError( 'update_if_needed called incorrectly without existing_item') # Process any associations with this item if associations is not None: for association_type, id_list in associations.items(): endpoint = '{0}{1}/'.format(item_url, association_type) self.modify_associations(endpoint, id_list) # If we change something and have an on_change call it if on_update is not None and self.json_output['changed']: if response is None: last_data = existing_item else: last_data = response['json'] on_update(self, last_data) elif auto_exit: self.exit_json(**self.json_output) else: if response is None: last_data = existing_item else: last_data = response['json'] return last_data def create_or_update_if_needed(self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, auto_exit=True, associations=None): if existing_item: return self.update_if_needed(existing_item, new_item, on_update=on_update, auto_exit=auto_exit, associations=associations) else: return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, auto_exit=auto_exit, associations=associations) def logout(self): if self.authenticated and self.oauth_token_id: # 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), query= None # in error cases, fail_json exists before exception handling )).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 HTTPError as he: try: resp = he.read() except Exception as e: resp = 'unknown {0}'.format(e) self.warn( 'Failed to release tower token: {0}, response: {1}'.format( he, resp)) 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 is_job_done(self, job_status): if job_status in ['new', 'pending', 'waiting', 'running']: return False else: return True def wait_on_url(self, url, object_name, object_type, timeout=30, interval=10): # Grab our start time to compare against for the timeout start = time.time() result = self.get_endpoint(url) while not result['json']['finished']: # If we are past our time out fail with a message if timeout and timeout < time.time() - start: # Account for Legacy messages if object_type == 'legacy_job_wait': self.json_output[ 'msg'] = 'Monitoring of Job - {0} aborted due to timeout'.format( object_name) else: self.json_output[ 'msg'] = 'Monitoring of {0} - {1} aborted due to timeout'.format( object_type, object_name) self.wait_output(result) self.fail_json(**self.json_output) # Put the process to sleep for our interval time.sleep(interval) result = self.get_endpoint(url) self.json_output['status'] = result['json']['status'] # If the job has failed, we want to raise a task failure for that so we get a non-zero response. if result['json']['failed']: # Account for Legacy messages if object_type == 'legacy_job_wait': self.json_output['msg'] = 'Job with id {0} failed'.format( object_name) else: self.json_output['msg'] = 'The {0} - {1}, failed'.format( object_type, object_name) self.wait_output(result) self.fail_json(**self.json_output) self.wait_output(result) return result def wait_output(self, response): for k in ('id', 'status', 'elapsed', 'started', 'finished'): self.json_output[k] = response['json'].get(k)
class OpenAPI: def __init__( self, base_url, doc_path, username=None, password=None, validate_certs=True, refresh_cache=False, ): self.doc_path = doc_path if base_url.startswith("unix:"): self.unix_socket = base_url.replace("unix:", "") self.base_url = "http://localhost/" else: self.unix_socket = None self.base_url = base_url headers = { "Content-Type": "application/json", "Accept": "application/json", } self._session = Request( url_username=username, url_password=password, headers=headers, validate_certs=validate_certs, force_basic_auth=True, ) self.load_api(refresh_cache=refresh_cache) def load_api(self, refresh_cache=False): # TODO: Find a way to invalidate caches on upstream change xdg_cache_home = os.environ.get("XDG_CACHE_HOME") or "~/.cache" apidoc_cache = os.path.join( os.path.expanduser(xdg_cache_home), "squeezer", self.base_url.replace(":", "_").replace("/", "_"), "api.json", ) try: if refresh_cache: raise IOError() with open(apidoc_cache, "rb") as f: data = f.read() self._parse_api(data) except Exception: # Try again with a freshly downloaded version data = self._download_api() self._parse_api(data) # Write to cache as it seems to be valid makedirs(os.path.dirname(apidoc_cache), exist_ok=True) with open(apidoc_cache, "wb") as f: f.write(data) def _parse_api(self, data): self.api_spec = json.loads(data) if self.api_spec.get("swagger") == "2.0": self.openapi_version = 2 elif self.api_spec.get("openapi", "").startswith("3."): self.openapi_version = 3 else: raise NotImplementedError("Unknown schema version") self.operations = { method_entry["operationId"]: (method, path) for path, path_entry in self.api_spec["paths"].items() for method, method_entry in path_entry.items() if method in {"get", "put", "post", "delete", "options", "head", "patch", "trace"} } def _download_api(self): return self._session.open( "GET", urljoin(self.base_url, self.doc_path), unix_socket=self.unix_socket ).read() def extract_params(self, param_type, path_spec, method_spec, params): param_spec = { entry["name"]: entry for entry in path_spec.get("parameters", []) if entry["in"] == param_type } param_spec.update( { entry["name"]: entry for entry in method_spec.get("parameters", []) if entry["in"] == param_type } ) result = {} for name in list(params.keys()): if name in param_spec: param_spec.pop(name) result[name] = params.pop(name) remaining_required = [ item["name"] for item in param_spec.values() if item.get("required", False) ] if any(remaining_required): raise Exception( "Required parameters [{0}] missing for {1}.".format( ", ".join(remaining_required), param_type ) ) return result def render_body(self, path_spec, method_spec, headers, body=None, uploads=None): if not (body or uploads): return None if self.openapi_version == 2: content_types = ( method_spec.get("consumes") or path_spec.get("consumes") or self.api_spec.get("consumes") ) else: content_types = list(method_spec["requestBody"]["content"].keys()) if uploads: body = body or {} if any( ( content_type.startswith("multipart/form-data") for content_type in content_types ) ): boundary = uuid.uuid4().hex part_boundary = b"--" + to_bytes(boundary, errors="surrogate_or_strict") form = [] for key, value in body.items(): b_key = to_bytes(key, errors="surrogate_or_strict") form.extend( [ part_boundary, b'Content-Disposition: form-data; name="%s"' % b_key, b"", to_bytes(value, errors="surrogate_or_strict"), ] ) for key, file_data in uploads.items(): b_key = to_bytes(key, errors="surrogate_or_strict") form.extend( [ part_boundary, b'Content-Disposition: file; name="%s"; filename="%s"' % (b_key, b_key), b"Content-Type: application/octet-stream", b"", file_data, ] ) form.append(part_boundary + b"--") data = b"\r\n".join(form) headers[ "Content-Type" ] = "multipart/form-data; boundary={boundary}".format(boundary=boundary) else: raise Exception("No suitable content type for file upload specified.") elif body: if any( ( content_type.startswith("application/json") for content_type in content_types ) ): data = json.dumps(body) headers["Content-Type"] = "application/json" elif any( ( content_type.startswith("application/x-www-form-urlencoded") for content_type in content_types ) ): data = urlencode(body) headers["Content-Type"] = "application/x-www-form-urlencoded" else: raise Exception("No suitable content type for file upload specified.") headers["Content-Length"] = len(data) return data def call(self, operation_id, parameters=None, body=None, uploads=None): method, path = self.operations[operation_id] path_spec = self.api_spec["paths"][path] method_spec = path_spec[method] if parameters is None: parameters = {} else: parameters = parameters.copy() if any(self.extract_params("cookie", path_spec, method_spec, parameters)): raise NotImplementedError("Cookie parameters are not implemented.") headers = self.extract_params("header", path_spec, method_spec, parameters) for name, value in self.extract_params( "path", path_spec, method_spec, parameters ).items(): path = path.replace("{" + name + "}", value) query_string = urlencode( self.extract_params("query", path_spec, method_spec, parameters), doseq=True ) if any(parameters): raise Exception( "Parameter [{names}] not available for {operation_id}.".format( names=", ".join(parameters.keys()), operation_id=operation_id ) ) url = urljoin(self.base_url, path) if query_string: url += "?" + query_string data = self.render_body(path_spec, method_spec, headers, body, uploads) result = self._session.open( method, url, data=data, headers=headers, unix_socket=self.unix_socket ).read() if result: return json.loads(result) return None
class AHAPIModule(AnsibleModule): """Ansible module for managing private automation hub servers.""" AUTH_ARGSPEC = dict( ah_host=dict(required=False, aliases=["ah_hostname"], fallback=(env_fallback, ["AH_HOST"])), ah_username=dict(required=False, fallback=(env_fallback, ["AH_USERNAME"])), ah_password=dict(no_log=True, required=False, fallback=(env_fallback, ["AH_PASSWORD"])), ah_path_prefix=dict(required=False, fallback=(env_fallback, ["GALAXY_API_PATH_PREFIX" ])), validate_certs=dict(type="bool", aliases=["ah_verify_ssl"], required=False, fallback=(env_fallback, ["AH_VERIFY_SSL"])), ) short_params = { "host": "ah_host", "username": "******", "password": "******", "verify_ssl": "validate_certs", "path_prefix": "ah_path_prefix", } host = "127.0.0.1" username = None password = None verify_ssl = True path_prefix = "galaxy" authenticated = False def __init__(self, argument_spec, **kwargs): """Initialize the object.""" full_argspec = {} full_argspec.update(AHAPIModule.AUTH_ARGSPEC) full_argspec.update(argument_spec) super(AHAPIModule, self).__init__(argument_spec=full_argspec, **kwargs) # Update the current object with the provided parameters for short_param, long_param in self.short_params.items(): direct_value = self.params.get(long_param) if direct_value is not None: setattr(self, short_param, direct_value) # Perform some basic validation if not re.match("^https{0,1}://", self.host): self.host = "https://{host}".format(host=self.host) # Try to parse the hostname as a url try: self.host_url = urlparse(self.host) except Exception as e: self.fail_json( msg="Unable to parse ah_host as a URL ({host}): {error}". format(host=self.host, error=e)) # Try to resolve the hostname try: socket.gethostbyname(self.host_url.hostname) except Exception as e: self.fail_json(msg="Unable to resolve ah_host ({host}): {error}". format(host=self.host_url.hostname, error=e)) self.headers = { "referer": self.host, "Content-Type": "application/json", "Accept": "application/json" } self.session = Request(validate_certs=self.verify_ssl, headers=self.headers) # Define the API paths self.galaxy_path_prefix = "/api/{prefix}".format( prefix=self.path_prefix.strip("/")) self.ui_path_prefix = "{galaxy_prefix}/_ui/v1".format( galaxy_prefix=self.galaxy_path_prefix) self.pulp_path_prefix = "/pulp/api/v3" def _build_url(self, prefix, endpoint=None, query_params=None): """Return a URL from the given prefix and endpoint. The URL is build as follows:: https://<host>/<prefix>/[<endpoint>]/[?<query>] :param prefix: Prefix to add to the endpoint. :type prefix: str :param endpoint: Usually the API object name ("users", "groups", ...) :type endpoint: str :param query_params: The optional query to append to the URL :type query_params: dict :return: The full URL built from the given prefix and endpoint. :rtype: :py:class:``urllib.parse.ParseResult`` """ if endpoint is None: api_path = "/{base}/".format(base=prefix.strip("/")) else: api_path = "{base}/{endpoint}/".format( base=prefix, endpoint=endpoint.strip("/")) url = self.host_url._replace(path=api_path) if query_params: url = url._replace(query=urlencode(query_params)) return url def build_ui_url(self, endpoint, query_params=None): """Return the URL of the given endpoint in the UI API. :param endpoint: Usually the API object name ("users", "groups", ...) :type endpoint: str :return: The full URL built from the given endpoint. :rtype: :py:class:``urllib.parse.ParseResult`` """ return self._build_url(self.ui_path_prefix, endpoint, query_params) def build_pulp_url(self, endpoint, query_params=None): """Return the URL of the given endpoint in the Pulp API. :param endpoint: Usually the API object name ("users", "groups", ...) :type endpoint: str :return: The full URL built from the given endpoint. :rtype: :py:class:``urllib.parse.ParseResult`` """ return self._build_url(self.pulp_path_prefix, endpoint, query_params) def make_request_raw_reponse(self, method, url, **kwargs): """Perform an API call and return the retrieved data. :param method: GET, PUT, POST, or DELETE :type method: str :param url: URL to the API endpoint :type url: :py:class:``urllib.parse.ParseResult`` :param kwargs: Additionnal parameter to pass to the API (headers, data for PUT and POST requests, ...) :raises AHAPIModuleError: The API request failed. :return: The reponse from the API call :rtype: :py:class:``http.client.HTTPResponse`` """ # 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") # Extract the provided headers and data headers = kwargs.get("headers", {}) data = json.dumps(kwargs.get("data", {})) #set default response response = {} try: response = self.session.open(method, url.geturl(), headers=headers, data=data) except SSLValidationError as ssl_err: raise AHAPIModuleError( "Could not establish a secure connection to {host}: {error}.". format(host=url.netloc, error=ssl_err)) except ConnectionError as con_err: raise AHAPIModuleError( "Network error when trying to connect to {host}: {error}.". format(host=url.netloc, error=con_err)) except HTTPError as he: # Sanity check: Did the server send back some kind of internal error? if he.code >= 500: raise AHAPIModuleError( "The host sent back a server error: {path}: {error}. Please check the logs and try again later" .format(path=url.path, error=he)) # Sanity check: Did we fail to authenticate properly? If so, fail out now; this is always a failure. elif he.code == 401: raise AHAPIModuleError( "Invalid authentication credentials for {path} (HTTP 401)." .format(path=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: raise AHAPIModuleError( "You do not have permission to {method} {path} (HTTP 403)." .format(method=method, path=url.path)) # 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: raise AHAPIModuleError( "The requested object could not be found at {path}.". format(path=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: raise AHAPIModuleError( "Cannot make a {method} request to this endpoint {path}". format(method=method, path=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 pass elif he.code == 204 and method == "DELETE": # A 204 is a normal response for a delete function pass else: raise AHAPIModuleError( "Unexpected return code when calling {url}: {error}". format(url=url.geturl(), error=he)) except Exception as e: raise AHAPIModuleError( "There was an unknown error when trying to connect to {name}: {error} {url}" .format(name=type(e).__name__, error=e, url=url.geturl())) return response def make_request(self, method, url, wait_for_task=True, **kwargs): """Perform an API call and return the data. :param method: GET, PUT, POST, or DELETE :type method: str :param url: URL to the API endpoint :type url: :py:class:``urllib.parse.ParseResult`` :param kwargs: Additionnal parameter to pass to the API (headers, data for PUT and POST requests, ...) :raises AHAPIModuleError: The API request failed. :return: A dictionnary with two entries: ``status_code`` provides the API call returned code and ``json`` provides the returned data in JSON format. :rtype: dict """ response = self.make_request_raw_reponse(method, url, **kwargs) try: response_body = response.read() except Exception as e: raise AHAPIModuleError( "Failed to read response body: {error}".format(error=e)) response_json = {} if response_body: try: response_json = json.loads(response_body) except Exception as e: raise AHAPIModuleError( "Failed to parse the response json: {0}".format(e)) # A background task has been triggered. Check if the task is completed if response.status == 202 and "task" in response_json and wait_for_task: url = url._replace(path=response_json["task"], query="") for _ in range(5): time.sleep(3) bg_task = self.make_request("GET", url) if "state" in bg_task["json"] and bg_task["json"][ "state"].lower().startswith("complete"): break else: if "state" in bg_task["json"]: raise AHAPIModuleError( "Failed to get the status of the remote task: {task}: last status: {status}" .format(task=response_json["task"], status=bg_task["json"]["state"])) raise AHAPIModuleError( "Failed to get the status of the remote task: {task}". format(task=response_json["task"])) return {"status_code": response.status, "json": response_json} def extract_error_msg(self, response): """Return the error message provided in the API response. Example of messages returned by the API call: { "errors": [ { "status":"400", "code":"invalid", "title":"Invalid input.", "detail":"Permission matching query does not exist." } ] } { "errors": [ { "status":"404", "code":"not_found", "title":"Not found." } ] } { "detail":"Not found." } :param response: The response message from the API. This dictionary has two keys: ``status_code`` provides the API call returned code and ``json`` provides the returned data in JSON format. :type response: dict :return: The error message or an empty string if the reponse does not provide a message. :rtype: str """ if not response or "json" not in response: return "" if "errors" in response["json"] and len(response["json"]["errors"]): if "detail" in response["json"]["errors"][0]: return response["json"]["errors"][0]["detail"] if "title" in response["json"]["errors"][0]: return response["json"]["errors"][0]["title"] if "detail" in response["json"]: return response["json"]["detail"] return "" def authenticate(self): """Authenticate with the API.""" # curl -k -i -X GET -H "Accept: application/json" -H "Content-Type: application/json" https://hub.lab.example.com/api/galaxy/_ui/v1/auth/login/ # HTTP/1.1 204 No Content # Server: nginx/1.18.0 # Date: Tue, 10 Aug 2021 07:33:37 GMT # Content-Length: 0 # Connection: keep-alive # Vary: Accept, Cookie # Allow: GET, POST, HEAD, OPTIONS # X-Frame-Options: SAMEORIGIN # Set-Cookie: csrftoken=jvdb...kKHo; expires=Tue, 09 Aug 2022 07:33:37 GMT; Max-Age=31449600; Path=/; SameSite=Lax # Strict-Transport-Security: max-age=15768000 url = self.build_ui_url("auth/login") try: response = self.make_request_raw_reponse("GET", url) except AHAPIModuleError as e: self.fail_json(msg="Authentication error: {error}".format(error=e)) # Set-Cookie: csrftoken=jvdb...kKHo; expires=Tue, 09 Aug 2022 07:33:37 GMT for h in response.getheaders(): if h[0].lower() == "set-cookie": k, v = h[1].split("=", 1) if k.lower() == "csrftoken": header = {"X-CSRFToken": v.split(";", 1)[0]} break else: header = {} # curl -k -i -X POST -H 'referer: https://hub.lab.example.com' -H "Accept: application/json" -H "Content-Type: application/json" # -H 'X-CSRFToken: jvdb...kKHo' --cookie 'csrftoken=jvdb...kKHo' -d '{"username":"******","password":"******"}' # https://hub.lab.example.com/api/galaxy/_ui/v1/auth/login/ # HTTP/1.1 204 No Content # Server: nginx/1.18.0 # Date: Tue, 10 Aug 2021 07:35:33 GMT # Content-Length: 0 # Connection: keep-alive # Vary: Accept, Cookie # Allow: GET, POST, HEAD, OPTIONS # X-Frame-Options: SAMEORIGIN # Set-Cookie: csrftoken=6DVP...at9a; expires=Tue, 09 Aug 2022 07:35:33 GMT; Max-Age=31449600; Path=/; SameSite=Lax # Set-Cookie: sessionid=87b0iw12wyvy0353rk5fwci0loy5s615; expires=Tue, 24 Aug 2021 07:35:33 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax # Strict-Transport-Security: max-age=15768000 try: response = self.make_request_raw_reponse("POST", url, data={ "username": self.username, "password": self.password }, headers=header) except AHAPIModuleError as e: self.fail_json(msg="Authentication error: {error}".format(error=e)) for h in response.getheaders(): if h[0].lower() == "set-cookie": k, v = h[1].split("=", 1) if k.lower() == "csrftoken": header = {"X-CSRFToken": v.split(";", 1)[0]} break else: header = {} self.headers.update(header) self.authenticated = True def logout(self): if not self.authenticated: return url = self.build_ui_url("auth/logout") try: self.make_request_raw_reponse("POST", url) except AHAPIModuleError: pass self.headers = { "referer": self.host, "Content-Type": "application/json", "Accept": "application/json" } self.session = Request(validate_certs=self.verify_ssl, headers=self.headers) self.authenticated = False def fail_json(self, **kwargs): self.logout() super(AHAPIModule, self).fail_json(**kwargs) def exit_json(self, **kwargs): self.logout() super(AHAPIModule, self).exit_json(**kwargs) def get_server_version(self): """Return the automation hub/galaxy server version. :return: the server version ("4.2.5" for example) or an empty string if that information is not available. :rtype: str """ url = self._build_url(self.galaxy_path_prefix) try: response = self.make_request("GET", url) except AHAPIModuleError as e: self.fail_json( msg="Error while getting server version: {error}".format( error=e)) if response["status_code"] != 200: error_msg = self.extract_error_msg(response) if error_msg: fail_msg = "Unable to get server version: {code}: {error}".format( code=response["status_code"], error=error_msg) else: fail_msg = "Unable to get server version: {code}".format( code=response["status_code"]) self.fail_json(msg=fail_msg) return response["json"][ "server_version"] if "server_version" in response["json"] else ""
class Connection(ConnectionBase): force_persistence = True transport = "pms" def __init__(self, play_context, *args, **kwargs): super(Connection, self).__init__(play_context, *args, **kwargs) self._messages = [] self._sub_plugin = {} self._conn_closed = False # We are, for the most part, just a local connection that knows how to # perform HTTP requests. self._local = connection_loader.get("local", play_context, "/dev/null") self._local.set_options() self._headers = {} def _connect(self): if self._connected: return self._address = self.get_option("address").rstrip("/") self._client = Request() # Login status, headers, _ = self._request( "POST", "/tokens", dict( username=self.get_option("username"), password=self.get_option("password"), )) self._headers["x-auth-token"] = headers["x-auth-token"] self._local._connect() self._connected = True def exec_command(self, *args, **kwargs): return self._local.exec_command(*args, **kwargs) def put_file(self, in_path, out_path): return self._local.put_file(in_path, out_path) def fetch_file(self, in_path, out_path): return self._local.fetch_file(in_path, out_path) def close(self): self._conn_closed = True if not self._connected: return self._local.close() if "x-auth-token" in self._headers: self.delete("/tokens/" + self._headers["x-auth-token"]) del self._headers["x-auth-token"] self._connected = False def queue_message(self, level, message): self._messages.append((level, message)) def pop_messages(self): messages, self._messages = self._messages, [] return messages def _log_messages(self, data): pass def _request(self, method, path, payload=None): headers = self._headers.copy() data = None if payload: data = json.dumps(payload) headers["Content-Type"] = "application/json" url = self._address + path try: r = self._client.open(method, url, data=data, headers=headers) r_status = r.getcode() r_headers = dict(r.headers) data = r.read().decode("utf-8") r_data = json.loads(data) if data else {} except HTTPError as e: r_status = e.code r_headers = {} r_data = dict(msg=str(e.reason)) except (ConnectionError, URLError) as e: raise AnsibleConnectionFailure( "Could not connect to {0}: {1}".format(url, e.reason)) return r_status, r_headers, r_data @ensure_connect def get(self, path): return self._request("GET", path) @ensure_connect def post(self, path, payload=None): return self._request("POST", path, payload) @ensure_connect def delete(self, path): return self._request("DELETE", path)