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