Skip to content

Commit 4f7fb9b

Browse files
Merge pull request #9 from dka-li/master
Snapshot creation and automatic token exipration handling
2 parents f162ffe + 31d86d5 commit 4f7fb9b

File tree

6 files changed

+153
-20
lines changed

6 files changed

+153
-20
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
# Changelog
22
> * Pre-v1.00, 11.2020 -- Initial release - Marcel Zehnder
33
> * v1.00, 11.2020 -- Adjustments for OpenSource - Andreas Graber
4+
> * v1.03, 06.2021 -- Snapshot creation added - Dario Kaelin

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ A python wrapper to the Cisco ACI REST-API.
77
We support Python 3.6 and up. Python 2 is not supported and there is no plan to add support for it.
88

99
## Installation
10-
``pip install aciclient``
10+
``pip install aciClient``
1111

1212
## Installation for Developing
1313
```
@@ -28,7 +28,7 @@ import logging
2828
logging.basicConfig(level=logging.INFO)
2929
logger = logging.getLogger(__name__)
3030

31-
aciclient = aciClient.ACI(apic_hostname, apic_username, apic_password)
31+
aciclient = aciClient.ACI(apic_hostname, apic_username, apic_password, refresh=False)
3232
try:
3333
aciclient.login()
3434

@@ -41,6 +41,11 @@ except Exception as e:
4141
logger.exception("Stack Trace")
4242

4343
```
44+
For automatic authentication token refresh you can set refresh to True
45+
```python
46+
aciclient = aciClient.ACI(apic_hostname, apic_username, apic_password, refresh=True)
47+
```
48+
4449

4550
### Certificate/signature
4651
```python
@@ -88,6 +93,11 @@ aciclient.postJson(config)
8893
aciclient.deleteMo('uni/tn-XYZ')
8994
```
9095

96+
### create snapshot
97+
```python
98+
aci.snapshot('test')
99+
```
100+
91101
## Testing
92102

