| # Copyright 2017-present Adtran, Inc. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT 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 structlog |
| from lxml import etree |
| from ncclient import manager |
| from ncclient.operations import RPCError |
| from ncclient.transport.errors import SSHError |
| from twisted.internet import defer, threads |
| from twisted.internet.defer import inlineCallbacks, returnValue |
| |
| log = structlog.get_logger('ncclient') |
| |
| ADTRAN_NS = 'http://www.adtran.com/ns/yang' |
| |
| |
| def adtran_module_url(module): |
| return '{}/{}'.format(ADTRAN_NS, module) |
| |
| |
| def phys_entities_rpc(): |
| return """ |
| <filter xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"> |
| <physical-entities-state xmlns="{}"> |
| <physical-entity/> |
| </physical-entities-state> |
| </filter> |
| """.format(adtran_module_url('adtran-physical-entities')) |
| |
| |
| class AdtranNetconfClient(object): |
| """ |
| Performs NETCONF requests |
| """ |
| def __init__(self, host_ip, port=830, username='', password='', timeout=10): |
| self._ip = host_ip |
| self._port = port |
| self._username = username |
| self._password = password |
| self._timeout = timeout |
| self._session = None |
| |
| def __str__(self): |
| return "AdtranNetconfClient {}@{}:{}".format(self._username, self._ip, self._port) |
| |
| @property |
| def capabilities(self): |
| """ |
| Get the server's NETCONF capabilities |
| |
| :return: (ncclient.capabilities.Capabilities) object representing the server's capabilities. |
| """ |
| return self._session.server_capabilities if self._session else None |
| |
| @property |
| def connected(self): |
| """ |
| Is this client connected to a NETCONF server |
| :return: (boolean) True if connected |
| """ |
| return self._session is not None and self._session.connected |
| |
| def connect(self, connect_timeout=None): |
| """ |
| Connect to the NETCONF server |
| |
| o To disable attempting publickey authentication altogether, call with |
| allow_agent and look_for_keys as False. |
| |
| o hostkey_verify enables hostkey verification from ~/.ssh/known_hosts |
| |
| :return: (deferred) Deferred request |
| """ |
| timeout = connect_timeout or self._timeout |
| |
| return threads.deferToThread(self._do_connect, timeout) |
| |
| def _do_connect(self, timeout): |
| try: |
| self._session = manager.connect(host=self._ip, |
| port=self._port, |
| username=self._username, |
| password=self._password, |
| allow_agent=False, |
| look_for_keys=False, |
| hostkey_verify=False, |
| timeout=timeout) |
| |
| except SSHError as e: |
| # Log and rethrow exception so any errBack is called |
| log.warn('SSHError-during-connect', e=e) |
| raise e |
| |
| except Exception as e: |
| # Log and rethrow exception so any errBack is called |
| log.exception('Connect-failed: {}', e=e) |
| raise e |
| |
| # If debug logging is enabled, decrease the level, DEBUG is a significant |
| # performance hit during response XML decode |
| |
| if log.isEnabledFor('DEBUG'): |
| log.setLevel('INFO') |
| |
| # TODO: ncclient also supports RaiseMode:NONE to limit exceptions. To set use: |
| # |
| # self._session.raise_mode = RaiseMode:NONE |
| # |
| # and the when you get a response back, you can check 'response.ok' to |
| # see if it is 'True' if it is not, you can enumerate the 'response.errors' |
| # list for more information |
| |
| return self._session |
| |
| def close(self): |
| """ |
| Close the connection to the NETCONF server |
| :return: (deferred) Deferred request |
| """ |
| s, self._session = self._session, None |
| |
| if s is None or not s.connected: |
| return defer.returnValue(True) |
| |
| return threads.deferToThread(self._do_close, s) |
| |
| def _do_close(self, old_session): |
| return old_session.close_session() |
| |
| @inlineCallbacks |
| def _reconnect(self): |
| try: |
| yield self.close() |
| except: |
| pass |
| |
| try: |
| yield self.connect() |
| except: |
| pass |
| |
| def get_config(self, source='running'): |
| """ |
| Get the configuration from the specified source |
| |
| :param source: (string) Configuration source, 'running', 'candidate', ... |
| |
| :return: (deferred) Deferred request that wraps the GetReply class |
| """ |
| if not self._session: |
| raise NotImplemented('No SSH Session') |
| |
| if not self._session.connected: |
| self._reconnect() |
| |
| return threads.deferToThread(self._do_get_config, source) |
| |
| def _do_get_config(self, source): |
| """ |
| Get the configuration from the specified source |
| |
| :param source: (string) Configuration source, 'running', 'candidate', ... |
| |
| :return: (GetReply) The configuration. |
| """ |
| return self._session.get_config(source) |
| |
| def get(self, payload): |
| """ |
| Get the requested data from the server |
| |
| :param payload: Payload/filter |
| :return: (deferred) for GetReply |
| """ |
| log.debug('get', filter=payload) |
| |
| if not self._session: |
| raise NotImplemented('No SSH Session') |
| |
| if not self._session.connected: |
| self._reconnect() |
| |
| return threads.deferToThread(self._do_get, payload) |
| |
| def _do_get(self, payload): |
| """ |
| Get the requested data from the server |
| |
| :param payload: Payload/filter |
| :return: (GetReply) response |
| """ |
| try: |
| log.debug('get', payload=payload) |
| response = self._session.get(payload) |
| # To get XML, use response.xml |
| log.debug('response', response=response) |
| |
| except RPCError as e: |
| log.exception('get', e=e) |
| raise |
| |
| return response |
| |
| def lock(self, source, lock_timeout): |
| """ |
| Lock the configuration system |
| :return: (deferred) for RpcReply |
| """ |
| log.info('lock', source=source, timeout=lock_timeout) |
| |
| if not self._session or not self._session.connected: |
| raise NotImplemented('TODO: Support auto-connect if needed') |
| |
| return threads.deferToThread(self._do_lock, source, lock_timeout) |
| |
| def _do_lock(self, source, lock_timeout): |
| """ |
| Lock the configuration system |
| """ |
| try: |
| response = self._session.lock(source, timeout=lock_timeout) |
| # To get XML, use response.xml |
| |
| except RPCError as e: |
| log.exception('lock', e=e) |
| raise |
| |
| return response |
| |
| def unlock(self, source): |
| """ |
| Get the requested data from the server |
| :param source: RPC request |
| |
| :return: (deferred) for RpcReply |
| """ |
| log.info('unlock', source=source) |
| |
| if not self._session or not self._session.connected: |
| raise NotImplemented('TODO: Support auto-connect if needed') |
| |
| return threads.deferToThread(self._do_unlock, source) |
| |
| def _do_unlock(self, source): |
| """ |
| Lock the configuration system |
| """ |
| try: |
| response = self._session.unlock(source) |
| # To get XML, use response.xml |
| |
| except RPCError as e: |
| log.exception('unlock', e=e) |
| raise |
| |
| return response |
| |
| @inlineCallbacks |
| def edit_config(self, config, target='running', default_operation='none', |
| test_option=None, error_option=None, ignore_delete_error=False): |
| """ |
| Loads all or part of the specified config to the target configuration datastore |
| with the ability to lock the datastore during the edit. |
| |
| :param config is the configuration, which must be rooted in the config element. |
| It can be specified either as a string or an Element.format="xml" |
| :param target is the name of the configuration datastore being edited |
| :param default_operation if specified must be one of { 'merge', 'replace', or 'none' } |
| :param test_option if specified must be one of { 'test_then_set', 'set' } |
| :param error_option if specified must be one of { 'stop-on-error', |
| 'continue-on-error', 'rollback-on-error' } The |
| 'rollback-on-error' error_option depends on the |
| :rollback-on-error capability. |
| :param ignore_delete_error: (bool) For some startup deletes/clean-ups, we do a |
| delete high up in the config to get whole lists. If |
| these lists are empty, this helps suppress any error |
| message from NETConf on failure to delete an empty list |
| |
| :return: (deferred) for RpcReply |
| """ |
| if not self._session: |
| raise NotImplemented('No SSH Session') |
| |
| if not self._session.connected: |
| try: |
| yield self._reconnect() |
| |
| except Exception as e: |
| log.exception('edit-config-connect', e=e) |
| |
| try: |
| if config[:7] != '<config': |
| config = '<config xmlns:xc="urn:ietf:params:xml:ns:netconf:base:1.0"' + \ |
| ' xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">' + \ |
| config + '</config>' |
| |
| log.debug('netconf-request', config=config, target=target, |
| default_operation=default_operation) |
| |
| rpc_reply = yield threads.deferToThread(self._do_edit_config, target, |
| config, default_operation, |
| test_option, error_option) |
| except Exception as e: |
| if ignore_delete_error and 'operation="delete"' in config.lower(): |
| returnValue('ignoring-delete-error') |
| log.exception('edit_config', e=e, config=config, target=target) |
| raise |
| |
| returnValue(rpc_reply) |
| |
| def _do_edit_config(self, target, config, default_operation, test_option, error_option, |
| ignore_delete_error=False): |
| """ |
| Perform actual edit-config operation |
| """ |
| try: |
| log.debug('edit-config', target=target, config=config) |
| |
| response = self._session.edit_config(target=target, config=config |
| # TODO: Support additional options later |
| # ,default_operation=default_operation, |
| # test_option=test_option, |
| # error_option=error_option |
| ) |
| |
| log.debug('netconf-response', response=response) |
| # To get XML, use response.xml |
| # To check status, use response.ok (boolean) |
| |
| except RPCError as e: |
| if not ignore_delete_error or 'operation="delete"' not in config.lower(): |
| log.exception('do_edit_config', e=e, config=config, target=target) |
| raise |
| |
| return response |
| |
| def rpc(self, rpc_string): |
| """ |
| Custom RPC request |
| :param rpc_string: (string) RPC request |
| :return: (deferred) for GetReply |
| """ |
| log.debug('rpc', rpc=rpc_string) |
| |
| if not self._session: |
| raise NotImplemented('No SSH Session') |
| |
| if not self._session.connected: |
| self._reconnect() |
| |
| return threads.deferToThread(self._do_rpc, rpc_string) |
| |
| def _do_rpc(self, rpc_string): |
| try: |
| response = self._session.dispatch(etree.fromstring(rpc_string)) |
| # To get XML, use response.xml |
| |
| except RPCError as e: |
| log.exception('rpc', e=e) |
| raise |
| |
| return response |