diff --git a/README.rst b/README.rst index 23f43cb..7ffedea 100644 --- a/README.rst +++ b/README.rst @@ -172,22 +172,20 @@ standard form of connection. Multiple examples below demonstrate connecting to C CVaaS ----- -CVaaS is CloudVision as a Service. Users with CVaaS have two options for connecting to CVP with REST APIs. +CVaaS is CloudVision as a Service. Users with CVaaS must use a REST API token for accessing CVP with REST APIs. -1. Local CVP users with username/password login. + In the case where users authenticate with CVP (CVaaS) using Oauth a REST API token is required to be generated + and used for running REST APIs. In this case no username/password login is necessary, but the API token + (via api_token parameter) must be provided to cvprac client with the is_cvaas parameter. + In the case that the api_token is used for REST APIs the username and password will be ignored and + the tenant parameter is not needed. - In order to use username/password login with CVaaS the user must be a user locally created within CVP. - This option looks very similar to a connection to an On Premises CVP cluster with a couple other options - (is_cvaas and tenant), required by CVaaS. +An example of a CVaaS connection is shown below. -2. Oauth users with REST API token. - - In the case where users authenticate with CVP using Oauth a REST API token is required to be generated and - used for running REST APIs. In this case no login is necessary, but the API token must be provided to - cvprac client with the is_cvaas parameter. In the case that the cvaas_token is used for REST APIs the - username and password will be ignored and the tenant parameter is not needed. - -Examples for both types of CVaaS connections are shown below. +Note that the token parameter was previously cvaas_token but this has been converted to api_token because +tokens are also available for usage with On Prem CVP deployments. The api_token parameter name is more +generic in this sense. If you are using the cvaas_token parameter please convert to api_token because the +cvaas_token parameter will be deprecated in the future. CVP Version Handling @@ -235,26 +233,14 @@ Same example as above using the API method: {u'version': u'2016.1.0'} >>> -Same example as above but connecting to CVaaS with a local CVP username/password: - -:: - - >>> from cvprac.cvp_client import CvpClient - >>> clnt = CvpClient() - >>> clnt.connect(nodes=['cvaas'], username='cvp_local_user', password='cvp_local_word', is_cvaas=True, tenant='user org/tenant') - >>> result = clnt.api.get_cvp_info() - >>> print result - {u'version': u'cvaas'} - >>> - Same example as above but connecting to CVaaS with a token: -Note that the username and password parameters are required by the connect function but will be ignored when using cvaas_token: +Note that the username and password parameters are required by the connect function but will be ignored when using api_token: :: >>> from cvprac.cvp_client import CvpClient >>> clnt = CvpClient() - >>> clnt.connect(nodes=['cvaas'], username='', password='', is_cvaas=True, cvaas_token='user token') + >>> clnt.connect(nodes=['cvaas'], username='', password='', is_cvaas=True, api_token='user token') >>> result = clnt.api.get_cvp_info() >>> print result {u'version': u'cvaas'} diff --git a/VERSION b/VERSION index 90a27f9..af0b7dd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.5 +1.0.6 diff --git a/cvprac/__init__.py b/cvprac/__init__.py index a1cdd74..e56ef8a 100644 --- a/cvprac/__init__.py +++ b/cvprac/__init__.py @@ -32,5 +32,5 @@ ''' RESTful API Client class for Cloudvision(R) Portal ''' -__version__ = '1.0.5' +__version__ = '1.0.6' __author__ = 'Arista Networks, Inc.' diff --git a/cvprac/cvp_api.py b/cvprac/cvp_api.py index 02b6e8f..1c42ddd 100644 --- a/cvprac/cvp_api.py +++ b/cvprac/cvp_api.py @@ -997,10 +997,7 @@ def get_configlets_by_device_id(self, mac, start=0, end=0): configlets (list): The list of configlets applied to the device ''' self.log.debug('get_configlets_by_device: mac: %s' % mac) - data = self.clnt.get('/provisioning/getConfigletsByNetElementId.do?' - 'netElementId=%s&queryParam=&startIndex=%d&' - 'endIndex=%d' % (mac, start, end), - timeout=self.request_timeout) + data = self.get_configlets_by_netelement_id(mac, start, end) return data['configletList'] def add_configlet_builder(self, name, config, draft=False, form=None): @@ -1104,6 +1101,65 @@ def update_configlet(self, config, key, name, wait_task_ids=False): return self.clnt.post('/configlet/updateConfiglet.do', data=body, timeout=self.request_timeout) + def update_configlet_builder(self, name, key, config, draft=False, + wait_for_task=False): + ''' Update an existing configlet builder. + Args: + config (str): Contents of the configlet builder configuration + key: (str): key/id of the configlet builder to be updated + name: (str): name of the configlet builder + draft (boolean): is update a draft + wait_for_task (boolean): wait for task IDs to be generated + ''' + data = { + "name": name, + "waitForTaskIds": wait_for_task, + "data": { + "main_script": { + "data": config + } + } + } + debug_str = 'update_configlet_builder:' \ + ' config: {} key: {} name: {} ' + self.log.debug(debug_str.format(config, key, name)) + # Update the configlet builder + url_string = '/configlet/updateConfigletBuilder.do?' \ + 'isDraft={}&id={}&action=save' + return self.clnt.post(url_string.format(draft, key), + data=data, timeout=self.request_timeout) + + def update_reconcile_configlet(self, device_mac, config, key, name, + reconciled=False): + ''' Update the reconcile configlet. + + Args: + device_mac (str): Mac address/Key for device whose reconcile + configlet is being updated + config (str): Reconciled config statements + key (str): Reconcile Configlet key + name (str): Reconcile Configlet name + reconciled (boolean): Wait for task IDs to generate + + Returns: + data (dict): Contains success or failure message + ''' + log_str = ('update_reconcile_configlet:' + ' device_mac: {} config: {} key: {} name: {}') + self.log.debug(log_str.format(device_mac, config, key, name)) + + url_str = ('/provisioning/updateReconcileConfiglet.do?' + 'netElementId={}') + body = { + 'config': config, + 'key': key, + 'name': name, + 'reconciled': reconciled, + 'unCheckedLines': '', + } + return self.clnt.post(url_str.format(device_mac), data=body, + timeout=self.request_timeout) + def add_note_to_configlet(self, key, note): ''' Add a note to a configlet. @@ -1658,8 +1714,23 @@ def delete_container(self, container_name, container_key, parent_name, 'parent: %s parent_key: %s' % (container_name, container_key, parent_name, parent_key)) - return self._container_op(container_name, container_key, parent_name, + resp = self._container_op(container_name, container_key, parent_name, parent_key, 'delete') + # As of CVP version 2020.1 the addTempAction.do API endpoint stopped + # raising an Error when attempting to delete a container with children. + # To account for this try to see if the container being deleted + # still exists after the attempted delete. If it still exists + # raise an error similar to how CVP behaved prior to CVP 2020.1 + try: + still_exists = self.get_container_by_id(container_key) + except CvpApiError as error: + if 'Invalid Container id' in error.msg: + return resp + else: + raise + if still_exists is not None: + raise CvpApiError('Container was not deleted. Check for children') + return resp def get_parent_container_for_device(self, device_mac): ''' Add the container to the specified parent. @@ -1697,9 +1768,9 @@ def move_device_to_container(self, app_name, device, container, Ex: {u'data': {u'status': u'success', u'taskIds': []}} ''' - info = '%s moving device %s to container %s' % (app_name, - device['fqdn'], - container['name']) + info = 'Device Add {} to container {} by {}'.format(device['fqdn'], + container['name'], + app_name) self.log.debug('Attempting to move device %s to container %s' % (device['fqdn'], container['name'])) if 'parentContainerId' in device: diff --git a/cvprac/cvp_client.py b/cvprac/cvp_client.py index 48fdc95..f759ed4 100644 --- a/cvprac/cvp_client.py +++ b/cvprac/cvp_client.py @@ -90,6 +90,7 @@ >>> ''' +import os import re import json import logging @@ -288,6 +289,14 @@ def connect(self, nodes, username, password, connect_timeout=10, if not isinstance(nodes, list): raise TypeError('nodes argument must be a list') + for idx, _ in enumerate(nodes): + if (os.environ.get('CURRENT_NODE_IP') and + nodes[idx] in ['127.0.0.1', 'localhost']): + # We set this env in script-executor container. + # Mask localhost or 127.0.0.1 with node IP if this + # is called from configlet builder scripts. + nodes[idx] = os.environ.get('CURRENT_NODE_IP') + self.cert = cert self.nodes = nodes self.node_cnt = len(nodes) @@ -406,7 +415,7 @@ def _is_good_response(self, response, prefix): msg = ('%s: Request Error: session logged out' % prefix) raise CvpSessionLogOutError(msg) - joutput = response.json() + joutput = json_decoder(response.text) err_code_val = self._finditem(joutput, 'errorCode') if err_code_val: if 'errorMessage' in joutput: @@ -472,7 +481,10 @@ def _login(self): if self.api_token is not None: return self._set_headers_api_token() elif self.is_cvaas: - return self._login_cvaas() + raise CvpLoginError('CVaaS only supports API token authentication.' + ' Please create an API token and provide it' + ' via the api_token parameter in combination' + ' with the is_cvaas parameter') return self._login_on_prem() def _login_on_prem(self): @@ -512,47 +524,6 @@ def _login_on_prem(self): self.cookies = response.cookies self.headers['APP_SESSION_ID'] = response.json()['sessionId'] - def _login_cvaas(self): - ''' Make a POST request to CVaaS login authentication. - An error can be raised from the post method call or the - _check_response_status method call. Any errors raised would be - a good reason not to use this host. - - Raises: - ConnectionError: A ConnectionError is raised if there was a - network problem (e.g. DNS failure, refused connection, etc) - CvpApiError: A CvpApiError is raised if there was a JSON error. - CvpRequestError: A CvpRequestError is raised if the request - is not properly constructed. - CvpSessionLogOutError: A CvpSessionLogOutError is raised if - response from server indicates session was logged out. - HTTPError: A HTTPError is raised if there was an invalid HTTP - response. - ReadTimeout: A ReadTimeout is raised if there was a request - timeout when reading from the connection. - Timeout: A Timeout is raised if there was a request timeout. - TooManyRedirects: A TooManyRedirects is raised if the request - exceeds the configured number of maximum redirections - ValueError: A ValueError is raised when there is no valid - CVP session. This occurs because the previous get or post - request failed and no session could be established to a - CVP node. Destroy the class and re-instantiate. - ''' - # For local CVaaS users no token is needed and the local username - # and password can be used with the below Login API. - url = (self.url_prefix_short + - '/api/v1/oauth?provider=local&next=false') - cvaas_auth = {"org": self.tenant, - "name": self.authdata['userId'], - "password": self.authdata['password']} - response = self.session.post(url, - data=json.dumps(cvaas_auth), - headers=self.headers, - timeout=self.connect_timeout, - verify=self.cert) - self._check_response_status(response, 'Authenticate: %s' % url) - self.cookies = response.cookies - def _set_headers_api_token(self): ''' Sets headers with API token instead of making a call to login API. ''' @@ -568,7 +539,7 @@ def logout(self): :return: ''' - response = self.session.post('/login/logout.do') + response = self.post('/login/logout.do') if response['data'] == 'success': self.log.info('User logged out.') self.session = None @@ -677,8 +648,14 @@ def _make_request(self, req_type, url, timeout, data=None, except ValueError as error: self.log.debug('Error trying to decode request response %s', error) - self.log.debug('Attempt to return response text') - resp_data = dict(data=response.text) + if 'Extra data' in str(error): + self.log.debug('Found multiple objects in response data.' + 'Attempt to decode') + decoded_data = json_decoder(response.text) + resp_data = dict(data=decoded_data) + else: + self.log.debug('Attempt to return response text') + resp_data = dict(data=response.text) else: self.log.debug('Received no response for request %s %s', req_type, url) @@ -962,3 +939,21 @@ def _finditem(self, obj, key): if item is not None: break return item + + +def json_decoder(data): + ''' Check for ... + ''' + decoder = json.JSONDecoder() + position = 0 + decoded_data = [] + while True: + try: + obj, position = decoder.raw_decode(data, position) + decoded_data.append(obj) + position += 1 + except ValueError: + break + if len(decoded_data) == 1: + return decoded_data[0] + return decoded_data diff --git a/docs/release-notes-1.0.6.rst b/docs/release-notes-1.0.6.rst new file mode 100644 index 0000000..170647f --- /dev/null +++ b/docs/release-notes-1.0.6.rst @@ -0,0 +1,26 @@ +###### +v1.0.6 +###### + +2021-5-17 + +New Modules +^^^^^^^^^^^ + +* Started to add api method update_configlet_builder and add test.. (`a32dd7a `_) [`dbm79 `_] +* Added function and test for API endpoint updateReconcileConfiglet.do. (`7e90de9 `_) [`mharista `_] + +Enhancements +^^^^^^^^^^^^ + +* Add client handling for new resource API REST bindings that return multiple objects in response data. (`bea2d28 `_) [`mharista `_] + +Fixed +^^^^^ + +* Fix client logout function to use cvprac client post function instead of session post function. (`abaf257 `_) [`mharista `_] +* Mask localhost/127.0.0.1 with node ip for cb scripts. (`d45ac6e `_) [`Rajat Bajaj `_] +* Updating info string to tackle backend inconsistent state when moving devices from the Undefined container. (`82ea8b9 `_) [`noredistribution `_] +* Remove CVaaS un/pw login. Only API tokens for CVaaS now. (`f9fd6b5 `_) [`mharista `_] +* Update redundant functions to self reference. (`0095b00 `_) [`mharista `_] +* Add exception when attempting to delete container with children for CVP versions 2020.1 and beyond. (`35bb566 `_) [`mharista `_] diff --git a/test/system/test_cvp_api.py b/test/system/test_cvp_api.py index 965630c..5a53e9f 100644 --- a/test/system/test_cvp_api.py +++ b/test/system/test_cvp_api.py @@ -722,7 +722,7 @@ def _create_configlet_builder(self, name, config, draft, form=None): self.assertIsNotNone(key) return key - def test_api_add_delete_configlet_builder(self): + def test_api_add_update_delete_configlet_builder(self): ''' Verify add_configlet_builder and delete_configlet Will test a configlet builder with form data and without ''' @@ -733,7 +733,7 @@ def test_api_add_delete_configlet_builder(self): "\n\nprint('hostname {0}'.format(dev_host))") draft = False - form = [{ + forms = [{ 'fieldId': 'txt_hostname', 'fieldLabel': 'Hostname', 'type': 'Text box', @@ -744,7 +744,7 @@ def test_api_add_delete_configlet_builder(self): }] # Add the configlet builder - key = self._create_configlet_builder(name, config, draft, form) + key = self._create_configlet_builder(name, config, draft, forms) key2 = self._create_configlet_builder(name2, config, draft) # Verify the configlet builder was added @@ -760,10 +760,27 @@ def test_api_add_delete_configlet_builder(self): result2 = self.api.get_configlet_by_name(name2) self.assertIsNotNone(result2) self.assertEqual(result2['name'], name2) - # self.assertEqual(result2['config'], config) + # self.assertIn("dev_host", result2['config']) + # self.assertNotIn("device_hostname", result2['config']) self.assertEqual(result2['type'], 'Builder') self.assertEqual(result2['key'], key2) + # Update No Form data + config2 = ("from cvplibrary import Form\n\n" + + "device_hostname = Form.getFieldById" + + "('txt_hostname').getValue()" + + "\n\nprint('Hostname {0}'.format(device_hostname))") + update_result2 = self.api.update_configlet_builder(name2, key2, + config2) + self.assertIsNotNone(update_result2) + update_info2 = self.api.get_configlet_by_name(name2) + self.assertIsNotNone(update_info2) + self.assertEqual(update_info2['name'], name2) + # self.assertIn("device_hostname", update_info2['config']) + # self.assertNotIn("dev_host", update_info2['config']) + self.assertEqual(update_info2['type'], 'Builder') + self.assertEqual(update_info2['key'], key2) + # Delete the configlet builder self.api.delete_configlet(name, key) self.api.delete_configlet(name2, key2) @@ -775,6 +792,30 @@ def test_api_add_delete_configlet_builder(self): with self.assertRaises(CvpApiError): self.api.get_configlet_by_name(name2) + def test_api_update_reconcile_configlet(self): + ''' Verify update_reconcile_configlet + ''' + rec_configlet_name = 'RECONCILE_{}'.format(self.device['ipAddress']) + # Verify this reconcile configlet doesn't already exist + with self.assertRaises(CvpApiError): + self.api.get_configlet_by_name(rec_configlet_name) + config = 'lldp timer 25' + # create reconcile configlet + result = self.api.update_reconcile_configlet(self.device['key'], + config, "", + rec_configlet_name, + reconciled=True) + self.assertIsNotNone(result) + # Verify this reconcile configlet exists + new_rec_configlet = self.api.get_configlet_by_name(rec_configlet_name) + self.assertIsNotNone(new_rec_configlet) + self.assertEqual(new_rec_configlet['config'], 'lldp timer 25\n') + self.api.delete_configlet(new_rec_configlet['name'], + new_rec_configlet['key']) + # Verify this reconcile configlet has been removed + with self.assertRaises(CvpApiError): + self.api.get_configlet_by_name(rec_configlet_name) + def test_api_get_device_image_info(self): ''' Verify get_device_image_info ''' @@ -1010,6 +1051,73 @@ def test_api_containers(self): result = self.api.search_topology(name) self.assertEqual(len(result['containerList']), 0) + def test_api_delete_container_with_children(self): + ''' Verify delete_container returns a failure when attempting to delete + a container with a child container + ''' + name = 'CVPRACTEST' + parent = self.container + # Verify create container + self.api.add_container(name, parent['name'], parent['key']) + + # Verify get container for exact container name returns only that + # container + new_container = self.api.get_container_by_name(name) + self.assertIsNotNone(new_container) + self.assertEqual(new_container['name'], name) + + child_name = 'CVPRACTESTCHILD' + self.api.add_container(child_name, new_container['name'], + new_container['key']) + # Verify get container for exact container name returns only that + # container + new_child_container = self.api.get_container_by_name(child_name) + self.assertIsNotNone(new_child_container) + self.assertEqual(new_child_container['name'], child_name) + + # Verify failure status when attempting to delete new parent container + # with self.assertRaises(CvpApiError): + # self.api.delete_container(new_container['name'], + # new_container['key'], + # parent['name'], parent['key']) + try: + self.api.delete_container(new_container['name'], + new_container['key'], + parent['name'], parent['key']) + except CvpApiError as error: + if 'Only empty container can be deleted' in error.msg: + pprint('CVP Version {} raises error when attempting to' + ' delete container with' + ' children'.format(self.clnt.apiversion)) + elif 'Container was not deleted. Check for children' in error.msg: + pprint('CVP Version {} does not raise error when attempting to' + ' delete container with' + ' children'.format(self.clnt.apiversion)) + + # Delete child container first + resp = self.api.delete_container(new_child_container['name'], + new_child_container['key'], + new_container['name'], + new_container['key']) + self.assertIsNotNone(resp) + self.assertIn('data', resp) + self.assertIn('status', resp['data']) + self.assertEqual('success', resp['data']['status']) + result = self.api.search_topology(new_child_container['name']) + self.assertEqual(len(result['containerList']), 0) + + # Now delete new parent container + resp = self.api.delete_container(new_container['name'], + new_container['key'], + parent['name'], + parent['key']) + self.assertIsNotNone(resp) + self.assertIn('data', resp) + self.assertIn('status', resp['data']) + self.assertEqual('success', resp['data']['status']) + result = self.api.search_topology(new_container['name']) + self.assertEqual(len(result['containerList']), 0) + def test_api_container_url_encode_name(self): ''' Verify special characters can be used in container names ''' diff --git a/test/system/test_cvp_client.py b/test/system/test_cvp_client.py index a17eb86..d92ffa9 100644 --- a/test/system/test_cvp_client.py +++ b/test/system/test_cvp_client.py @@ -217,6 +217,14 @@ def test_connect_port_bad(self): self.clnt.connect([dut['node']], dut['username'], dut['password'], port=700) + def test_connect_from_cb_script(self): + ''' Verify connection works from configlet builder scripts + ''' + dut = self.duts[0] + os.environ["CURRENT_NODE_IP"] = dut['node'] + self.clnt.connect(['localhost'], dut['username'], dut['password']) + self.clnt.connect(['127.0.0.1'], dut['username'], dut['password']) + def test_get_not_connected(self): ''' Verify get with no connection raises a ValueError '''