diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c2aaf18..5efab3f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,10 +37,8 @@ jobs: fail-fast: false matrix: # A list of Python versions to run the tests on - python-version: ['3.5', '3.6', '3.7', '3.8', '3.9'] + python-version: ['3.6', '3.7', '3.8', '3.9'] include: - - python-version: '3.5' - TOXENV: 'py35' - python-version: '3.6' TOXENV: 'py36' - python-version: '3.7' diff --git a/infoblox_client/connector.py b/infoblox_client/connector.py index 5b2742c..aa407d8 100644 --- a/infoblox_client/connector.py +++ b/infoblox_client/connector.py @@ -19,6 +19,7 @@ import requests import six import urllib3 +import time from requests import exceptions as req_exc try: @@ -49,6 +50,7 @@ def reraise_neutron_exception(func): @functools.wraps(func) def callee(*args, **kwargs): + """Catches third-party exceptions and raises Infoblox exceptions""" try: return func(*args, **kwargs) except req_exc.Timeout as e: @@ -59,6 +61,47 @@ def callee(*args, **kwargs): return callee +def retry_on_expired_cookie(func): + """ + Decorator to handle expired cookies by re-authenticating + and retrying the request. + """ + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + """ + Wrapper function to handle expired cookies by re-authenticating + and retrying the request. + """ + try: + return func(self, *args, **kwargs) + except (req_exc.HTTPError, ib_ex.InfobloxBadWAPICredential, + ib_ex.InfobloxFileDownloadFailed, + ib_ex.InfobloxFileUploadFailed) as e: + if isinstance(e, (req_exc.HTTPError, + ib_ex.InfobloxBadWAPICredential, + ib_ex.InfobloxFileDownloadFailed, + ib_ex.InfobloxFileUploadFailed)): + LOG.warning("Bad WAPI credentials or Cookie timeout.\ + Re-authenticating and retrying the request.") + else: + raise + + LOG.warning("Clearing cookies and re-authenticating.") + self.session.cookies.clear() + if hasattr(self, 'username') and hasattr(self, 'password'): + LOG.warning("Re-authenticating with username and password.") + self.session.auth = (self.username, self.password) + elif hasattr(self, 'cert') and hasattr(self, 'key'): + LOG.warning("Re-authenticating with client certificate.") + self.session.cert = (self.cert, self.key) + else: + LOG.warning("No valid credentials found to re-authenticate.") + raise ib_ex.InfobloxConfigException( + msg="No valid credentials found to re-authenticate.") + return func(self, *args, **kwargs) + return wrapper + + class Connector(object): """Connector stands for interacting with Infoblox NIOS @@ -92,7 +135,7 @@ def __init__(self, options): self._urljoin = urlparse.urljoin def _parse_options(self, options): - """Copy needed options to self""" + """Copy necessary options to self""" attributes = ('host', 'wapi_version', 'username', 'password', 'cert', 'key', 'ssl_verify', 'http_request_timeout', 'max_retries', 'http_pool_connections', @@ -207,6 +250,7 @@ def _validate_obj_type_or_die(obj_type, obj_type_expected=True): @staticmethod def _validate_authorized(response): if response.status_code == requests.codes.UNAUTHORIZED: + LOG.debug("WAPI Response: %s", response.content) raise ib_ex.InfobloxBadWAPICredential(response='') @staticmethod @@ -233,6 +277,37 @@ def _build_query_params(payload=None, return_fields=None, return query_params + def is_cookie_expired(self): + """ + Check if the cookie is expired by comparing the expiration time + with the current time. + """ + + cookie_jar = self.session.cookies + for cookie in cookie_jar: + if cookie.name == 'ibapauth': + # Parse the cookie value + cookie_value = cookie.value.strip('"') + cookie_parts = {} + for part in cookie_value.split(','): + if '=' in part: + key, value = part.split('=', 1) + cookie_parts[key] = value + + # Extract ctime and timeout + ctime = int(cookie_parts.get('ctime', 0)) + timeout = int(cookie_parts.get('timeout', 0)) + + # Calculate expiration time + expiration_time = ctime + timeout + + # Get current time + current_time = int(time.time()) + + # Check if the cookie is expired + return current_time > expiration_time + return True # If the cookie is not found, consider it expired + def _get_request_options(self, data=None): opts = dict(timeout=self.http_request_timeout, headers=self.DEFAULT_HEADER, @@ -241,6 +316,25 @@ def _get_request_options(self, data=None): opts['data'] = jsonutils.dumps(data) return opts + def _validate_cookie(self): + if self.is_cookie_expired(): + LOG.info("Cookie expired. \ + Clearing cookies and re-authenticating on the next request.") + self.session.cookies.clear() + if hasattr(self, 'username') and hasattr(self, 'password'): + LOG.info("Re-authenticating with username and password.") + self.session.auth = (self.username, self.password) + elif hasattr(self, 'cert') and hasattr(self, 'key'): + LOG.info("Re-authenticating with client certificate.") + self.session.cert = (self.cert, self.key) + else: + LOG.warning("No valid credentials found to re-authenticate.") + raise ib_ex.InfobloxConfigException( + msg="No valid credentials found to re-authenticate.") + else: + LOG.info("Using existing cookie for authentication") + self.session.auth = None # Do not re-authenticate + @staticmethod def _parse_reply(request): """Tries to parse reply from NIOS. @@ -248,8 +342,10 @@ def _parse_reply(request): Raises exception with content if reply is not in json format """ try: + LOG.debug("WAPI Response: %s", request.content) return jsonutils.loads(request.content) except ValueError: + LOG.error("Failed to parse reply from NIOS: %s", request.content) raise ib_ex.InfobloxConnectionError(reason=request.content) def _log_request(self, verb, url, opts): @@ -261,6 +357,7 @@ def _log_request(self, verb, url, opts): LOG.debug(*message) @reraise_neutron_exception + @retry_on_expired_cookie def get_object(self, obj_type, payload=None, return_fields=None, extattrs=None, force_proxy=False, max_results=None, paging=None): @@ -269,7 +366,7 @@ def get_object(self, obj_type, payload=None, return_fields=None, Some get requests like 'ipv4address' should be always proxied to GM on Hellfire If request is cloud and proxy is not forced yet, - then plan to do 2 request: + then plan to do two requests: - the first one is not proxied to GM - the second is proxied to GM @@ -282,13 +379,13 @@ def get_object(self, obj_type, payload=None, return_fields=None, force_proxy (bool): Set _proxy_search flag to process requests on GM max_results (int): Maximum number of objects to be returned. - If set to a negative number the appliance will return an error + If set to a negative number, the appliance will return an error when the number of returned objects would exceed the setting. The default is -1000. If this is set to a positive number, the results will be truncated when necessary. - paging (bool): Enables paging to wapi calls if paging = True, + Paging (bool): Enables paging to wapi calls if paging = True, it uses _max_results to set paging size of the wapi calls. - If _max_results is negative it will take paging size as 1000. + If _max_results is negative, it will take paging size as 1000. Returns: A list of the Infoblox objects requested @@ -359,7 +456,7 @@ def _get_object(self, obj_type, url): if self.session.cookies: # the first 'get' or 'post' action will generate a cookie # after that, we don't need to re-authenticate - self.session.auth = None + self._validate_cookie() r = self.session.get(url, **opts) self._validate_authorized(r) @@ -371,11 +468,18 @@ def _get_object(self, obj_type, url): return self._parse_reply(r) @reraise_neutron_exception + @retry_on_expired_cookie def download_file(self, url): + req_cookies = None if self.session.cookies: - self.session.auth = None - ibapauth_cookie = self.session.cookies.get('ibapauth') - req_cookies = {'ibapauth': ibapauth_cookie} + self._validate_cookie() + cookies_dict = self.session.cookies.get_dict() + ibapauth_cookie = cookies_dict.get('ibapauth', None) + if ibapauth_cookie: + req_cookies = {'ibapauth': ibapauth_cookie} + else: + req_cookies = None + headers = {'content-type': 'application/force-download'} r = self.session.get(url, headers=headers, cookies=req_cookies) if r.status_code != requests.codes.ok: @@ -384,10 +488,10 @@ def download_file(self, url): response=response, url=url ) - return r @reraise_neutron_exception + @retry_on_expired_cookie def upload_file(self, url, files): """Upload file to fully-qualified upload url @@ -399,9 +503,16 @@ def upload_file(self, url, files): Raises: InfobloxException """ + req_cookies = None if self.session.cookies: - self.session.auth = None - r = self.session.post(url, files=files) + self._validate_cookie() + cookies_dict = self.session.cookies.get_dict() + ibapauth_cookie = cookies_dict.get('ibapauth', None) + if ibapauth_cookie: + req_cookies = {'ibapauth': ibapauth_cookie} + else: + req_cookies = None + r = self.session.post(url, files=files, cookies=req_cookies) if r.status_code != requests.codes.ok: response = utils.safe_json_load(r.content) raise ib_ex.InfobloxFileUploadFailed( @@ -410,20 +521,20 @@ def upload_file(self, url, files): content=response, code=r.status_code, ) - return r @reraise_neutron_exception + @retry_on_expired_cookie def create_object(self, obj_type, payload, return_fields=None): """Create an Infoblox object of type 'obj_type' Args: - obj_type (str): Infoblox object type, - e.g. 'network', 'range', etc. - payload (dict): Payload with data to send + obj_type (str): Infoblox object type, + e.g. 'network', 'range', etc. + payload (dict): Payload with data to send return_fields (list): List of fields to be returned Returns: - The object reference of the newly create object + The object reference of the new creation object Raises: InfobloxException """ @@ -437,7 +548,7 @@ def create_object(self, obj_type, payload, return_fields=None): if self.session.cookies: # the first 'get' or 'post' action will generate a cookie # after that, we don't need to re-authenticate - self.session.auth = None + self._validate_cookie() r = self.session.post(url, **opts) self._validate_authorized(r) @@ -468,7 +579,10 @@ def _check_service_availability(self, operation, resp, ref): code=resp.status_code) @reraise_neutron_exception + @retry_on_expired_cookie def call_func(self, func_name, ref, payload, return_fields=None): + if self.session.cookies: + self._validate_cookie() query_params = self._build_query_params(return_fields=return_fields) query_params['_function'] = func_name @@ -493,6 +607,7 @@ def call_func(self, func_name, ref, payload, return_fields=None): return self._parse_reply(r) @reraise_neutron_exception + @retry_on_expired_cookie def update_object(self, ref, payload, return_fields=None): """Update an Infoblox object @@ -510,6 +625,8 @@ def update_object(self, ref, payload, return_fields=None): opts = self._get_request_options(data=payload) url = self._construct_url(ref, query_params) self._log_request('put', url, opts) + if self.session.cookies: + self._validate_cookie() r = self.session.put(url, **opts) self._validate_authorized(r) @@ -526,6 +643,7 @@ def update_object(self, ref, payload, return_fields=None): return self._parse_reply(r) @reraise_neutron_exception + @retry_on_expired_cookie def delete_object(self, ref, delete_arguments=None): """Remove an Infoblox object @@ -542,6 +660,8 @@ def delete_object(self, ref, delete_arguments=None): delete_arguments = {} url = self._construct_url(ref, query_params=delete_arguments) self._log_request('delete', url, opts) + if self.session.cookies: + self._validate_cookie() r = self.session.delete(url, **opts) self._validate_authorized(r) diff --git a/infoblox_client/utils.py b/infoblox_client/utils.py index d63eaf3..df68b4f 100644 --- a/infoblox_client/utils.py +++ b/infoblox_client/utils.py @@ -30,9 +30,14 @@ def is_valid_ip(ip): + """Check if the given IP address is valid.""" + if not ip: + LOG.info("IP address is not provided") + return False try: netaddr.IPAddress(ip) except netaddr.core.AddrFormatError: + LOG.info("Invalid IP address provided: %s", ip) return False return True diff --git a/tests/test_connector.py b/tests/test_connector.py index 049312d..acd584d 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -12,6 +12,7 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. +import time import unittest import os @@ -19,6 +20,7 @@ import requests from mock import patch from requests import exceptions as req_exc +from requests.cookies import RequestsCookieJar try: from oslo_serialization import jsonutils @@ -76,7 +78,11 @@ def test_create_object(self): with patch.object(requests.Session, 'post', return_value=mock.Mock()) as patched_create: - self.connector.session.cookies = ['cookies'] + cookie_jar = RequestsCookieJar() + cookie_jar.set('ibapauth', + 'ctime={},user=admin,group=admin-group,auth=LOCAL,client=API,mtime=1731651582,su=1,ip=localhost,timeout=60,mjuHjy8l1tY0GhSf+aRcxI7rybaIONUIpjc'.format( + int(time.time())), domain='infoblox.localhost') + self.connector.session.cookies = cookie_jar patched_create.return_value.status_code = 201 patched_create.return_value.content = '{}' self.connector.create_object(objtype, payload) @@ -399,6 +405,7 @@ def test__get_object_raises_search_error(self): response.url = url self.connector.session = mock.Mock() self.connector.session.get.return_value = response + self.connector.session.cookies = RequestsCookieJar() with self.assertRaises(requests.HTTPError): self.connector._get_object('network', url) @@ -517,7 +524,12 @@ def test_call_upload_file(self): payload = dict(file=data) with patch.object(requests.Session, 'post', return_value=mock.Mock()) as patched_post: - self.connector.session.cookies = ['cookies'] + cookie_jar = RequestsCookieJar() + cookie_jar.set( + 'ibapauth', + 'ctime={},user=admin,group=admin-group,auth=LOCAL,client=API,mtime=1731651582,su=1,ip=localhost,timeout=60,mjuHjy8l1tY0GhSf+aRcxI7rybaIONUIpjc'.format( + int(time.time())), domain='infoblox.localhost') + self.connector.session.cookies = cookie_jar patched_post.return_value.status_code = 200 patched_post.return_value.content = '{}' self.connector.upload_file(upload_url, payload) @@ -547,7 +559,12 @@ def test_call_download_file(self): download_url = 'https://infoblox.example.org' + download_file_path with patch.object(requests.Session, 'get', return_value=mock.Mock()) as patched_get: - self.connector.session.cookies = ['cookies'] + cookie_jar = RequestsCookieJar() + cookie_jar.set( + 'ibapauth', + 'ctime={},user=admin,group=admin-group,auth=LOCAL,client=API,mtime=1731651582,su=1,ip=localhost,timeout=60,mjuHjy8l1tY0GhSf+aRcxI7rybaIONUIpjc'.format( + int(time.time())), domain='infoblox.localhost') + self.connector.session.cookies = cookie_jar patched_get.return_value.status_code = 200 patched_get.return_value.content = '{}' self.connector.download_file(download_url) @@ -606,7 +623,11 @@ def test_get_object_with_cookies(self): objtype = 'network' with patch.object(requests.Session, 'get', return_value=mock.Mock()) as patched_get: - self.connector.session.cookies = ['cookies'] + cookie_jar = RequestsCookieJar() + cookie_jar.set('ibapauth', + 'ctime={},user=admin,group=admin-group,auth=LOCAL,client=API,mtime=1731651582,su=1,ip=localhost,timeout=60,mjuHjy8l1tY0GhSf+aRcxI7rybaIONUIpjc'.format( + int(time.time())), domain='infoblox.localhost') + self.connector.session.cookies = cookie_jar patched_get.return_value.status_code = 200 patched_get.return_value.content = '{}' self.connector.get_object(objtype, {})