93103
```
@@ -104,6 +114,7 @@ of conduct, and the process for submitting pull requests to this project.
104114
* **Marcel Zehnder** - *Initial work*
105115
* **Andreas Graber** - *Migration to open source*
106116
* **Richard Strnad** - *Paginagtion for large requests, various small stuff*
117+
* **Dario Kaelin** - *Added snapshot creation*
107118

108119
## License
109120

aciClient/aci.py

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import logging
1111
import json
1212
import requests
13+
import threading
1314

1415
# The modules are named different in python2/python3...
1516
try:
@@ -27,7 +28,7 @@ class ACI:
2728
# ==============================================================================
2829
# constructor
2930
# ==============================================================================
30-
def __init__(self, apicIp, apicUser, apicPasword):
31+
def __init__(self, apicIp, apicUser, apicPasword, refresh=False):
3132
self.__logger.debug('Constructor called')
3233
self.apicIp = apicIp
3334
self.apicUser = apicUser
@@ -36,25 +37,36 @@ def __init__(self, apicIp, apicUser, apicPasword):
3637
self.baseUrl = 'https://' + self.apicIp + '/api/'
3738
self.__logger.debug(f'BaseUrl set to: {self.baseUrl}')
3839

40+
self.refresh_auto = refresh
41+
self.refresh_next = None
42+
self.refresh_thread = None
43+
self.refresh_offset = 30
3944
self.session = None
4045
self.token = None
4146

47+
def __refresh_session_timer(self, response):
48+
self.__logger.debug(f'refreshing the token {self.refresh_offset}s before it expires')
49+
self.refresh_next = int(response.json()['imdata'][0]['aaaLogin']['attributes']['refreshTimeoutSeconds'])
50+
self.refresh_thread = threading.Timer(self.refresh_next - self.refresh_offset, self.renewCookie)
51+
self.__logger.debug(f'starting thread to refresh token in {self.refresh_next - self.refresh_offset}s')
52+
self.refresh_thread.start()
53+
4254
# ==============================================================================
4355
# login
4456
# ==============================================================================
4557
def login(self) -> bool:
4658
self.__logger.debug('login called')
4759

4860
self.session = requests.Session()
49-
self.__logger.info('Session Object Created')
61+
self.__logger.debug('Session Object Created')
5062

5163
# create credentials structure
5264
userPass = json.dumps({'aaaUser': {'attributes': {'name': self.apicUser, 'pwd': self.apicPassword}}})
5365

5466
self.__logger.info(f'Login to apic {self.baseUrl}')
5567
response = self.session.post(self.baseUrl + 'aaaLogin.json', data=userPass, verify=False, timeout=5)
5668

57-
# Don't rise an exception for 401
69+
# Don't raise an exception for 401
5870
if response.status_code == 401:
5971
self.__logger.error(f'Login not possible due to Error: {response.text}')
6072
self.session = False
@@ -64,15 +76,24 @@ def login(self) -> bool:
6476
response.raise_for_status()
6577

6678
self.token = response.json()['imdata'][0]['aaaLogin']['attributes']['token']
67-
self.__logger.info('Successful get Token from APIC')
79+
self.__logger.debug('Successful get Token from APIC')
80+
81+
if self.refresh_auto:
82+
self.__refresh_session_timer(response=response)
6883
return True
6984

7085
# ==============================================================================
7186
# logout
7287
# ==============================================================================
7388
def logout(self):
74-
self.__logger.debug('Logout from APIC...')
89+
self.__logger.debug('logout called')
90+
self.refresh_auto = False
91+
if self.refresh_thread is not None:
92+
if self.refresh_thread.is_alive():
93+
self.__logger.debug('Stoping refresh_auto thread')
94+
self.refresh_thread.cancel()
7595
self.postJson(jsonData={'aaaUser': {'attributes': {'name': self.apicUser}}}, url='aaaLogout.json')
96+
self.__logger.debug('Logout from APIC sucessfull')
7697

7798
# ==============================================================================
7899
# renew cookie (aaaRefresh)
@@ -81,11 +102,17 @@ def renewCookie(self) -> bool:
81102
self.__logger.debug('Renew Cookie called')
82103
response = self.session.post(self.baseUrl + 'aaaRefresh.json', verify=False)
83104

84-
# Raise Exception for an error 4xx and 5xx
85-
response.raise_for_status()
86-
87-
self.token = response.json()['imdata'][0]['aaaLogin']['attributes']['token']
88-
self.__logger.info('Successful renewed the Token')
105+
if response.status_code == 200:
106+
if self.refresh_auto:
107+
self.__refresh_session_timer(response=response)
108+
self.token = response.json()['imdata'][0]['aaaLogin']['attributes']['token']
109+
self.__logger.debug('Successfuly renewed the token')
110+
else:
111+
self.token = False
112+
self.refresh_auto = False
113+
self.__logger.error(f'Could not renew token. {response.text}')
114+
response.raise_for_status()
115+
return False
89116
return True
90117

91118
# ==============================================================================
@@ -108,10 +135,10 @@ def getJson(self, uri, subscription=False) -> {}:
108135

109136
if response.ok:
110137
responseJson = response.json()
111-
self.__logger.info(f'Successful get Data from APIC: {responseJson}')
138+
self.__logger.debug(f'Successful get Data from APIC: {responseJson}')
112139
if subscription:
113140
subscription_id = responseJson['subscriptionId']
114-
self.__logger.info(f'Returning Subscription Id: {subscription_id}')
141+
self.__logger.debug(f'Returning Subscription Id: {subscription_id}')
115142
return subscription_id
116143
return responseJson['imdata']
117144

@@ -120,7 +147,7 @@ def getJson(self, uri, subscription=False) -> {}:
120147
self.__logger.error(f'Error 400 during get occured: {resp_text}')
121148
if resp_text == 'Unable to process the query, result dataset is too big':
122149
# Dataset was too big, we try to grab all the data with pagination
123-
self.__logger.info(f'Trying with Pagination, uri: {uri}')
150+
self.__logger.debug(f'Trying with Pagination, uri: {uri}')
124151
return self.getJsonPaged(uri)
125152
return resp_text
126153
else:
@@ -148,7 +175,7 @@ def getJsonPaged(self, uri) -> {}:
148175

149176
if response.ok:
150177
responseJson = response.json()
151-
self.__logger.info(f'Successful get Data from APIC: {responseJson}')
178+
self.__logger.debug(f'Successful get Data from APIC: {responseJson}')
152179
if responseJson['imdata']:
153180
return_data.extend(responseJson['imdata'])
154181
else:
@@ -170,7 +197,7 @@ def postJson(self, jsonData, url='mo.json') -> {}:
170197
self.__logger.debug(f'Post Json called data: {jsonData}')
171198
response = self.session.post(self.baseUrl + url, verify=False, data=json.dumps(jsonData, sort_keys=True))
172199
if response.status_code == 200:
173-
self.__logger.info(f'Successful Posted Data to APIC: {response.json()}')
200+
self.__logger.debug(f'Successful Posted Data to APIC: {response.json()}')
174201
return response.status_code
175202
elif response.status_code == 400:
176203
resp_text = '400: ' + response.json()['imdata'][0]['error']['attributes']['text']
@@ -192,3 +219,36 @@ def deleteMo(self, dn) -> int:
192219
response.raise_for_status()
193220

194221
return response.status_code
222+
223+
# ==============================================================================
224+
# snapshot
225+
# ==============================================================================
226+
def snapshot(self, description="snapshot") -> bool:
227+
self.__logger.debug(f'snapshot called {description}')
228+
229+
json_payload = [
230+
{
231+
"configExportP": {
232+
"attributes": {
233+
"adminSt": "triggered",
234+
"descr": f"by aciClient - {description}",
235+
"dn": "uni/fabric/configexp-aciclient",
236+
"format": "json",
237+
"includeSecureFields": "yes",
238+
"maxSnapshotCount": "global-limit",
239+
"name": "aciclient",
240+
"nameAlias": "",
241+
"snapshot": "yes",
242+
"targetDn": ""
243+
}
244+
}
245+
}
246+
]
247+
248+
response = self.postJson(json_payload)
249+
if response == 200:
250+
self.__logger.debug('snapshot created and triggered')
251+
return True
252+
else:
253+
self.__logger.error(f'snapshot creation not succesfull: {response}')
254+
return False

aciClient/aciCertClient.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def getJson(self, uri) -> {}:
5555
# Raise Exception if http Error occurred
5656
r.raise_for_status()
5757

58-
self.__logger.info(f'Successful get Data from APIC: {r.json()}')
58+
self.__logger.debug(f'Successful get Data from APIC: {r.json()}')
5959
return r.json()['imdata']
6060

6161
# ==============================================================================
@@ -73,7 +73,7 @@ def postJson(self, jsonData):
7373
r.raise_for_status()
7474

7575
if r.status_code == 200:
76-
self.__logger.info(f'Successful Posted Data to APIC: {r.json()}')
76+
self.__logger.debug(f'Successful Posted Data to APIC: {r.json()}')
7777
return r.status_code
7878
else:
7979
self.__logger.error(f'Error during get occured: {r.json()}')

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
long_description = f.read()
88

99
setup(name='aciClient',
10-
version='1.2',
10+
version='1.3',
1111
description='aci communication helper class',
1212
url='http://www.netcloud.ch',
1313
author='mze',

test/test_aci.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from aciClient.aci import ACI
1212
import pytest
13+
import time
1314

1415
__BASE_URL = 'testing-apic.ncdev.ch'
1516

@@ -46,6 +47,40 @@ def test_login_404_exception(requests_mock):
4647
with pytest.raises(RequestException):
4748
resp = aci.login()
4849

50+
def test_login_refresh_ok(requests_mock):
51+
requests_mock.post(f'https://{__BASE_URL}/api/aaaLogin.json', json={'imdata': [
52+
{'aaaLogin': {'attributes': {'refreshTimeoutSeconds': '31', 'token':'tokenxyz'}}}
53+
]})
54+
requests_mock.post(f'https://{__BASE_URL}/api/aaaRefresh.json', json={
55+
'imdata': [
56+
{
57+
'aaaLogin': {
58+
'attributes': {
59+
'refreshTimeoutSeconds': '300',
60+
'token':'tokenabc'
61+
}
62+
}
63+
}
64+
]})
65+
requests_mock.post(f'https://{__BASE_URL}/api/aaaLogout.json', json={'imdata': []}, status_code=200)
66+
aci = ACI(apicIp=__BASE_URL, apicUser='admin', apicPasword='unkown', refresh=True)
67+
aci.login()
68+
token = aci.getToken()
69+
time.sleep(2)
70+
aci.logout()
71+
assert token != aci.getToken()
72+
73+
def test_login_refresh_nok(requests_mock):
74+
requests_mock.post(f'https://{__BASE_URL}/api/aaaLogin.json', json={'imdata': [
75+
{'aaaLogin': {'attributes': {'refreshTimeoutSeconds': '31', 'token':'tokenxyz'}}}
76+
]})
77+
requests_mock.post(f'https://{__BASE_URL}/api/aaaRefresh.json', json={
78+
'imdata': []}, status_code=403)
79+
aci = ACI(apicIp=__BASE_URL, apicUser='admin', apicPasword='unkown', refresh=True)
80+
aci.login()
81+
time.sleep(3)
82+
token = aci.getToken()
83+
assert not token
4984

5085
def test_renew_cookie_ok(requests_mock):
5186
requests_mock.post(f'https://{__BASE_URL}/api/aaaLogin.json', json={'imdata': [
@@ -194,3 +229,29 @@ def test_post_tenant_forbidden_exception(requests_mock):
194229
aci.login()
195230
with pytest.raises(RequestException):
196231
aci.postJson(post_data)
232+
233+
234+
def test_snapshot_ok(requests_mock):
235+
requests_mock.post(f'https://{__BASE_URL}/api/mo.json', json={"totalCount": "0", "imdata": []})
236+
requests_mock.post(f'https://{__BASE_URL}/api/aaaLogin.json', json={'imdata': [
237+
{'aaaLogin': {'attributes': {'token': 'tokenxyz'}}}
238+
]})
239+
240+
aci = ACI(apicIp=__BASE_URL, apicUser='admin', apicPasword='unkown')
241+
aci.login()
242+
resp = aci.snapshot(description='unit_test')
243+
assert resp
244+
245+
246+
def test_snapshot_nok(requests_mock):
247+
requests_mock.post(f'https://{__BASE_URL}/api/mo.json',
248+
json={"totalCount": "0", "imdata": [{"error": {"attributes": {"text": "Error UnitTest"}}}]},
249+
status_code=400)
250+
requests_mock.post(f'https://{__BASE_URL}/api/aaaLogin.json', json={'imdata': [
251+
{'aaaLogin': {'attributes': {'token': 'tokenxyz'}}}
252+
]})
253+
254+
aci = ACI(apicIp=__BASE_URL, apicUser='admin', apicPasword='unkown')
255+
aci.login()
256+
resp = aci.snapshot(description='unit_test')
257+
assert not resp

0 commit comments

Comments
 (0)