Mini Shell
"""
Utils for the NAPALM modules and proxy.
.. seealso::
- :mod:`NAPALM grains: select network devices based on their characteristics <salt.grains.napalm>`
- :mod:`NET module: network basic features <salt.modules.napalm_network>`
- :mod:`NTP operational and configuration management module <salt.modules.napalm_ntp>`
- :mod:`BGP operational and configuration management module <salt.modules.napalm_bgp>`
- :mod:`Routes details <salt.modules.napalm_route>`
- :mod:`SNMP configuration module <salt.modules.napalm_snmp>`
- :mod:`Users configuration management <salt.modules.napalm_users>`
.. versionadded:: 2017.7.0
"""
import copy
import importlib
import logging
import traceback
from functools import wraps
import salt.output
import salt.utils.args
import salt.utils.platform
try:
# will try to import NAPALM
# https://github.com/napalm-automation/napalm
# pylint: disable=unused-import,no-name-in-module
import napalm
# pylint: enable=unused-import,no-name-in-module
HAS_NAPALM = True
try:
NAPALM_MAJOR = int(napalm.__version__.split(".")[0])
except AttributeError:
NAPALM_MAJOR = 0
except ImportError:
HAS_NAPALM = False
try:
# try importing ConnectionClosedException
# from napalm-base
# this exception has been introduced only in version 0.24.0
# pylint: disable=unused-import,no-name-in-module
from napalm.base.exceptions import ConnectionClosedException
# pylint: enable=unused-import,no-name-in-module
HAS_CONN_CLOSED_EXC_CLASS = True
except ImportError:
HAS_CONN_CLOSED_EXC_CLASS = False
log = logging.getLogger(__file__)
def is_proxy(opts):
"""
Is this a NAPALM proxy?
"""
return (
salt.utils.platform.is_proxy()
and opts.get("proxy", {}).get("proxytype") == "napalm"
)
def is_always_alive(opts):
"""
Is always alive required?
"""
return opts.get("proxy", {}).get("always_alive", True)
def not_always_alive(opts):
"""
Should this proxy be always alive?
"""
return (is_proxy(opts) and not is_always_alive(opts)) or is_minion(opts)
def is_minion(opts):
"""
Is this a NAPALM straight minion?
"""
return not salt.utils.platform.is_proxy() and "napalm" in opts
def virtual(opts, virtualname, filename):
"""
Returns the __virtual__.
"""
if (HAS_NAPALM and NAPALM_MAJOR >= 2) and (is_proxy(opts) or is_minion(opts)):
return virtualname
else:
return (
False,
f'"{virtualname}" ({filename}) cannot be loaded: '
"NAPALM is not installed: ``pip install napalm``",
)
def call(napalm_device, method, *args, **kwargs):
"""
Calls arbitrary methods from the network driver instance.
Please check the readthedocs_ page for the updated list of getters.
.. _readthedocs: http://napalm.readthedocs.org/en/latest/support/index.html#getters-support-matrix
method
Specifies the name of the method to be called.
*args
Arguments.
**kwargs
More arguments.
:return: A dictionary with three keys:
* result (True/False): if the operation succeeded
* out (object): returns the object as-is from the call
* comment (string): provides more details in case the call failed
* traceback (string): complete traceback in case of exception. \
Please submit an issue including this traceback \
on the `correct driver repo`_ and make sure to read the FAQ_
.. _`correct driver repo`: https://github.com/napalm-automation/napalm/issues/new
.. FAQ_: https://github.com/napalm-automation/napalm#faq
Example:
.. code-block:: python
salt.utils.napalm.call(
napalm_object,
'cli',
[
'show version',
'show chassis fan'
]
)
"""
result = False
out = None
opts = napalm_device.get("__opts__", {})
retry = kwargs.pop("__retry", True) # retry executing the task?
force_reconnect = kwargs.get("force_reconnect", False)
if force_reconnect:
log.debug("Forced reconnection initiated")
log.debug("The current opts (under the proxy key):")
log.debug(opts["proxy"])
opts["proxy"].update(**kwargs)
log.debug("Updated to:")
log.debug(opts["proxy"])
napalm_device = get_device(opts)
try:
if not napalm_device.get("UP", False):
raise Exception("not connected")
# if connected will try to execute desired command
kwargs_copy = {}
kwargs_copy.update(kwargs)
for karg, warg in kwargs_copy.items():
# lets clear None arguments
# to not be sent to NAPALM methods
if warg is None:
kwargs.pop(karg)
out = getattr(napalm_device.get("DRIVER"), method)(*args, **kwargs)
# calls the method with the specified parameters
result = True
except Exception as error: # pylint: disable=broad-except
# either not connected
# either unable to execute the command
hostname = napalm_device.get("HOSTNAME", "[unspecified hostname]")
err_tb = (
traceback.format_exc()
) # let's get the full traceback and display for debugging reasons.
if isinstance(error, NotImplementedError):
comment = (
"{method} is not implemented for the NAPALM {driver} driver!".format(
method=method, driver=napalm_device.get("DRIVER_NAME")
)
)
elif (
retry
and HAS_CONN_CLOSED_EXC_CLASS
and isinstance(error, ConnectionClosedException)
):
# Received disconection whilst executing the operation.
# Instructed to retry (default behaviour)
# thus trying to re-establish the connection
# and re-execute the command
# if any of the operations (close, open, call) will rise again ConnectionClosedException
# it will fail loudly.
kwargs["__retry"] = False # do not attempt re-executing
comment = "Disconnected from {device}. Trying to reconnect.".format(
device=hostname
)
log.error(err_tb)
log.error(comment)
log.debug("Clearing the connection with %s", hostname)
call(napalm_device, "close", __retry=False) # safely close the connection
# Make sure we don't leave any TCP connection open behind
# if we fail to close properly, we might not be able to access the
log.debug("Re-opening the connection with %s", hostname)
call(napalm_device, "open", __retry=False)
log.debug("Connection re-opened with %s", hostname)
log.debug("Re-executing %s", method)
return call(napalm_device, method, *args, **kwargs)
# If still not able to reconnect and execute the task,
# the proxy keepalive feature (if enabled) will attempt
# to reconnect.
# If the device is using a SSH-based connection, the failure
# will also notify the paramiko transport and the `is_alive` flag
# is going to be set correctly.
# More background: the network device may decide to disconnect,
# although the SSH session itself is alive and usable, the reason
# being the lack of activity on the CLI.
# Paramiko's keepalive doesn't help in this case, as the ServerAliveInterval
# are targeting the transport layer, whilst the device takes the decision
# when there isn't any activity on the CLI, thus at the application layer.
# Moreover, the disconnect is silent and paramiko's is_alive flag will
# continue to return True, although the connection is already unusable.
# For more info, see https://github.com/paramiko/paramiko/issues/813.
# But after a command fails, the `is_alive` flag becomes aware of these
# changes and will return False from there on. And this is how the
# Salt proxy keepalive helps: immediately after the first failure, it
# will know the state of the connection and will try reconnecting.
else:
comment = (
'Cannot execute "{method}" on {device}{port} as {user}. Reason:'
" {error}!".format(
device=napalm_device.get("HOSTNAME", "[unspecified hostname]"),
port=(
":{port}".format(
port=napalm_device.get("OPTIONAL_ARGS", {}).get("port")
)
if napalm_device.get("OPTIONAL_ARGS", {}).get("port")
else ""
),
user=napalm_device.get("USERNAME", ""),
method=method,
error=error,
)
)
log.error(comment)
log.error(err_tb)
return {"out": {}, "result": False, "comment": comment, "traceback": err_tb}
finally:
if opts and not_always_alive(opts) and napalm_device.get("CLOSE", True):
# either running in a not-always-alive proxy
# either running in a regular minion
# close the connection when the call is over
# unless the CLOSE is explicitly set as False
napalm_device["DRIVER"].close()
return {"out": out, "result": result, "comment": ""}
def get_device_opts(opts, salt_obj=None):
"""
Returns the options of the napalm device.
:pram: opts
:return: the network device opts
"""
network_device = {}
# by default, look in the proxy config details
device_dict = opts.get("proxy", {}) if is_proxy(opts) else opts.get("napalm", {})
if opts.get("proxy") or opts.get("napalm"):
opts["multiprocessing"] = device_dict.get("multiprocessing", False)
# Most NAPALM drivers are SSH-based, so multiprocessing should default to False.
# But the user can be allows one to have a different value for the multiprocessing, which will
# override the opts.
if not device_dict:
# still not able to setup
log.error(
"Incorrect minion config. Please specify at least the napalm driver name!"
)
# either under the proxy hier, either under the napalm in the config file
network_device["HOSTNAME"] = (
device_dict.get("host")
or device_dict.get("hostname")
or device_dict.get("fqdn")
or device_dict.get("ip")
)
network_device["USERNAME"] = device_dict.get("username") or device_dict.get("user")
network_device["DRIVER_NAME"] = device_dict.get("driver") or device_dict.get("os")
network_device["PASSWORD"] = (
device_dict.get("passwd")
or device_dict.get("password")
or device_dict.get("pass")
or ""
)
network_device["TIMEOUT"] = device_dict.get("timeout", 60)
network_device["OPTIONAL_ARGS"] = device_dict.get("optional_args", {})
network_device["ALWAYS_ALIVE"] = device_dict.get("always_alive", True)
network_device["PROVIDER"] = device_dict.get("provider")
network_device["UP"] = False
# get driver object form NAPALM
if "config_lock" not in network_device["OPTIONAL_ARGS"]:
network_device["OPTIONAL_ARGS"]["config_lock"] = False
if (
network_device["ALWAYS_ALIVE"]
and "keepalive" not in network_device["OPTIONAL_ARGS"]
):
network_device["OPTIONAL_ARGS"]["keepalive"] = 5 # 5 seconds keepalive
return network_device
def get_device(opts, salt_obj=None):
"""
Initialise the connection with the network device through NAPALM.
:param: opts
:return: the network device object
"""
log.debug("Setting up NAPALM connection")
network_device = get_device_opts(opts, salt_obj=salt_obj)
provider_lib = napalm.base
if network_device.get("PROVIDER"):
# Configuration example:
# provider: napalm_base_example
try:
provider_lib = importlib.import_module(network_device.get("PROVIDER"))
except ImportError as ierr:
log.error(
"Unable to import %s", network_device.get("PROVIDER"), exc_info=True
)
_driver_ = provider_lib.get_network_driver(network_device.get("DRIVER_NAME"))
try:
network_device["DRIVER"] = _driver_(
network_device.get("HOSTNAME", ""),
network_device.get("USERNAME", ""),
network_device.get("PASSWORD", ""),
timeout=network_device["TIMEOUT"],
optional_args=network_device["OPTIONAL_ARGS"],
)
network_device.get("DRIVER").open()
# no exception raised here, means connection established
network_device["UP"] = True
except napalm.base.exceptions.ConnectionException as error:
base_err_msg = "Cannot connect to {hostname}{port} as {username}.".format(
hostname=network_device.get("HOSTNAME", "[unspecified hostname]"),
port=(
":{port}".format(
port=network_device.get("OPTIONAL_ARGS", {}).get("port")
)
if network_device.get("OPTIONAL_ARGS", {}).get("port")
else ""
),
username=network_device.get("USERNAME", ""),
)
log.error(base_err_msg)
log.error("Please check error: %s", error)
raise napalm.base.exceptions.ConnectionException(base_err_msg)
return network_device
def proxy_napalm_wrap(func):
"""
This decorator is used to make the execution module functions
available outside a proxy minion, or when running inside a proxy
minion. If we are running in a proxy, retrieve the connection details
from the __proxy__ injected variable. If we are not, then
use the connection information from the opts.
:param func:
:return:
"""
@wraps(func)
def func_wrapper(*args, **kwargs):
wrapped_global_namespace = func.__globals__
# get __opts__ and __proxy__ from func_globals
proxy = wrapped_global_namespace.get("__proxy__")
opts = copy.deepcopy(wrapped_global_namespace.get("__opts__"))
# in any case, will inject the `napalm_device` global
# the execution modules will make use of this variable from now on
# previously they were accessing the device properties through the __proxy__ object
always_alive = opts.get("proxy", {}).get("always_alive", True)
# force_reconnect is a magic keyword arg that allows one to establish
# a separate connection to the network device running under an always
# alive Proxy Minion, using new credentials (overriding the ones
# configured in the opts / pillar.
force_reconnect = kwargs.get("force_reconnect", False)
if force_reconnect:
log.debug("Usage of reconnect force detected")
log.debug("Opts before merging")
log.debug(opts["proxy"])
opts["proxy"].update(**kwargs)
log.debug("Opts after merging")
log.debug(opts["proxy"])
if is_proxy(opts) and always_alive:
# if it is running in a NAPALM Proxy and it's using the default
# always alive behaviour, will get the cached copy of the network
# device object which should preserve the connection.
if force_reconnect:
wrapped_global_namespace["napalm_device"] = get_device(opts)
else:
wrapped_global_namespace["napalm_device"] = proxy["napalm.get_device"]()
elif is_proxy(opts) and not always_alive:
# if still proxy, but the user does not want the SSH session always alive
# get a new device instance
# which establishes a new connection
# which is closed just before the call() function defined above returns
if "inherit_napalm_device" not in kwargs or (
"inherit_napalm_device" in kwargs
and not kwargs["inherit_napalm_device"]
):
# try to open a new connection
# but only if the function does not inherit the napalm driver
# for configuration management this is very important,
# in order to make sure we are editing the same session.
try:
wrapped_global_namespace["napalm_device"] = get_device(opts)
except napalm.base.exceptions.ConnectionException as nce:
log.error(nce)
return "{base_msg}. See log for details.".format(
base_msg=str(nce.msg)
)
else:
# in case the `inherit_napalm_device` is set
# and it also has a non-empty value,
# the global var `napalm_device` will be overridden.
# this is extremely important for configuration-related features
# as all actions must be issued within the same configuration session
# otherwise we risk to open multiple sessions
wrapped_global_namespace["napalm_device"] = kwargs[
"inherit_napalm_device"
]
else:
# if not a NAPLAM proxy
# thus it is running on a regular minion, directly on the network device
# or another flavour of Minion from where we can invoke arbitrary
# NAPALM commands
# get __salt__ from func_globals
log.debug("Not running in a NAPALM Proxy Minion")
_salt_obj = wrapped_global_namespace.get("__salt__")
napalm_opts = _salt_obj["config.get"]("napalm", {})
napalm_inventory = _salt_obj["config.get"]("napalm_inventory", {})
log.debug("NAPALM opts found in the Minion config")
log.debug(napalm_opts)
clean_kwargs = salt.utils.args.clean_kwargs(**kwargs)
napalm_opts.update(clean_kwargs) # no need for deeper merge
log.debug("Merging the found opts with the CLI args")
log.debug(napalm_opts)
host = (
napalm_opts.get("host")
or napalm_opts.get("hostname")
or napalm_opts.get("fqdn")
or napalm_opts.get("ip")
)
if (
host
and napalm_inventory
and isinstance(napalm_inventory, dict)
and host in napalm_inventory
):
inventory_opts = napalm_inventory[host]
log.debug("Found %s in the NAPALM inventory:", host)
log.debug(inventory_opts)
napalm_opts.update(inventory_opts)
log.debug(
"Merging the config for %s with the details found in the napalm"
" inventory:",
host,
)
log.debug(napalm_opts)
opts = copy.deepcopy(opts) # make sure we don't override the original
# opts, but just inject the CLI args from the kwargs to into the
# object manipulated by ``get_device_opts`` to extract the
# connection details, then use then to establish the connection.
opts["napalm"] = napalm_opts
if "inherit_napalm_device" not in kwargs or (
"inherit_napalm_device" in kwargs
and not kwargs["inherit_napalm_device"]
):
# try to open a new connection
# but only if the function does not inherit the napalm driver
# for configuration management this is very important,
# in order to make sure we are editing the same session.
try:
wrapped_global_namespace["napalm_device"] = get_device(
opts, salt_obj=_salt_obj
)
except napalm.base.exceptions.ConnectionException as nce:
log.error(nce)
return "{base_msg}. See log for details.".format(
base_msg=str(nce.msg)
)
else:
# in case the `inherit_napalm_device` is set
# and it also has a non-empty value,
# the global var `napalm_device` will be overridden.
# this is extremely important for configuration-related features
# as all actions must be issued within the same configuration session
# otherwise we risk to open multiple sessions
wrapped_global_namespace["napalm_device"] = kwargs[
"inherit_napalm_device"
]
if not_always_alive(opts):
# inject the __opts__ only when not always alive
# otherwise, we don't want to overload the always-alive proxies
wrapped_global_namespace["napalm_device"]["__opts__"] = opts
ret = func(*args, **kwargs)
if force_reconnect:
log.debug("That was a forced reconnect, gracefully clearing up")
device = wrapped_global_namespace["napalm_device"]
closing = call(device, "close", __retry=False)
return ret
return func_wrapper
def default_ret(name):
"""
Return the default dict of the state output.
"""
ret = {"name": name, "changes": {}, "result": False, "comment": ""}
return ret
def loaded_ret(ret, loaded, test, debug, compliance_report=False, opts=None):
"""
Return the final state output.
ret
The initial state output structure.
loaded
The loaded dictionary.
"""
# Always get the comment
changes = {}
ret["comment"] = loaded["comment"]
if "diff" in loaded:
changes["diff"] = loaded["diff"]
if "commit_id" in loaded:
changes["commit_id"] = loaded["commit_id"]
if "compliance_report" in loaded:
if compliance_report:
changes["compliance_report"] = loaded["compliance_report"]
if debug and "loaded_config" in loaded:
changes["loaded_config"] = loaded["loaded_config"]
if changes.get("diff"):
ret["comment"] = "{comment_base}\n\nConfiguration diff:\n\n{diff}".format(
comment_base=ret["comment"], diff=changes["diff"]
)
if changes.get("loaded_config"):
ret["comment"] = "{comment_base}\n\nLoaded config:\n\n{loaded_cfg}".format(
comment_base=ret["comment"], loaded_cfg=changes["loaded_config"]
)
if changes.get("compliance_report"):
ret["comment"] = "{comment_base}\n\nCompliance report:\n\n{compliance}".format(
comment_base=ret["comment"],
compliance=salt.output.string_format(
changes["compliance_report"], "nested", opts=opts
),
)
if not loaded.get("result", False):
# Failure of some sort
return ret
if not loaded.get("already_configured", True):
# We're making changes
if test:
ret["result"] = None
return ret
# Not test, changes were applied
ret.update(
{
"result": True,
"changes": changes,
"comment": "Configuration changed!\n{}".format(loaded["comment"]),
}
)
return ret
# No changes
ret.update({"result": True, "changes": {}})
return ret
Zerion Mini Shell 1.0