From d13430ca5f57a0b3581c1674704864ea2472385b Mon Sep 17 00:00:00 2001 From: kaelin Date: Tue, 8 Jun 2021 17:44:13 +0200 Subject: [PATCH 1/6] create snapshot through aciClient --- CHANGELOG.md | 1 + README.md | 6 ++++++ aciClient/aci.py | 33 +++++++++++++++++++++++++++++++++ setup.py | 2 +- 4 files changed, 41 insertions(+), 1 deletion(-) 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..7f8e8de 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,11 @@ aciclient.postJson(config) aciclient.deleteMo('uni/tn-XYZ') ``` +### create snapshot +```python +aci.snapshot('test') +``` + ## Testing ``` @@ -104,6 +109,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..3678254 100644 --- a/aciClient/aci.py +++ b/aciClient/aci.py @@ -192,3 +192,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-netcloud-aciclient", + "format": "json", + "includeSecureFields": "yes", + "maxSnapshotCount": "global-limit", + "name": "netcloud-aciclient", + "nameAlias": "", + "snapshot": "yes", + "targetDn": "" + } + } + } + ] + + response = self.postJson(json_payload) + if response == 200: + self.__logger.info(f'snapshot created and triggered') + return True + else: + self.__logger.error(f'snapshot creation not succesfull: {response.text}') + return False \ No newline at end of file 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', From a55563f3e65842720be508bba08858535a84ba22 Mon Sep 17 00:00:00 2001 From: kaelin Date: Tue, 8 Jun 2021 18:02:33 +0200 Subject: [PATCH 2/6] ajusted logging to lower level --- aciClient/aci.py | 18 +++++++++--------- aciClient/aciCertClient.py | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/aciClient/aci.py b/aciClient/aci.py index 3678254..f799d99 100644 --- a/aciClient/aci.py +++ b/aciClient/aci.py @@ -46,7 +46,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}}}) @@ -64,7 +64,7 @@ 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') return True # ============================================================================== @@ -85,7 +85,7 @@ def renewCookie(self) -> bool: response.raise_for_status() self.token = response.json()['imdata'][0]['aaaLogin']['attributes']['token'] - self.__logger.info('Successful renewed the Token') + self.__logger.debug('Successful renewed the Token') return True # ============================================================================== @@ -108,10 +108,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 +120,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 +148,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 +170,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'] @@ -220,7 +220,7 @@ def snapshot(self, description="snapshot") -> bool: response = self.postJson(json_payload) if response == 200: - self.__logger.info(f'snapshot created and triggered') + self.__logger.debug(f'snapshot created and triggered') return True else: self.__logger.error(f'snapshot creation not succesfull: {response.text}') 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()}') From 1bf446eb33f76d4f4e3cf1cdfaad47e2228a76c3 Mon Sep 17 00:00:00 2001 From: kaelin Date: Wed, 9 Jun 2021 08:51:43 +0200 Subject: [PATCH 3/6] provide a way to keep the token automatically refreshed --- README.md | 2 +- aciClient/aci.py | 48 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7f8e8de..ba959c0 100644 --- a/README.md +++ b/README.md @@ -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=True) try: aciclient.login() diff --git a/aciClient/aci.py b/aciClient/aci.py index f799d99..4c68b8a 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,6 +37,9 @@ 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_thread = None + self.refresh_offset = 30 self.session = None self.token = None @@ -65,14 +69,35 @@ def login(self) -> bool: self.token = response.json()['imdata'][0]['aaaLogin']['attributes']['token'] self.__logger.debug('Successful get Token from APIC') - return True + + if self.refresh_auto: + 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() + 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 auto (aaaRefresh) + # ============================================================================== + def renewCookie_auto(self): + self.__logger.debug('renewCookie called') + response = self.session.get(self.baseUrl + 'aaaRefresh.json', verify=False) + # ============================================================================== # renew cookie (aaaRefresh) @@ -81,11 +106,18 @@ 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.debug('Successful renewed the Token') + if response.status_code == 200: + if self.refresh_auto: + 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'refresh_auto on, next renew in {self.refresh_next-self.refresh_offset}') + self.refresh_thread.start() + self.token = response.json()['imdata'][0]['aaaLogin']['attributes']['token'] + self.__logger.debug('Successfuly renewed the token') + else: + response.raise_for_status() + self.__logger.error(f'Could not renew token. {response.text}') + return False return True # ============================================================================== From 1649d4a63b43de76327b0882f4eb5c5e0b0306f3 Mon Sep 17 00:00:00 2001 From: graber Date: Wed, 9 Jun 2021 11:33:41 +0200 Subject: [PATCH 4/6] UnitTest + refactorings --- aciClient/aci.py | 56 ++++++++++++++++++++++++------------------------ test/test_aci.py | 26 ++++++++++++++++++++++ 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/aciClient/aci.py b/aciClient/aci.py index 4c68b8a..0a33d02 100644 --- a/aciClient/aci.py +++ b/aciClient/aci.py @@ -38,11 +38,19 @@ def __init__(self, apicIp, apicUser, apicPasword, refresh=False): 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 # ============================================================================== @@ -69,14 +77,10 @@ def login(self) -> bool: self.token = response.json()['imdata'][0]['aaaLogin']['attributes']['token'] self.__logger.debug('Successful get Token from APIC') - + if self.refresh_auto: - 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() - return True + self.__refresh_session_timer(response=response) + return True # ============================================================================== # logout @@ -90,14 +94,13 @@ def logout(self): self.refresh_thread.cancel() self.postJson(jsonData={'aaaUser': {'attributes': {'name': self.apicUser}}}, url='aaaLogout.json') self.__logger.debug('Logout from APIC sucessfull') - + # ============================================================================== # renew cookie auto (aaaRefresh) # ============================================================================== def renewCookie_auto(self): self.__logger.debug('renewCookie called') response = self.session.get(self.baseUrl + 'aaaRefresh.json', verify=False) - # ============================================================================== # renew cookie (aaaRefresh) @@ -108,10 +111,7 @@ def renewCookie(self) -> bool: if response.status_code == 200: if self.refresh_auto: - 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'refresh_auto on, next renew in {self.refresh_next-self.refresh_offset}') - self.refresh_thread.start() + self.__refresh_session_timer(response=response) self.token = response.json()['imdata'][0]['aaaLogin']['attributes']['token'] self.__logger.debug('Successfuly renewed the token') else: @@ -233,18 +233,18 @@ def snapshot(self, description="snapshot") -> bool: json_payload = [ { - "configExportP": { - "attributes": { - "adminSt": "triggered", - "descr": f"by aciClient - {description}", - "dn": "uni/fabric/configexp-netcloud-aciclient", - "format": "json", - "includeSecureFields": "yes", - "maxSnapshotCount": "global-limit", - "name": "netcloud-aciclient", - "nameAlias": "", - "snapshot": "yes", - "targetDn": "" + "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": "" } } } @@ -252,8 +252,8 @@ def snapshot(self, description="snapshot") -> bool: response = self.postJson(json_payload) if response == 200: - self.__logger.debug(f'snapshot created and triggered') + self.__logger.debug('snapshot created and triggered') return True else: - self.__logger.error(f'snapshot creation not succesfull: {response.text}') - return False \ No newline at end of file + self.__logger.error(f'snapshot creation not succesfull: {response}') + return False diff --git a/test/test_aci.py b/test/test_aci.py index d158afa..ef1a4ab 100644 --- a/test/test_aci.py +++ b/test/test_aci.py @@ -194,3 +194,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 From eab946940cfd5fd9ac1d5c3f3d3aeb368c0e48c0 Mon Sep 17 00:00:00 2001 From: kaelin Date: Wed, 9 Jun 2021 15:31:45 +0200 Subject: [PATCH 5/6] unittest for snapshot & session refresh --- aciClient/aci.py | 13 ++++--------- test/test_aci.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/aciClient/aci.py b/aciClient/aci.py index 0a33d02..48c9f06 100644 --- a/aciClient/aci.py +++ b/aciClient/aci.py @@ -66,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 @@ -95,13 +95,6 @@ def logout(self): self.postJson(jsonData={'aaaUser': {'attributes': {'name': self.apicUser}}}, url='aaaLogout.json') self.__logger.debug('Logout from APIC sucessfull') - # ============================================================================== - # renew cookie auto (aaaRefresh) - # ============================================================================== - def renewCookie_auto(self): - self.__logger.debug('renewCookie called') - response = self.session.get(self.baseUrl + 'aaaRefresh.json', verify=False) - # ============================================================================== # renew cookie (aaaRefresh) # ============================================================================== @@ -115,8 +108,10 @@ def renewCookie(self) -> bool: self.token = response.json()['imdata'][0]['aaaLogin']['attributes']['token'] self.__logger.debug('Successfuly renewed the token') else: - response.raise_for_status() + 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 diff --git a/test/test_aci.py b/test/test_aci.py index ef1a4ab..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': [ From 31d86d53231bdb80c5311b2a3aff9a3a7f82eca7 Mon Sep 17 00:00:00 2001 From: dka-li <57945737+dka-li@users.noreply.github.com> Date: Tue, 31 Aug 2021 11:44:35 +0200 Subject: [PATCH 6/6] Update README.md Added authentication refresh to readme --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ba959c0..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, refresh=True) +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