diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c12dd..15fb3ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ # Changelog > * Pre-v1.00, 11.2020 -- Initial release - Marcel Zehnder > * v1.00, 11.2020 -- Adjustments for OpenSource - Andreas Graber +> * v1.03, 06.2021 -- Snapshot creation added - Dario Kaelin diff --git a/README.md b/README.md index dafe9e9..df25187 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A python wrapper to the Cisco ACI REST-API. We support Python 3.6 and up. Python 2 is not supported and there is no plan to add support for it. ## Installation -``pip install aciclient`` +``pip install aciClient`` ## Installation for Developing ``` @@ -28,7 +28,7 @@ import logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -aciclient = aciClient.ACI(apic_hostname, apic_username, apic_password) +aciclient = aciClient.ACI(apic_hostname, apic_username, apic_password, refresh=False) try: aciclient.login() @@ -41,6 +41,11 @@ except Exception as e: logger.exception("Stack Trace") ``` +For automatic authentication token refresh you can set refresh to True +```python +aciclient = aciClient.ACI(apic_hostname, apic_username, apic_password, refresh=True) +``` + ### Certificate/signature ```python @@ -88,6 +93,11 @@ aciclient.postJson(config) aciclient.deleteMo('uni/tn-XYZ') ``` +### create snapshot +```python +aci.snapshot('test') +``` + ## Testing ``` @@ -104,6 +114,7 @@ of conduct, and the process for submitting pull requests to this project. * **Marcel Zehnder** - *Initial work* * **Andreas Graber** - *Migration to open source* * **Richard Strnad** - *Paginagtion for large requests, various small stuff* +* **Dario Kaelin** - *Added snapshot creation* ## License diff --git a/aciClient/aci.py b/aciClient/aci.py index 3be0728..48c9f06 100644 --- a/aciClient/aci.py +++ b/aciClient/aci.py @@ -10,6 +10,7 @@ import logging import json import requests +import threading # The modules are named different in python2/python3... try: @@ -27,7 +28,7 @@ class ACI: # ============================================================================== # constructor # ============================================================================== - def __init__(self, apicIp, apicUser, apicPasword): + def __init__(self, apicIp, apicUser, apicPasword, refresh=False): self.__logger.debug('Constructor called') self.apicIp = apicIp self.apicUser = apicUser @@ -36,9 +37,20 @@ def __init__(self, apicIp, apicUser, apicPasword): self.baseUrl = 'https://' + self.apicIp + '/api/' self.__logger.debug(f'BaseUrl set to: {self.baseUrl}') + self.refresh_auto = refresh + self.refresh_next = None + self.refresh_thread = None + self.refresh_offset = 30 self.session = None self.token = None + def __refresh_session_timer(self, response): + self.__logger.debug(f'refreshing the token {self.refresh_offset}s before it expires') + self.refresh_next = int(response.json()['imdata'][0]['aaaLogin']['attributes']['refreshTimeoutSeconds']) + self.refresh_thread = threading.Timer(self.refresh_next - self.refresh_offset, self.renewCookie) + self.__logger.debug(f'starting thread to refresh token in {self.refresh_next - self.refresh_offset}s') + self.refresh_thread.start() + # ============================================================================== # login # ============================================================================== @@ -46,7 +58,7 @@ def login(self) -> bool: self.__logger.debug('login called') self.session = requests.Session() - self.__logger.info('Session Object Created') + self.__logger.debug('Session Object Created') # create credentials structure userPass = json.dumps({'aaaUser': {'attributes': {'name': self.apicUser, 'pwd': self.apicPassword}}}) @@ -54,7 +66,7 @@ def login(self) -> bool: self.__logger.info(f'Login to apic {self.baseUrl}') response = self.session.post(self.baseUrl + 'aaaLogin.json', data=userPass, verify=False, timeout=5) - # Don't rise an exception for 401 + # Don't raise an exception for 401 if response.status_code == 401: self.__logger.error(f'Login not possible due to Error: {response.text}') self.session = False @@ -64,15 +76,24 @@ def login(self) -> bool: response.raise_for_status() self.token = response.json()['imdata'][0]['aaaLogin']['attributes']['token'] - self.__logger.info('Successful get Token from APIC') + self.__logger.debug('Successful get Token from APIC') + + if self.refresh_auto: + self.__refresh_session_timer(response=response) return True # ============================================================================== # logout # ============================================================================== def logout(self): - self.__logger.debug('Logout from APIC...') + self.__logger.debug('logout called') + self.refresh_auto = False + if self.refresh_thread is not None: + if self.refresh_thread.is_alive(): + self.__logger.debug('Stoping refresh_auto thread') + self.refresh_thread.cancel() self.postJson(jsonData={'aaaUser': {'attributes': {'name': self.apicUser}}}, url='aaaLogout.json') + self.__logger.debug('Logout from APIC sucessfull') # ============================================================================== # renew cookie (aaaRefresh) @@ -81,11 +102,17 @@ def renewCookie(self) -> bool: self.__logger.debug('Renew Cookie called') response = self.session.post(self.baseUrl + 'aaaRefresh.json', verify=False) - # Raise Exception for an error 4xx and 5xx - response.raise_for_status() - - self.token = response.json()['imdata'][0]['aaaLogin']['attributes']['token'] - self.__logger.info('Successful renewed the Token') + if response.status_code == 200: + if self.refresh_auto: + self.__refresh_session_timer(response=response) + self.token = response.json()['imdata'][0]['aaaLogin']['attributes']['token'] + self.__logger.debug('Successfuly renewed the token') + else: + self.token = False + self.refresh_auto = False + self.__logger.error(f'Could not renew token. {response.text}') + response.raise_for_status() + return False return True # ============================================================================== @@ -108,10 +135,10 @@ def getJson(self, uri, subscription=False) -> {}: if response.ok: responseJson = response.json() - self.__logger.info(f'Successful get Data from APIC: {responseJson}') + self.__logger.debug(f'Successful get Data from APIC: {responseJson}') if subscription: subscription_id = responseJson['subscriptionId'] - self.__logger.info(f'Returning Subscription Id: {subscription_id}') + self.__logger.debug(f'Returning Subscription Id: {subscription_id}') return subscription_id return responseJson['imdata'] @@ -120,7 +147,7 @@ def getJson(self, uri, subscription=False) -> {}: self.__logger.error(f'Error 400 during get occured: {resp_text}') if resp_text == 'Unable to process the query, result dataset is too big': # Dataset was too big, we try to grab all the data with pagination - self.__logger.info(f'Trying with Pagination, uri: {uri}') + self.__logger.debug(f'Trying with Pagination, uri: {uri}') return self.getJsonPaged(uri) return resp_text else: @@ -148,7 +175,7 @@ def getJsonPaged(self, uri) -> {}: if response.ok: responseJson = response.json() - self.__logger.info(f'Successful get Data from APIC: {responseJson}') + self.__logger.debug(f'Successful get Data from APIC: {responseJson}') if responseJson['imdata']: return_data.extend(responseJson['imdata']) else: @@ -170,7 +197,7 @@ def postJson(self, jsonData, url='mo.json') -> {}: self.__logger.debug(f'Post Json called data: {jsonData}') response = self.session.post(self.baseUrl + url, verify=False, data=json.dumps(jsonData, sort_keys=True)) if response.status_code == 200: - self.__logger.info(f'Successful Posted Data to APIC: {response.json()}') + self.__logger.debug(f'Successful Posted Data to APIC: {response.json()}') return response.status_code elif response.status_code == 400: resp_text = '400: ' + response.json()['imdata'][0]['error']['attributes']['text'] @@ -192,3 +219,36 @@ def deleteMo(self, dn) -> int: response.raise_for_status() return response.status_code + + # ============================================================================== + # snapshot + # ============================================================================== + def snapshot(self, description="snapshot") -> bool: + self.__logger.debug(f'snapshot called {description}') + + json_payload = [ + { + "configExportP": { + "attributes": { + "adminSt": "triggered", + "descr": f"by aciClient - {description}", + "dn": "uni/fabric/configexp-aciclient", + "format": "json", + "includeSecureFields": "yes", + "maxSnapshotCount": "global-limit", + "name": "aciclient", + "nameAlias": "", + "snapshot": "yes", + "targetDn": "" + } + } + } + ] + + response = self.postJson(json_payload) + if response == 200: + self.__logger.debug('snapshot created and triggered') + return True + else: + self.__logger.error(f'snapshot creation not succesfull: {response}') + return False diff --git a/aciClient/aciCertClient.py b/aciClient/aciCertClient.py index 10fb733..27b6ad9 100644 --- a/aciClient/aciCertClient.py +++ b/aciClient/aciCertClient.py @@ -55,7 +55,7 @@ def getJson(self, uri) -> {}: # Raise Exception if http Error occurred r.raise_for_status() - self.__logger.info(f'Successful get Data from APIC: {r.json()}') + self.__logger.debug(f'Successful get Data from APIC: {r.json()}') return r.json()['imdata'] # ============================================================================== @@ -73,7 +73,7 @@ def postJson(self, jsonData): r.raise_for_status() if r.status_code == 200: - self.__logger.info(f'Successful Posted Data to APIC: {r.json()}') + self.__logger.debug(f'Successful Posted Data to APIC: {r.json()}') return r.status_code else: self.__logger.error(f'Error during get occured: {r.json()}') diff --git a/setup.py b/setup.py index 5d00edf..62f801c 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ long_description = f.read() setup(name='aciClient', - version='1.2', + version='1.3', description='aci communication helper class', url='http://www.netcloud.ch', author='mze', diff --git a/test/test_aci.py b/test/test_aci.py index d158afa..80160e1 100644 --- a/test/test_aci.py +++ b/test/test_aci.py @@ -10,6 +10,7 @@ from aciClient.aci import ACI import pytest +import time __BASE_URL = 'testing-apic.ncdev.ch' @@ -46,6 +47,40 @@ def test_login_404_exception(requests_mock): with pytest.raises(RequestException): resp = aci.login() +def test_login_refresh_ok(requests_mock): + requests_mock.post(f'https://{__BASE_URL}/api/aaaLogin.json', json={'imdata': [ + {'aaaLogin': {'attributes': {'refreshTimeoutSeconds': '31', 'token':'tokenxyz'}}} + ]}) + requests_mock.post(f'https://{__BASE_URL}/api/aaaRefresh.json', json={ + 'imdata': [ + { + 'aaaLogin': { + 'attributes': { + 'refreshTimeoutSeconds': '300', + 'token':'tokenabc' + } + } + } + ]}) + requests_mock.post(f'https://{__BASE_URL}/api/aaaLogout.json', json={'imdata': []}, status_code=200) + aci = ACI(apicIp=__BASE_URL, apicUser='admin', apicPasword='unkown', refresh=True) + aci.login() + token = aci.getToken() + time.sleep(2) + aci.logout() + assert token != aci.getToken() + +def test_login_refresh_nok(requests_mock): + requests_mock.post(f'https://{__BASE_URL}/api/aaaLogin.json', json={'imdata': [ + {'aaaLogin': {'attributes': {'refreshTimeoutSeconds': '31', 'token':'tokenxyz'}}} + ]}) + requests_mock.post(f'https://{__BASE_URL}/api/aaaRefresh.json', json={ + 'imdata': []}, status_code=403) + aci = ACI(apicIp=__BASE_URL, apicUser='admin', apicPasword='unkown', refresh=True) + aci.login() + time.sleep(3) + token = aci.getToken() + assert not token def test_renew_cookie_ok(requests_mock): requests_mock.post(f'https://{__BASE_URL}/api/aaaLogin.json', json={'imdata': [ @@ -194,3 +229,29 @@ def test_post_tenant_forbidden_exception(requests_mock): aci.login() with pytest.raises(RequestException): aci.postJson(post_data) + + +def test_snapshot_ok(requests_mock): + requests_mock.post(f'https://{__BASE_URL}/api/mo.json', json={"totalCount": "0", "imdata": []}) + requests_mock.post(f'https://{__BASE_URL}/api/aaaLogin.json', json={'imdata': [ + {'aaaLogin': {'attributes': {'token': 'tokenxyz'}}} + ]}) + + aci = ACI(apicIp=__BASE_URL, apicUser='admin', apicPasword='unkown') + aci.login() + resp = aci.snapshot(description='unit_test') + assert resp + + +def test_snapshot_nok(requests_mock): + requests_mock.post(f'https://{__BASE_URL}/api/mo.json', + json={"totalCount": "0", "imdata": [{"error": {"attributes": {"text": "Error UnitTest"}}}]}, + status_code=400) + requests_mock.post(f'https://{__BASE_URL}/api/aaaLogin.json', json={'imdata': [ + {'aaaLogin': {'attributes': {'token': 'tokenxyz'}}} + ]}) + + aci = ACI(apicIp=__BASE_URL, apicUser='admin', apicPasword='unkown') + aci.login() + resp = aci.snapshot(description='unit_test') + assert not resp