updates eos shared modules (#20738)

* eos module now uses network_cli connection plugin
* adds unit tests for eos module
* eapi support now provided by eapi module
* updates doc fragment for eapi common properties
This commit is contained in:
Peter Sprygada 2017-01-26 23:33:07 -05:00 committed by GitHub
parent e8a00377ae
commit ad83756b48
4 changed files with 559 additions and 283 deletions

View file

@ -0,0 +1,259 @@
# This code is part of Ansible, but is an independent component.
# This particular file snippet, and this file snippet only, is BSD licensed.
# Modules you write using this snippet, which is embedded dynamically by Ansible
# still belong to the author of the module, and may assign their own license
# to the complete work.
#
# (c) 2017, Red Hat, Inc.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
import time
from ansible.module_utils.basic import env_fallback
from ansible.module_utils.urls import fetch_url
from ansible.module_utils.network_common import to_list
_DEVICE_CONNECTION = None
_DEVICE_CONFIGS = {}
_SESSION_SUPPORT = None
eapi_argument_spec = dict(
host=dict(),
port=dict(type='int'),
url_username=dict(fallback=(env_fallback, ['ANSIBLE_NET_USERNAME']), aliases=('username',)),
url_password=dict(fallback=(env_fallback, ['ANSIBLE_NET_PASSWORD']), aliases=('password',), no_log=True),
authorize=dict(default=False, fallback=(env_fallback, ['ANSIBLE_NET_AUTHORIZE']), type='bool'),
auth_pass=dict(no_log=True, fallback=(env_fallback, ['ANSIBLE_NET_AUTH_PASS'])),
provider=dict(type='dict'),
# deprecated in Ansible 2.3
transport=dict(),
use_ssl=dict(type='bool', default=True),
validate_certs=dict(type='bool', default=True),
timeout=dict(default=10, type='int')
)
def check_args(module):
for key in ('host', 'username', 'password'):
if not module.params[key]:
module.fail_json(msg='missing required argument %s' % key)
if module.params['transport'] == 'cli':
module.fail_json(msg='transport: cli is no longer supported, use '
'connection=network_cli instead')
class Eapi:
def __init__(self, module):
self._module = module
self._enable = None
host = module.params['host']
port = module.params['port']
if module.params['use_ssl']:
proto = 'https'
if not port:
port = 443
else:
proto = 'http'
if not port:
port = 80
self._url = '%s://%s:%s/command-api' % (proto, host, port)
if module.params['auth_pass']:
self._enable = {'cmd': 'enable', 'input': module.params['auth_pass']}
else:
self._enable = 'enable'
def _request_builder(self, commands, output, reqid=None):
params = dict(version=1, cmds=commands, format=output)
return dict(jsonrpc='2.0', id=reqid, method='runCmds', params=params)
def send_request(self, commands, output='text'):
commands = to_list(commands)
if self._enable:
commands.insert(0, 'enable')
body = self._request_builder(commands, output)
data = self._module.jsonify(body)
headers = {'Content-Type': 'application/json-rpc'}
timeout = self._module.params['timeout']
response, headers = fetch_url(
self._module, self._url, data=data, headers=headers,
method='POST', timeout=timeout
)
if headers['status'] != 200:
module.fail_json(**headers)
try:
data = response.read()
response = self._module.from_json(data)
except ValueError:
module.fail_json(msg='unable to load response from device', data=data)
if self._enable and 'result' in response:
response['result'].pop(0)
return response
def connection(module):
global _DEVICE_CONNECTION
if not _DEVICE_CONNECTION:
_DEVICE_CONNECTION = Eapi(module)
return _DEVICE_CONNECTION
is_json = lambda x: str(x).endswith('| json')
is_text = lambda x: not str(x).endswith('| json')
def run_commands(module, commands):
"""Runs list of commands on remote device and returns results
"""
output = None
queue = list()
responses = list()
conn = connection(module)
def _send(commands, output):
response = conn.send_request(commands, output=output)
if 'error' in response:
err = response['error']
module.fail_json(msg=err['message'], code=err['code'])
return response['result']
for item in to_list(commands):
if all((output == 'json', is_text(item))) or all((output =='text', is_json(item))):
responses.extend(_send(queue, output))
queue = list()
if is_json(item):
output = 'json'
else:
output = 'text'
queue.append(item)
if queue:
responses.extend(_send(queue, output))
for index, item in enumerate(commands):
if is_text(item):
responses[index] = responses[index]['output'].strip()
return responses
def get_config(module, flags=[]):
"""Retrieves the current config from the device or cache
"""
cmd = 'show running-config '
cmd += ' '.join(flags)
cmd = cmd.strip()
try:
return _DEVICE_CONFIGS[cmd]
except KeyError:
conn = connection(module)
out = conn.send_request(cmd)
cfg = str(out['result'][0]['output']).strip()
_DEVICE_CONFIGS[cmd] = cfg
return cfg
def supports_sessions(module):
global _SESSION_SUPPORT
if _SESSION_SUPPORT is not None:
return _SESSION_SUPPORT
conn = connection(module)
response = conn.send_request(['show configuration sessions'])
_SESSION_SUPPORT = 'error' not in response
return _SESSION_SUPPORT
def configure(module, commands):
"""Sends the ordered set of commands to the device
"""
cmds = ['configure terminal']
cmds.extend(commands)
conn = connection(module)
responses = conn.send_request(commands)
if 'error' in response:
err = response['error']
module.fail_json(msg=err['message'], code=err['code'])
return responses[1:]
def load_config(module, config, commit=False, replace=False):
"""Loads the configuration onto the remote devices
If the device doesn't support configuration sessions, this will
fallback to using configure() to load the commands. If that happens,
there will be no returned diff or session values
"""
if not supports_sessions(module):
return configure(module, commands)
conn = connection(module)
session = 'ansible_%s' % int(time.time())
result = {'session': session}
commands = ['configure session %s' % session]
if replace:
commands.append('rollback clean-config')
commands.extend(config)
response = conn.send_request(commands)
if 'error' in response:
commands = ['configure session %s' % session, 'abort']
conn.send_request(commands)
err = response['error']
module.fail_json(msg=err['message'], code=err['code'])
commands = ['configure session %s' % session, 'show session-config diffs']
if commit:
commands.append('commit')
else:
commands.append('abort')
response = conn.send_request(commands, output='text')
diff = response['result'][1]['output']
if diff:
result['diff'] = diff
return result

View file

@ -4,7 +4,7 @@
# still belong to the author of the module, and may assign their own license
# to the complete work.
#
# Copyright (c) 2015 Peter Sprygada, <psprygada@ansible.com>
# (c) 2016 Red Hat Inc.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
@ -25,314 +25,114 @@
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
import re
import time
from ansible.module_utils.basic import json, get_exception
from ansible.module_utils.network import ModuleStub, NetworkError, NetworkModule
from ansible.module_utils.network import add_argument, register_transport, to_list
from ansible.module_utils.netcli import Command
from ansible.module_utils.shell import CliBase
from ansible.module_utils.urls import fetch_url, url_argument_spec
from ansible.module_utils._text import to_native
from ansible.module_utils.network_common import to_list
EAPI_FORMATS = ['json', 'text']
_DEVICE_CONFIGS = {}
add_argument('use_ssl', dict(default=True, type='bool'))
add_argument('validate_certs', dict(default=True, type='bool'))
def get_config(module, flags=[]):
cmd = 'show running-config '
cmd += ' '.join(flags)
cmd = cmd.strip()
try:
return _DEVICE_CONFIGS[cmd]
except KeyError:
rc, out, err = module.exec_command(cmd)
if rc != 0:
module.fail_json(msg='unable to retrieve current config', stderr=err)
cfg = str(out).strip()
_DEVICE_CONFIGS[cmd] = cfg
return cfg
class EosConfigMixin(object):
def check_authorization(module):
for cmd in ['show clock', 'prompt()']:
rc, out, err = module.exec_command(cmd)
return out.endswith('#')
### Config methods ###
def supports_sessions(module):
rc, out, err = module.exec_command('show configuration sessions')
return rc == 0
def configure(self, commands, **kwargs):
cmds = ['configure terminal']
cmds.extend(to_list(commands))
cmds.append('end')
responses = self.execute(cmds)
return responses[1:-1]
def run_commands(module, commands):
"""Run list of commands on remote device and return results
"""
responses = list()
def get_config(self, include_defaults=False, **kwargs):
cmd = 'show running-config'
if include_defaults:
cmd += ' all'
return self.execute([cmd])[0]
for cmd in to_list(commands):
rc, out, err = module.exec_command(cmd)
def load_config(self, config, commit=False, replace=False):
if self.supports_sessions():
return self.load_config_session(config, commit, replace)
else:
return self.configure(config)
def load_config_session(self, config, commit=False, replace=False):
""" Loads the configuration into the remote device
"""
session = 'ansible_%s' % int(time.time())
commands = ['configure session %s' % session]
if replace:
commands.append('rollback clean-config')
commands.extend(config)
if commands[-1] != 'end':
commands.append('end')
if rc != 0:
module.fail_json(msg=err)
try:
self.execute(commands)
diff = self.diff_config(session)
if commit:
self.commit_config(session)
else:
self.execute(['no configure session %s' % session])
except NetworkError:
exc = get_exception()
if 'timeout trying to send command' in to_native(exc):
# try to get control back and get out of config mode
if isinstance(self, Cli):
self.execute(['\x03', 'end'])
self.abort_config(session)
diff = None
raise
return diff
def save_config(self):
self.execute(['copy running-config startup-config'])
def diff_config(self, session):
commands = ['configure session %s' % session,
'show session-config diffs',
'end']
if isinstance(self, Eapi):
response = self.execute(commands, output='text')
response[-2] = response[-2].get('output').strip()
else:
response = self.execute(commands)
return response[-2]
def commit_config(self, session):
commands = ['configure session %s' % session, 'commit']
self.execute(commands)
def abort_config(self, session):
commands = ['configure session %s' % session, 'abort']
self.execute(commands)
def supports_sessions(self):
try:
if isinstance(self, Eapi):
self.execute(['show configuration sessions'], output='text')
else:
self.execute('show configuration sessions')
return True
except NetworkError:
return False
class Eapi(EosConfigMixin):
def __init__(self):
self.url = None
self.url_args = ModuleStub(url_argument_spec(), self._error)
self.enable = None
self.default_output = 'json'
self._connected = False
def _error(self, msg):
raise NetworkError(msg, url=self.url)
def _get_body(self, commands, output, reqid=None):
"""Create a valid eAPI JSON-RPC request message
"""
if output not in EAPI_FORMATS:
msg = 'invalid format, received %s, expected one of %s' % \
(output, ', '.join(EAPI_FORMATS))
self._error(msg=msg)
params = dict(version=1, cmds=commands, format=output)
return dict(jsonrpc='2.0', id=reqid, method='runCmds', params=params)
def connect(self, params, **kwargs):
host = params['host']
port = params['port']
# sets the module_utils/urls.py req parameters
self.url_args.params['url_username'] = params['username']
self.url_args.params['url_password'] = params['password']
self.url_args.params['validate_certs'] = params['validate_certs']
self.url_args.params['timeout'] = params['timeout']
if params['use_ssl']:
proto = 'https'
if not port:
port = 443
else:
proto = 'http'
if not port:
port = 80
self.url = '%s://%s:%s/command-api' % (proto, host, port)
self._connected = True
def disconnect(self, **kwargs):
self.url = None
self._connected = False
def authorize(self, params, **kwargs):
if params.get('auth_pass'):
passwd = params['auth_pass']
self.enable = dict(cmd='enable', input=passwd)
else:
self.enable = 'enable'
### Command methods ###
def execute(self, commands, output='json', **kwargs):
"""Send commands to the device.
"""
if self.url is None:
raise NetworkError('Not connected to endpoint.')
if self.enable is not None:
commands.insert(0, self.enable)
body = self._get_body(commands, output)
data = json.dumps(body)
headers = {'Content-Type': 'application/json-rpc'}
timeout = self.url_args.params['timeout']
response, headers = fetch_url(
self.url_args, self.url, data=data, headers=headers,
method='POST', timeout=timeout
)
if headers['status'] != 200:
raise NetworkError(**headers)
try:
response = json.loads(response.read())
out = module.from_json(out)
except ValueError:
raise NetworkError('unable to load response from device')
out = str(out).strip()
if 'error' in response:
err = response['error']
raise NetworkError(
msg=err['message'], code=err['code'], data=err['data'],
commands=commands
)
responses.append(out)
return responses
if self.enable:
response['result'].pop(0)
def configure(module, commands):
"""Sends configuration commands to the remote device
"""
if not check_authorization(module):
module.fail_json(msg='configuration operations require privilege escalation')
return response['result']
rc, out, err = module.exec_command('configure')
if rc != 0:
module.fail_json(msg='unable to enter configuration mode', output=err)
def run_commands(self, commands, **kwargs):
output = None
cmds = list()
responses = list()
for cmd in to_list(commands):
if cmd == 'end':
continue
rc, out, err = module.exec_command(cmd)
if rc != 0:
module.fail_json(msg=err)
for cmd in commands:
if output and output != cmd.output:
responses.extend(self.execute(cmds, output=output))
cmds = list()
module.exec_command('end')
output = cmd.output
cmds.append(str(cmd))
def load_config(module, commands, commit=False, replace=False):
"""Loads the config commands onto the remote device
"""
if not check_authorization(module):
module.fail_json(msg='configuration operations require privilege escalation')
if cmds:
responses.extend(self.execute(cmds, output=output))
if not supports_sessions(module):
return configure(commands)
for index, cmd in enumerate(commands):
if cmd.output == 'text':
responses[index] = responses[index].get('output')
session = 'ansible_%s' % int(time.time())
return responses
result = {'session': session}
### Config methods ###
rc, out, err = module.exec_command('configure session %s' % session)
if rc != 0:
module.fail_json(msg='unable to enter configuration mode', output=err)
def get_config(self, include_defaults=False):
cmd = 'show running-config'
if include_defaults:
cmd += ' all'
return self.execute([cmd], output='text')[0]['output']
if replace:
module.exec_command('rollback clean-config', check_rc=True)
Eapi = register_transport('eapi')(Eapi)
failed = False
for command in to_list(commands):
if command == 'end':
pass
rc, out, err = module.exec_command(command)
if rc != 0:
failed = True
break
class Cli(EosConfigMixin, CliBase):
rc, out, err = module.exec_command('show session-config diffs')
if rc == 0:
result['diff'] = out
CLI_PROMPTS_RE = [
re.compile(r"[\r\n]?[\w+\-\.:\/\[\]]+(?:\([^\)]+\)){,3}(?:>|#) ?$"),
re.compile(r"\[\w+\@[\w\-\.]+(?: [^\]])\] ?[>#\$] ?$")
]
if failed:
module.exec_command('abort')
module.fail_json(msg=err, commands=commands)
elif commit:
module.exec_command('commit')
else:
module.exec_command('abort')
CLI_ERRORS_RE = [
re.compile(r"% ?Error"),
re.compile(r"^% \w+", re.M),
re.compile(r"% ?Bad secret"),
re.compile(r"invalid input", re.I),
re.compile(r"(?:incomplete|ambiguous) command", re.I),
re.compile(r"connection timed out", re.I),
re.compile(r"[^\r\n]+ not found", re.I),
re.compile(r"'[^']' +returned error code: ?\d+"),
re.compile(r"[^\r\n]\/bin\/(?:ba)?sh")
]
NET_PASSWD_RE = re.compile(r"[\r\n]?password: $", re.I)
def connect(self, params, **kwargs):
super(Cli, self).connect(params, kickstart=False, **kwargs)
self.shell.send('terminal length 0')
def authorize(self, params, **kwargs):
passwd = params['auth_pass']
if passwd:
self.execute(Command('enable', prompt=self.NET_PASSWD_RE, response=passwd))
else:
self.execute('enable')
### Command methods ###
def run_commands(self, commands):
cmds = list(prepare_commands(commands))
responses = self.execute(cmds)
for index, cmd in enumerate(commands):
if cmd.output == 'json':
try:
responses[index] = json.loads(responses[index])
except ValueError:
raise NetworkError(
msg='unable to load response from device',
response=responses[index],
responses=responses
)
return responses
Cli = register_transport('cli', default=True)(Cli)
def prepare_config(commands):
commands = to_list(commands)
commands.insert(0, 'configure terminal')
commands.append('end')
return commands
def prepare_commands(commands):
jsonify = lambda x: '%s | json' % x
for item in to_list(commands):
if item.output == 'json':
cmd = jsonify(item)
elif item.command.endswith('| json'):
item.output = 'json'
cmd = str(item)
else:
cmd = str(item)
yield cmd
return result