Chip Boling | f5af85d | 2019-02-12 15:36:17 -0600 | [diff] [blame] | 1 | # Copyright 2017-present Adtran, Inc. |
| 2 | # |
| 3 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | # you may not use this file except in compliance with the License. |
| 5 | # You may obtain a copy of the License at |
| 6 | # |
| 7 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | # |
| 9 | # Unless required by applicable law or agreed to in writing, software |
| 10 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | # See the License for the specific language governing permissions and |
| 13 | # limitations under the License. |
| 14 | |
| 15 | import structlog |
| 16 | from lxml import etree |
| 17 | from ncclient import manager |
| 18 | from ncclient.operations import RPCError |
| 19 | from ncclient.transport.errors import SSHError |
| 20 | from twisted.internet import defer, threads |
| 21 | from twisted.internet.defer import inlineCallbacks, returnValue |
| 22 | |
| 23 | log = structlog.get_logger('ncclient') |
| 24 | |
| 25 | ADTRAN_NS = 'http://www.adtran.com/ns/yang' |
| 26 | |
| 27 | |
| 28 | def adtran_module_url(module): |
| 29 | return '{}/{}'.format(ADTRAN_NS, module) |
| 30 | |
| 31 | |
| 32 | def phys_entities_rpc(): |
| 33 | return """ |
| 34 | <filter xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"> |
| 35 | <physical-entities-state xmlns="{}"> |
| 36 | <physical-entity/> |
| 37 | </physical-entities-state> |
| 38 | </filter> |
| 39 | """.format(adtran_module_url('adtran-physical-entities')) |
| 40 | |
| 41 | |
| 42 | class AdtranNetconfClient(object): |
| 43 | """ |
| 44 | Performs NETCONF requests |
| 45 | """ |
| 46 | def __init__(self, host_ip, port=830, username='', password='', timeout=10): |
| 47 | self._ip = host_ip |
| 48 | self._port = port |
| 49 | self._username = username |
| 50 | self._password = password |
| 51 | self._timeout = timeout |
| 52 | self._session = None |
| 53 | |
| 54 | def __str__(self): |
| 55 | return "AdtranNetconfClient {}@{}:{}".format(self._username, self._ip, self._port) |
| 56 | |
| 57 | @property |
| 58 | def capabilities(self): |
| 59 | """ |
| 60 | Get the server's NETCONF capabilities |
| 61 | |
| 62 | :return: (ncclient.capabilities.Capabilities) object representing the server's capabilities. |
| 63 | """ |
| 64 | return self._session.server_capabilities if self._session else None |
| 65 | |
| 66 | @property |
| 67 | def connected(self): |
| 68 | """ |
| 69 | Is this client connected to a NETCONF server |
| 70 | :return: (boolean) True if connected |
| 71 | """ |
| 72 | return self._session is not None and self._session.connected |
| 73 | |
| 74 | def connect(self, connect_timeout=None): |
| 75 | """ |
| 76 | Connect to the NETCONF server |
| 77 | |
| 78 | o To disable attempting publickey authentication altogether, call with |
| 79 | allow_agent and look_for_keys as False. |
| 80 | |
| 81 | o hostkey_verify enables hostkey verification from ~/.ssh/known_hosts |
| 82 | |
| 83 | :return: (deferred) Deferred request |
| 84 | """ |
| 85 | timeout = connect_timeout or self._timeout |
| 86 | |
| 87 | return threads.deferToThread(self._do_connect, timeout) |
| 88 | |
| 89 | def _do_connect(self, timeout): |
| 90 | try: |
| 91 | self._session = manager.connect(host=self._ip, |
| 92 | port=self._port, |
| 93 | username=self._username, |
| 94 | password=self._password, |
| 95 | allow_agent=False, |
| 96 | look_for_keys=False, |
| 97 | hostkey_verify=False, |
| 98 | timeout=timeout) |
| 99 | |
| 100 | except SSHError as e: |
| 101 | # Log and rethrow exception so any errBack is called |
| 102 | log.warn('SSHError-during-connect', e=e) |
| 103 | raise e |
| 104 | |
| 105 | except Exception as e: |
| 106 | # Log and rethrow exception so any errBack is called |
| 107 | log.exception('Connect-failed: {}', e=e) |
| 108 | raise e |
| 109 | |
| 110 | # If debug logging is enabled, decrease the level, DEBUG is a significant |
| 111 | # performance hit during response XML decode |
| 112 | |
| 113 | if log.isEnabledFor('DEBUG'): |
| 114 | log.setLevel('INFO') |
| 115 | |
| 116 | # TODO: ncclient also supports RaiseMode:NONE to limit exceptions. To set use: |
| 117 | # |
| 118 | # self._session.raise_mode = RaiseMode:NONE |
| 119 | # |
| 120 | # and the when you get a response back, you can check 'response.ok' to |
| 121 | # see if it is 'True' if it is not, you can enumerate the 'response.errors' |
| 122 | # list for more information |
| 123 | |
| 124 | return self._session |
| 125 | |
| 126 | def close(self): |
| 127 | """ |
| 128 | Close the connection to the NETCONF server |
| 129 | :return: (deferred) Deferred request |
| 130 | """ |
| 131 | s, self._session = self._session, None |
| 132 | |
| 133 | if s is None or not s.connected: |
| 134 | return defer.returnValue(True) |
| 135 | |
| 136 | return threads.deferToThread(self._do_close, s) |
| 137 | |
| 138 | def _do_close(self, old_session): |
| 139 | return old_session.close_session() |
| 140 | |
| 141 | @inlineCallbacks |
| 142 | def _reconnect(self): |
| 143 | try: |
| 144 | yield self.close() |
| 145 | except: |
| 146 | pass |
| 147 | |
| 148 | try: |
| 149 | yield self.connect() |
| 150 | except: |
| 151 | pass |
| 152 | |
| 153 | def get_config(self, source='running'): |
| 154 | """ |
| 155 | Get the configuration from the specified source |
| 156 | |
| 157 | :param source: (string) Configuration source, 'running', 'candidate', ... |
| 158 | |
| 159 | :return: (deferred) Deferred request that wraps the GetReply class |
| 160 | """ |
| 161 | if not self._session: |
| 162 | raise NotImplemented('No SSH Session') |
| 163 | |
| 164 | if not self._session.connected: |
| 165 | self._reconnect() |
| 166 | |
| 167 | return threads.deferToThread(self._do_get_config, source) |
| 168 | |
| 169 | def _do_get_config(self, source): |
| 170 | """ |
| 171 | Get the configuration from the specified source |
| 172 | |
| 173 | :param source: (string) Configuration source, 'running', 'candidate', ... |
| 174 | |
| 175 | :return: (GetReply) The configuration. |
| 176 | """ |
| 177 | return self._session.get_config(source) |
| 178 | |
| 179 | def get(self, payload): |
| 180 | """ |
| 181 | Get the requested data from the server |
| 182 | |
| 183 | :param payload: Payload/filter |
| 184 | :return: (deferred) for GetReply |
| 185 | """ |
| 186 | log.debug('get', filter=payload) |
| 187 | |
| 188 | if not self._session: |
| 189 | raise NotImplemented('No SSH Session') |
| 190 | |
| 191 | if not self._session.connected: |
| 192 | self._reconnect() |
| 193 | |
| 194 | return threads.deferToThread(self._do_get, payload) |
| 195 | |
| 196 | def _do_get(self, payload): |
| 197 | """ |
| 198 | Get the requested data from the server |
| 199 | |
| 200 | :param payload: Payload/filter |
| 201 | :return: (GetReply) response |
| 202 | """ |
| 203 | try: |
| 204 | log.debug('get', payload=payload) |
| 205 | response = self._session.get(payload) |
| 206 | # To get XML, use response.xml |
| 207 | log.debug('response', response=response) |
| 208 | |
| 209 | except RPCError as e: |
| 210 | log.exception('get', e=e) |
| 211 | raise |
| 212 | |
| 213 | return response |
| 214 | |
| 215 | def lock(self, source, lock_timeout): |
| 216 | """ |
| 217 | Lock the configuration system |
| 218 | :return: (deferred) for RpcReply |
| 219 | """ |
| 220 | log.info('lock', source=source, timeout=lock_timeout) |
| 221 | |
| 222 | if not self._session or not self._session.connected: |
| 223 | raise NotImplemented('TODO: Support auto-connect if needed') |
| 224 | |
| 225 | return threads.deferToThread(self._do_lock, source, lock_timeout) |
| 226 | |
| 227 | def _do_lock(self, source, lock_timeout): |
| 228 | """ |
| 229 | Lock the configuration system |
| 230 | """ |
| 231 | try: |
| 232 | response = self._session.lock(source, timeout=lock_timeout) |
| 233 | # To get XML, use response.xml |
| 234 | |
| 235 | except RPCError as e: |
| 236 | log.exception('lock', e=e) |
| 237 | raise |
| 238 | |
| 239 | return response |
| 240 | |
| 241 | def unlock(self, source): |
| 242 | """ |
| 243 | Get the requested data from the server |
| 244 | :param source: RPC request |
| 245 | |
| 246 | :return: (deferred) for RpcReply |
| 247 | """ |
| 248 | log.info('unlock', source=source) |
| 249 | |
| 250 | if not self._session or not self._session.connected: |
| 251 | raise NotImplemented('TODO: Support auto-connect if needed') |
| 252 | |
| 253 | return threads.deferToThread(self._do_unlock, source) |
| 254 | |
| 255 | def _do_unlock(self, source): |
| 256 | """ |
| 257 | Lock the configuration system |
| 258 | """ |
| 259 | try: |
| 260 | response = self._session.unlock(source) |
| 261 | # To get XML, use response.xml |
| 262 | |
| 263 | except RPCError as e: |
| 264 | log.exception('unlock', e=e) |
| 265 | raise |
| 266 | |
| 267 | return response |
| 268 | |
| 269 | @inlineCallbacks |
| 270 | def edit_config(self, config, target='running', default_operation='none', |
| 271 | test_option=None, error_option=None, ignore_delete_error=False): |
| 272 | """ |
| 273 | Loads all or part of the specified config to the target configuration datastore |
| 274 | with the ability to lock the datastore during the edit. |
| 275 | |
| 276 | :param config is the configuration, which must be rooted in the config element. |
| 277 | It can be specified either as a string or an Element.format="xml" |
| 278 | :param target is the name of the configuration datastore being edited |
| 279 | :param default_operation if specified must be one of { 'merge', 'replace', or 'none' } |
| 280 | :param test_option if specified must be one of { 'test_then_set', 'set' } |
| 281 | :param error_option if specified must be one of { 'stop-on-error', |
| 282 | 'continue-on-error', 'rollback-on-error' } The |
| 283 | 'rollback-on-error' error_option depends on the |
| 284 | :rollback-on-error capability. |
| 285 | :param ignore_delete_error: (bool) For some startup deletes/clean-ups, we do a |
| 286 | delete high up in the config to get whole lists. If |
| 287 | these lists are empty, this helps suppress any error |
| 288 | message from NETConf on failure to delete an empty list |
| 289 | |
| 290 | :return: (deferred) for RpcReply |
| 291 | """ |
| 292 | if not self._session: |
| 293 | raise NotImplemented('No SSH Session') |
| 294 | |
| 295 | if not self._session.connected: |
| 296 | try: |
| 297 | yield self._reconnect() |
| 298 | |
| 299 | except Exception as e: |
| 300 | log.exception('edit-config-connect', e=e) |
| 301 | |
| 302 | try: |
| 303 | if config[:7] != '<config': |
| 304 | config = '<config xmlns:xc="urn:ietf:params:xml:ns:netconf:base:1.0"' + \ |
| 305 | ' xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">' + \ |
| 306 | config + '</config>' |
| 307 | |
| 308 | log.debug('netconf-request', config=config, target=target, |
| 309 | default_operation=default_operation) |
| 310 | |
| 311 | rpc_reply = yield threads.deferToThread(self._do_edit_config, target, |
| 312 | config, default_operation, |
| 313 | test_option, error_option) |
| 314 | except Exception as e: |
| 315 | if ignore_delete_error and 'operation="delete"' in config.lower(): |
| 316 | returnValue('ignoring-delete-error') |
| 317 | log.exception('edit_config', e=e, config=config, target=target) |
| 318 | raise |
| 319 | |
| 320 | returnValue(rpc_reply) |
| 321 | |
| 322 | def _do_edit_config(self, target, config, default_operation, test_option, error_option, |
| 323 | ignore_delete_error=False): |
| 324 | """ |
| 325 | Perform actual edit-config operation |
| 326 | """ |
| 327 | try: |
| 328 | log.debug('edit-config', target=target, config=config) |
| 329 | |
| 330 | response = self._session.edit_config(target=target, config=config |
| 331 | # TODO: Support additional options later |
| 332 | # ,default_operation=default_operation, |
| 333 | # test_option=test_option, |
| 334 | # error_option=error_option |
| 335 | ) |
| 336 | |
| 337 | log.debug('netconf-response', response=response) |
| 338 | # To get XML, use response.xml |
| 339 | # To check status, use response.ok (boolean) |
| 340 | |
| 341 | except RPCError as e: |
| 342 | if not ignore_delete_error or 'operation="delete"' not in config.lower(): |
| 343 | log.exception('do_edit_config', e=e, config=config, target=target) |
| 344 | raise |
| 345 | |
| 346 | return response |
| 347 | |
| 348 | def rpc(self, rpc_string): |
| 349 | """ |
| 350 | Custom RPC request |
| 351 | :param rpc_string: (string) RPC request |
| 352 | :return: (deferred) for GetReply |
| 353 | """ |
| 354 | log.debug('rpc', rpc=rpc_string) |
| 355 | |
| 356 | if not self._session: |
| 357 | raise NotImplemented('No SSH Session') |
| 358 | |
| 359 | if not self._session.connected: |
| 360 | self._reconnect() |
| 361 | |
| 362 | return threads.deferToThread(self._do_rpc, rpc_string) |
| 363 | |
| 364 | def _do_rpc(self, rpc_string): |
| 365 | try: |
| 366 | response = self._session.dispatch(etree.fromstring(rpc_string)) |
| 367 | # To get XML, use response.xml |
| 368 | |
| 369 | except RPCError as e: |
| 370 | log.exception('rpc', e=e) |
| 371 | raise |
| 372 | |
| 373 | return response |