1""" 2Utils for the NAPALM modules and proxy. 3 4.. seealso:: 5 6 - :mod:`NAPALM grains: select network devices based on their characteristics <salt.grains.napalm>` 7 - :mod:`NET module: network basic features <salt.modules.napalm_network>` 8 - :mod:`NTP operational and configuration management module <salt.modules.napalm_ntp>` 9 - :mod:`BGP operational and configuration management module <salt.modules.napalm_bgp>` 10 - :mod:`Routes details <salt.modules.napalm_route>` 11 - :mod:`SNMP configuration module <salt.modules.napalm_snmp>` 12 - :mod:`Users configuration management <salt.modules.napalm_users>` 13 14.. versionadded:: 2017.7.0 15""" 16 17 18import copy 19import importlib 20import logging 21import traceback 22from functools import wraps 23 24import salt.output 25import salt.utils.args 26import salt.utils.platform 27 28try: 29 # will try to import NAPALM 30 # https://github.com/napalm-automation/napalm 31 # pylint: disable=unused-import,no-name-in-module 32 import napalm 33 import napalm.base as napalm_base 34 35 # pylint: enable=unused-import,no-name-in-module 36 HAS_NAPALM = True 37 try: 38 NAPALM_MAJOR = int(napalm.__version__.split(".")[0]) 39 except AttributeError: 40 NAPALM_MAJOR = 0 41except ImportError: 42 HAS_NAPALM = False 43 44try: 45 # try importing ConnectionClosedException 46 # from napalm-base 47 # this exception has been introduced only in version 0.24.0 48 from napalm.base.exceptions import ConnectionClosedException 49 50 HAS_CONN_CLOSED_EXC_CLASS = True 51except ImportError: 52 HAS_CONN_CLOSED_EXC_CLASS = False 53 54log = logging.getLogger(__file__) 55 56 57def is_proxy(opts): 58 """ 59 Is this a NAPALM proxy? 60 """ 61 return ( 62 salt.utils.platform.is_proxy() 63 and opts.get("proxy", {}).get("proxytype") == "napalm" 64 ) 65 66 67def is_always_alive(opts): 68 """ 69 Is always alive required? 70 """ 71 return opts.get("proxy", {}).get("always_alive", True) 72 73 74def not_always_alive(opts): 75 """ 76 Should this proxy be always alive? 77 """ 78 return (is_proxy(opts) and not is_always_alive(opts)) or is_minion(opts) 79 80 81def is_minion(opts): 82 """ 83 Is this a NAPALM straight minion? 84 """ 85 return not salt.utils.platform.is_proxy() and "napalm" in opts 86 87 88def virtual(opts, virtualname, filename): 89 """ 90 Returns the __virtual__. 91 """ 92 if (HAS_NAPALM and NAPALM_MAJOR >= 2) and (is_proxy(opts) or is_minion(opts)): 93 return virtualname 94 else: 95 return ( 96 False, 97 '"{vname}"" {filename} cannot be loaded: ' 98 "NAPALM is not installed: ``pip install napalm``".format( 99 vname=virtualname, filename="({filename})".format(filename=filename) 100 ), 101 ) 102 103 104def call(napalm_device, method, *args, **kwargs): 105 """ 106 Calls arbitrary methods from the network driver instance. 107 Please check the readthedocs_ page for the updated list of getters. 108 109 .. _readthedocs: http://napalm.readthedocs.org/en/latest/support/index.html#getters-support-matrix 110 111 method 112 Specifies the name of the method to be called. 113 114 *args 115 Arguments. 116 117 **kwargs 118 More arguments. 119 120 :return: A dictionary with three keys: 121 122 * result (True/False): if the operation succeeded 123 * out (object): returns the object as-is from the call 124 * comment (string): provides more details in case the call failed 125 * traceback (string): complete traceback in case of exception. \ 126 Please submit an issue including this traceback \ 127 on the `correct driver repo`_ and make sure to read the FAQ_ 128 129 .. _`correct driver repo`: https://github.com/napalm-automation/napalm/issues/new 130 .. FAQ_: https://github.com/napalm-automation/napalm#faq 131 132 Example: 133 134 .. code-block:: python 135 136 salt.utils.napalm.call( 137 napalm_object, 138 'cli', 139 [ 140 'show version', 141 'show chassis fan' 142 ] 143 ) 144 """ 145 result = False 146 out = None 147 opts = napalm_device.get("__opts__", {}) 148 retry = kwargs.pop("__retry", True) # retry executing the task? 149 force_reconnect = kwargs.get("force_reconnect", False) 150 if force_reconnect: 151 log.debug("Forced reconnection initiated") 152 log.debug("The current opts (under the proxy key):") 153 log.debug(opts["proxy"]) 154 opts["proxy"].update(**kwargs) 155 log.debug("Updated to:") 156 log.debug(opts["proxy"]) 157 napalm_device = get_device(opts) 158 try: 159 if not napalm_device.get("UP", False): 160 raise Exception("not connected") 161 # if connected will try to execute desired command 162 kwargs_copy = {} 163 kwargs_copy.update(kwargs) 164 for karg, warg in kwargs_copy.items(): 165 # lets clear None arguments 166 # to not be sent to NAPALM methods 167 if warg is None: 168 kwargs.pop(karg) 169 out = getattr(napalm_device.get("DRIVER"), method)(*args, **kwargs) 170 # calls the method with the specified parameters 171 result = True 172 except Exception as error: # pylint: disable=broad-except 173 # either not connected 174 # either unable to execute the command 175 hostname = napalm_device.get("HOSTNAME", "[unspecified hostname]") 176 err_tb = ( 177 traceback.format_exc() 178 ) # let's get the full traceback and display for debugging reasons. 179 if isinstance(error, NotImplementedError): 180 comment = ( 181 "{method} is not implemented for the NAPALM {driver} driver!".format( 182 method=method, driver=napalm_device.get("DRIVER_NAME") 183 ) 184 ) 185 elif ( 186 retry 187 and HAS_CONN_CLOSED_EXC_CLASS 188 and isinstance(error, ConnectionClosedException) 189 ): 190 # Received disconection whilst executing the operation. 191 # Instructed to retry (default behaviour) 192 # thus trying to re-establish the connection 193 # and re-execute the command 194 # if any of the operations (close, open, call) will rise again ConnectionClosedException 195 # it will fail loudly. 196 kwargs["__retry"] = False # do not attempt re-executing 197 comment = "Disconnected from {device}. Trying to reconnect.".format( 198 device=hostname 199 ) 200 log.error(err_tb) 201 log.error(comment) 202 log.debug("Clearing the connection with %s", hostname) 203 call(napalm_device, "close", __retry=False) # safely close the connection 204 # Make sure we don't leave any TCP connection open behind 205 # if we fail to close properly, we might not be able to access the 206 log.debug("Re-opening the connection with %s", hostname) 207 call(napalm_device, "open", __retry=False) 208 log.debug("Connection re-opened with %s", hostname) 209 log.debug("Re-executing %s", method) 210 return call(napalm_device, method, *args, **kwargs) 211 # If still not able to reconnect and execute the task, 212 # the proxy keepalive feature (if enabled) will attempt 213 # to reconnect. 214 # If the device is using a SSH-based connection, the failure 215 # will also notify the paramiko transport and the `is_alive` flag 216 # is going to be set correctly. 217 # More background: the network device may decide to disconnect, 218 # although the SSH session itself is alive and usable, the reason 219 # being the lack of activity on the CLI. 220 # Paramiko's keepalive doesn't help in this case, as the ServerAliveInterval 221 # are targeting the transport layer, whilst the device takes the decision 222 # when there isn't any activity on the CLI, thus at the application layer. 223 # Moreover, the disconnect is silent and paramiko's is_alive flag will 224 # continue to return True, although the connection is already unusable. 225 # For more info, see https://github.com/paramiko/paramiko/issues/813. 226 # But after a command fails, the `is_alive` flag becomes aware of these 227 # changes and will return False from there on. And this is how the 228 # Salt proxy keepalive helps: immediately after the first failure, it 229 # will know the state of the connection and will try reconnecting. 230 else: 231 comment = ( 232 'Cannot execute "{method}" on {device}{port} as {user}. Reason:' 233 " {error}!".format( 234 device=napalm_device.get("HOSTNAME", "[unspecified hostname]"), 235 port=( 236 ":{port}".format( 237 port=napalm_device.get("OPTIONAL_ARGS", {}).get("port") 238 ) 239 if napalm_device.get("OPTIONAL_ARGS", {}).get("port") 240 else "" 241 ), 242 user=napalm_device.get("USERNAME", ""), 243 method=method, 244 error=error, 245 ) 246 ) 247 log.error(comment) 248 log.error(err_tb) 249 return {"out": {}, "result": False, "comment": comment, "traceback": err_tb} 250 finally: 251 if opts and not_always_alive(opts) and napalm_device.get("CLOSE", True): 252 # either running in a not-always-alive proxy 253 # either running in a regular minion 254 # close the connection when the call is over 255 # unless the CLOSE is explicitly set as False 256 napalm_device["DRIVER"].close() 257 return {"out": out, "result": result, "comment": ""} 258 259 260def get_device_opts(opts, salt_obj=None): 261 """ 262 Returns the options of the napalm device. 263 :pram: opts 264 :return: the network device opts 265 """ 266 network_device = {} 267 # by default, look in the proxy config details 268 device_dict = opts.get("proxy", {}) if is_proxy(opts) else opts.get("napalm", {}) 269 if opts.get("proxy") or opts.get("napalm"): 270 opts["multiprocessing"] = device_dict.get("multiprocessing", False) 271 # Most NAPALM drivers are SSH-based, so multiprocessing should default to False. 272 # But the user can be allows one to have a different value for the multiprocessing, which will 273 # override the opts. 274 if not device_dict: 275 # still not able to setup 276 log.error( 277 "Incorrect minion config. Please specify at least the napalm driver name!" 278 ) 279 # either under the proxy hier, either under the napalm in the config file 280 network_device["HOSTNAME"] = ( 281 device_dict.get("host") 282 or device_dict.get("hostname") 283 or device_dict.get("fqdn") 284 or device_dict.get("ip") 285 ) 286 network_device["USERNAME"] = device_dict.get("username") or device_dict.get("user") 287 network_device["DRIVER_NAME"] = device_dict.get("driver") or device_dict.get("os") 288 network_device["PASSWORD"] = ( 289 device_dict.get("passwd") 290 or device_dict.get("password") 291 or device_dict.get("pass") 292 or "" 293 ) 294 network_device["TIMEOUT"] = device_dict.get("timeout", 60) 295 network_device["OPTIONAL_ARGS"] = device_dict.get("optional_args", {}) 296 network_device["ALWAYS_ALIVE"] = device_dict.get("always_alive", True) 297 network_device["PROVIDER"] = device_dict.get("provider") 298 network_device["UP"] = False 299 # get driver object form NAPALM 300 if "config_lock" not in network_device["OPTIONAL_ARGS"]: 301 network_device["OPTIONAL_ARGS"]["config_lock"] = False 302 if ( 303 network_device["ALWAYS_ALIVE"] 304 and "keepalive" not in network_device["OPTIONAL_ARGS"] 305 ): 306 network_device["OPTIONAL_ARGS"]["keepalive"] = 5 # 5 seconds keepalive 307 return network_device 308 309 310def get_device(opts, salt_obj=None): 311 """ 312 Initialise the connection with the network device through NAPALM. 313 :param: opts 314 :return: the network device object 315 """ 316 log.debug("Setting up NAPALM connection") 317 network_device = get_device_opts(opts, salt_obj=salt_obj) 318 provider_lib = napalm_base 319 if network_device.get("PROVIDER"): 320 # In case the user requires a different provider library, 321 # other than napalm-base. 322 # For example, if napalm-base does not satisfy the requirements 323 # and needs to be enahanced with more specific features, 324 # we may need to define a custom library on top of napalm-base 325 # with the constraint that it still needs to provide the 326 # `get_network_driver` function. However, even this can be 327 # extended later, if really needed. 328 # Configuration example: 329 # provider: napalm_base_example 330 try: 331 provider_lib = importlib.import_module(network_device.get("PROVIDER")) 332 except ImportError as ierr: 333 log.error( 334 "Unable to import %s", network_device.get("PROVIDER"), exc_info=True 335 ) 336 log.error("Falling back to napalm-base") 337 _driver_ = provider_lib.get_network_driver(network_device.get("DRIVER_NAME")) 338 try: 339 network_device["DRIVER"] = _driver_( 340 network_device.get("HOSTNAME", ""), 341 network_device.get("USERNAME", ""), 342 network_device.get("PASSWORD", ""), 343 timeout=network_device["TIMEOUT"], 344 optional_args=network_device["OPTIONAL_ARGS"], 345 ) 346 network_device.get("DRIVER").open() 347 # no exception raised here, means connection established 348 network_device["UP"] = True 349 except napalm_base.exceptions.ConnectionException as error: 350 base_err_msg = "Cannot connect to {hostname}{port} as {username}.".format( 351 hostname=network_device.get("HOSTNAME", "[unspecified hostname]"), 352 port=( 353 ":{port}".format( 354 port=network_device.get("OPTIONAL_ARGS", {}).get("port") 355 ) 356 if network_device.get("OPTIONAL_ARGS", {}).get("port") 357 else "" 358 ), 359 username=network_device.get("USERNAME", ""), 360 ) 361 log.error(base_err_msg) 362 log.error("Please check error: %s", error) 363 raise napalm_base.exceptions.ConnectionException(base_err_msg) 364 return network_device 365 366 367def proxy_napalm_wrap(func): 368 """ 369 This decorator is used to make the execution module functions 370 available outside a proxy minion, or when running inside a proxy 371 minion. If we are running in a proxy, retrieve the connection details 372 from the __proxy__ injected variable. If we are not, then 373 use the connection information from the opts. 374 :param func: 375 :return: 376 """ 377 378 @wraps(func) 379 def func_wrapper(*args, **kwargs): 380 wrapped_global_namespace = func.__globals__ 381 # get __opts__ and __proxy__ from func_globals 382 proxy = wrapped_global_namespace.get("__proxy__") 383 opts = copy.deepcopy(wrapped_global_namespace.get("__opts__")) 384 # in any case, will inject the `napalm_device` global 385 # the execution modules will make use of this variable from now on 386 # previously they were accessing the device properties through the __proxy__ object 387 always_alive = opts.get("proxy", {}).get("always_alive", True) 388 # force_reconnect is a magic keyword arg that allows one to establish 389 # a separate connection to the network device running under an always 390 # alive Proxy Minion, using new credentials (overriding the ones 391 # configured in the opts / pillar. 392 force_reconnect = kwargs.get("force_reconnect", False) 393 if force_reconnect: 394 log.debug("Usage of reconnect force detected") 395 log.debug("Opts before merging") 396 log.debug(opts["proxy"]) 397 opts["proxy"].update(**kwargs) 398 log.debug("Opts after merging") 399 log.debug(opts["proxy"]) 400 if is_proxy(opts) and always_alive: 401 # if it is running in a NAPALM Proxy and it's using the default 402 # always alive behaviour, will get the cached copy of the network 403 # device object which should preserve the connection. 404 if force_reconnect: 405 wrapped_global_namespace["napalm_device"] = get_device(opts) 406 else: 407 wrapped_global_namespace["napalm_device"] = proxy["napalm.get_device"]() 408 elif is_proxy(opts) and not always_alive: 409 # if still proxy, but the user does not want the SSH session always alive 410 # get a new device instance 411 # which establishes a new connection 412 # which is closed just before the call() function defined above returns 413 if "inherit_napalm_device" not in kwargs or ( 414 "inherit_napalm_device" in kwargs 415 and not kwargs["inherit_napalm_device"] 416 ): 417 # try to open a new connection 418 # but only if the function does not inherit the napalm driver 419 # for configuration management this is very important, 420 # in order to make sure we are editing the same session. 421 try: 422 wrapped_global_namespace["napalm_device"] = get_device(opts) 423 except napalm_base.exceptions.ConnectionException as nce: 424 log.error(nce) 425 return "{base_msg}. See log for details.".format( 426 base_msg=str(nce.msg) 427 ) 428 else: 429 # in case the `inherit_napalm_device` is set 430 # and it also has a non-empty value, 431 # the global var `napalm_device` will be overridden. 432 # this is extremely important for configuration-related features 433 # as all actions must be issued within the same configuration session 434 # otherwise we risk to open multiple sessions 435 wrapped_global_namespace["napalm_device"] = kwargs[ 436 "inherit_napalm_device" 437 ] 438 else: 439 # if not a NAPLAM proxy 440 # thus it is running on a regular minion, directly on the network device 441 # or another flavour of Minion from where we can invoke arbitrary 442 # NAPALM commands 443 # get __salt__ from func_globals 444 log.debug("Not running in a NAPALM Proxy Minion") 445 _salt_obj = wrapped_global_namespace.get("__salt__") 446 napalm_opts = _salt_obj["config.get"]("napalm", {}) 447 napalm_inventory = _salt_obj["config.get"]("napalm_inventory", {}) 448 log.debug("NAPALM opts found in the Minion config") 449 log.debug(napalm_opts) 450 clean_kwargs = salt.utils.args.clean_kwargs(**kwargs) 451 napalm_opts.update(clean_kwargs) # no need for deeper merge 452 log.debug("Merging the found opts with the CLI args") 453 log.debug(napalm_opts) 454 host = ( 455 napalm_opts.get("host") 456 or napalm_opts.get("hostname") 457 or napalm_opts.get("fqdn") 458 or napalm_opts.get("ip") 459 ) 460 if ( 461 host 462 and napalm_inventory 463 and isinstance(napalm_inventory, dict) 464 and host in napalm_inventory 465 ): 466 inventory_opts = napalm_inventory[host] 467 log.debug("Found %s in the NAPALM inventory:", host) 468 log.debug(inventory_opts) 469 napalm_opts.update(inventory_opts) 470 log.debug( 471 "Merging the config for %s with the details found in the napalm" 472 " inventory:", 473 host, 474 ) 475 log.debug(napalm_opts) 476 opts = copy.deepcopy(opts) # make sure we don't override the original 477 # opts, but just inject the CLI args from the kwargs to into the 478 # object manipulated by ``get_device_opts`` to extract the 479 # connection details, then use then to establish the connection. 480 opts["napalm"] = napalm_opts 481 if "inherit_napalm_device" not in kwargs or ( 482 "inherit_napalm_device" in kwargs 483 and not kwargs["inherit_napalm_device"] 484 ): 485 # try to open a new connection 486 # but only if the function does not inherit the napalm driver 487 # for configuration management this is very important, 488 # in order to make sure we are editing the same session. 489 try: 490 wrapped_global_namespace["napalm_device"] = get_device( 491 opts, salt_obj=_salt_obj 492 ) 493 except napalm_base.exceptions.ConnectionException as nce: 494 log.error(nce) 495 return "{base_msg}. See log for details.".format( 496 base_msg=str(nce.msg) 497 ) 498 else: 499 # in case the `inherit_napalm_device` is set 500 # and it also has a non-empty value, 501 # the global var `napalm_device` will be overridden. 502 # this is extremely important for configuration-related features 503 # as all actions must be issued within the same configuration session 504 # otherwise we risk to open multiple sessions 505 wrapped_global_namespace["napalm_device"] = kwargs[ 506 "inherit_napalm_device" 507 ] 508 if not_always_alive(opts): 509 # inject the __opts__ only when not always alive 510 # otherwise, we don't want to overload the always-alive proxies 511 wrapped_global_namespace["napalm_device"]["__opts__"] = opts 512 ret = func(*args, **kwargs) 513 if force_reconnect: 514 log.debug("That was a forced reconnect, gracefully clearing up") 515 device = wrapped_global_namespace["napalm_device"] 516 closing = call(device, "close", __retry=False) 517 return ret 518 519 return func_wrapper 520 521 522def default_ret(name): 523 """ 524 Return the default dict of the state output. 525 """ 526 ret = {"name": name, "changes": {}, "result": False, "comment": ""} 527 return ret 528 529 530def loaded_ret(ret, loaded, test, debug, compliance_report=False, opts=None): 531 """ 532 Return the final state output. 533 ret 534 The initial state output structure. 535 loaded 536 The loaded dictionary. 537 """ 538 # Always get the comment 539 changes = {} 540 ret["comment"] = loaded["comment"] 541 if "diff" in loaded: 542 changes["diff"] = loaded["diff"] 543 if "commit_id" in loaded: 544 changes["commit_id"] = loaded["commit_id"] 545 if "compliance_report" in loaded: 546 if compliance_report: 547 changes["compliance_report"] = loaded["compliance_report"] 548 if debug and "loaded_config" in loaded: 549 changes["loaded_config"] = loaded["loaded_config"] 550 if changes.get("diff"): 551 ret["comment"] = "{comment_base}\n\nConfiguration diff:\n\n{diff}".format( 552 comment_base=ret["comment"], diff=changes["diff"] 553 ) 554 if changes.get("loaded_config"): 555 ret["comment"] = "{comment_base}\n\nLoaded config:\n\n{loaded_cfg}".format( 556 comment_base=ret["comment"], loaded_cfg=changes["loaded_config"] 557 ) 558 if changes.get("compliance_report"): 559 ret["comment"] = "{comment_base}\n\nCompliance report:\n\n{compliance}".format( 560 comment_base=ret["comment"], 561 compliance=salt.output.string_format( 562 changes["compliance_report"], "nested", opts=opts 563 ), 564 ) 565 if not loaded.get("result", False): 566 # Failure of some sort 567 return ret 568 if not loaded.get("already_configured", True): 569 # We're making changes 570 if test: 571 ret["result"] = None 572 return ret 573 # Not test, changes were applied 574 ret.update( 575 { 576 "result": True, 577 "changes": changes, 578 "comment": "Configuration changed!\n{}".format(loaded["comment"]), 579 } 580 ) 581 return ret 582 # No changes 583 ret.update({"result": True, "changes": {}}) 584 return ret 585