salt/utils/napalm.py
# -*- coding: utf-8 -*-
'''
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 Python libs
from __future__ import absolute_import, unicode_literals, print_function
import copy
import traceback
import logging
import importlib
from functools import wraps
# Import Salt libs
from salt.ext import six
import salt.output
import salt.utils.platform
import salt.utils.args
# Import third party libs
try:
# will try to import NAPALM
# https://github.com/napalm-automation/napalm
# pylint: disable=W0611
import napalm
import napalm.base as napalm_base
# pylint: enable=W0611
HAS_NAPALM = True
HAS_NAPALM_BASE = False # doesn't matter anymore, but needed for the logic below
try:
NAPALM_MAJOR = int(napalm.__version__.split('.')[0])
except AttributeError:
NAPALM_MAJOR = 0
except ImportError:
HAS_NAPALM = False
try:
import napalm_base
HAS_NAPALM_BASE = True
except ImportError:
HAS_NAPALM_BASE = False
try:
# try importing ConnectionClosedException
# from napalm-base
# this exception has been introduced only in version 0.24.0
from napalm_base.exceptions import ConnectionClosedException
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) or HAS_NAPALM_BASE) and (is_proxy(opts) or is_minion(opts)):
return virtualname
else:
return (
False,
(
'"{vname}"" {filename} cannot be loaded: '
'NAPALM is not installed: ``pip install napalm``'
).format(
vname=virtualname,
filename='({filename})'.format(filename=filename)
)
)
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 six.iteritems(kwargs_copy):
# 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:
# 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'):
# In case the user requires a different provider library,
# other than napalm-base.
# For example, if napalm-base does not satisfy the requirements
# and needs to be enahanced with more specific features,
# we may need to define a custom library on top of napalm-base
# with the constraint that it still needs to provide the
# `get_network_driver` function. However, even this can be
# extended later, if really needed.
# 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)
log.error('Falling back to napalm-base')
_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=six.text_type(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=six.text_type(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