From 02432565cdd345bcb24a177a414aee77fb469b13 Mon Sep 17 00:00:00 2001 From: Nathaniel Case Date: Tue, 11 Dec 2018 16:26:59 -0500 Subject: [PATCH] Remove cliconf from httpapi connection (#46813) * Bare minimum rip out cliconf * nxapi changeover * Update documentation, move options * Memoize device_info * Gratuitous rename to underscore use of local api implementation Fixup eos module_utils like nxos * Streamline version and image scans * Expose get_capabilities through module_utils * Add load_config to module_utils * Support rpcs using both args and kwargs * Add get_config for nxos * Add get_diff * module context, pulled from nxapi We could probably do this correctly later * Fix eos issues * Limit connection._sub_plugin to only one plugin --- lib/ansible/executor/task_executor.py | 6 +- lib/ansible/module_utils/connection.py | 5 +- lib/ansible/module_utils/network/eos/eos.py | 189 +++++++++++++++- lib/ansible/module_utils/network/nxos/nxos.py | 175 ++++++++++++++- lib/ansible/plugins/cliconf/eos.py | 23 +- lib/ansible/plugins/cliconf/nxos.py | 23 +- lib/ansible/plugins/connection/__init__.py | 18 +- lib/ansible/plugins/connection/httpapi.py | 14 +- lib/ansible/plugins/connection/napalm.py | 2 +- lib/ansible/plugins/connection/netconf.py | 4 +- lib/ansible/plugins/connection/network_cli.py | 2 +- lib/ansible/plugins/httpapi/eos.py | 210 +++++++++--------- lib/ansible/plugins/httpapi/nxos.py | 156 ++++++++----- lib/ansible/utils/jsonrpc.py | 10 +- 14 files changed, 575 insertions(+), 262 deletions(-) diff --git a/lib/ansible/executor/task_executor.py b/lib/ansible/executor/task_executor.py index a6b72fb87ca..2c48e2c9b07 100644 --- a/lib/ansible/executor/task_executor.py +++ b/lib/ansible/executor/task_executor.py @@ -900,9 +900,9 @@ class TaskExecutor: final_vars = combine_vars(variables, variables.get('ansible_delegated_vars', dict()).get(self._task.delegate_to, dict())) option_vars = C.config.get_plugin_vars('connection', connection._load_name) - for plugin in connection._sub_plugins: - if plugin['type'] != 'external': - option_vars.extend(C.config.get_plugin_vars(plugin['type'], plugin['name'])) + plugin = connection._sub_plugin + if plugin['type'] != 'external': + option_vars.extend(C.config.get_plugin_vars(plugin['type'], plugin['name'])) options = {} for k in option_vars: diff --git a/lib/ansible/module_utils/connection.py b/lib/ansible/module_utils/connection.py index 1bf6f708f7c..5e5451b5cd9 100644 --- a/lib/ansible/module_utils/connection.py +++ b/lib/ansible/module_utils/connection.py @@ -100,10 +100,7 @@ def exec_command(module, command): def request_builder(method_, *args, **kwargs): reqid = str(uuid.uuid4()) req = {'jsonrpc': '2.0', 'method': method_, 'id': reqid} - - params = args or kwargs or None - if params: - req['params'] = params + req['params'] = (args, kwargs) return req diff --git a/lib/ansible/module_utils/network/eos/eos.py b/lib/ansible/module_utils/network/eos/eos.py index 7c9296b9077..7f39ab3f9fb 100644 --- a/lib/ansible/module_utils/network/eos/eos.py +++ b/lib/ansible/module_utils/network/eos/eos.py @@ -27,6 +27,7 @@ # 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 json import os import time @@ -99,10 +100,15 @@ def get_connection(module): global _DEVICE_CONNECTION if not _DEVICE_CONNECTION: load_params(module) - if is_eapi(module): - conn = Eapi(module) + if is_local_eapi(module): + conn = LocalEapi(module) else: - conn = Cli(module) + connection_proxy = Connection(module._socket_path) + cap = json.loads(connection_proxy.get_capabilities()) + if cap['network_api'] == 'cliconf': + conn = Cli(module) + elif cap['network_api'] == 'eapi': + conn = HttpApi(module) _DEVICE_CONNECTION = conn return _DEVICE_CONNECTION @@ -180,7 +186,7 @@ class Cli: return diff -class Eapi: +class LocalEapi: def __init__(self, module): self._module = module @@ -394,18 +400,187 @@ class Eapi: return diff +class HttpApi: + def __init__(self, module): + self._module = module + self._device_configs = {} + self._session_support = None + self._connection_obj = None + + @property + def _connection(self): + if not self._connection_obj: + self._connection_obj = Connection(self._module._socket_path) + + return self._connection_obj + + def run_commands(self, commands, check_rc=True): + """Runs list of commands on remote device and returns results + """ + output = None + queue = list() + responses = list() + + def run_queue(queue, output): + try: + response = to_list(self._connection.send_request(queue, output=output)) + except Exception as exc: + if check_rc: + raise + return to_text(exc) + + if output == 'json': + response = [json.loads(item) for item in response] + return response + + for item in to_list(commands): + cmd_output = 'text' + if isinstance(item, dict): + command = item['command'] + if 'output' in item: + cmd_output = item['output'] + else: + command = item + + # Emulate '| json' from CLI + if is_json(command): + command = command.rsplit('|', 1)[0] + cmd_output = 'json' + + if output and output != cmd_output: + responses.extend(run_queue(queue, output)) + queue = list() + + output = cmd_output + queue.append(command) + + if queue: + responses.extend(run_queue(queue, output)) + + return responses + + def get_config(self, flags=None): + """Retrieves the current config from the device or cache + """ + flags = [] if flags is None else flags + + cmd = 'show running-config ' + cmd += ' '.join(flags) + cmd = cmd.strip() + + try: + return self._device_configs[cmd] + except KeyError: + try: + out = self._connection.send_request(cmd) + except ConnectionError as exc: + self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + + cfg = to_text(out).strip() + self._device_configs[cmd] = cfg + return cfg + + def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): + diff = {} + + # prepare candidate configuration + candidate_obj = NetworkConfig(indent=3) + candidate_obj.load(candidate) + + if running and diff_match != 'none' and diff_replace != 'config': + # running configuration + running_obj = NetworkConfig(indent=3, contents=running, ignore_lines=diff_ignore_lines) + configdiffobjs = candidate_obj.difference(running_obj, path=path, match=diff_match, replace=diff_replace) + + else: + configdiffobjs = candidate_obj.items + + diff['config_diff'] = dumps(configdiffobjs, 'commands') if configdiffobjs else {} + return diff + + def load_config(self, 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 + """ + return self.edit_config(config, commit, replace) + + def edit_config(self, 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 + """ + session = 'ansible_%s' % int(time.time()) + result = {'session': session} + banner_cmd = None + banner_input = [] + + commands = ['configure session %s' % session] + if replace: + commands.append('rollback clean-config') + + for command in config: + if command.startswith('banner'): + banner_cmd = command + banner_input = [] + elif banner_cmd: + if command == 'EOF': + command = {'cmd': banner_cmd, 'input': '\n'.join(banner_input)} + banner_cmd = None + commands.append(command) + else: + banner_input.append(command) + continue + else: + commands.append(command) + + try: + response = self._connection.send_request(commands) + except Exception: + commands = ['configure session %s' % session, 'abort'] + response = self._connection.send_request(commands, output='text') + raise + + commands = ['configure session %s' % session, 'show session-config diffs'] + if commit: + commands.append('commit') + else: + commands.append('abort') + + response = self._connection.send_request(commands, output='text') + diff = response[1].strip() + if diff: + result['diff'] = diff + + return result + + def get_capabilities(self): + """Returns platform info of the remove device + """ + try: + capabilities = self._connection.get_capabilities() + except ConnectionError as exc: + self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + + return json.loads(capabilities) + + def is_json(cmd): - return to_native(cmd, errors='surrogate_then_replace').endswith('| json') + return to_text(cmd, errors='surrogate_then_replace').endswith('| json') -def is_eapi(module): +def is_local_eapi(module): transport = module.params['transport'] provider_transport = (module.params['provider'] or {}).get('transport') return 'eapi' in (transport, provider_transport) def to_command(module, commands): - if is_eapi(module): + if is_local_eapi(module): default_output = 'json' else: default_output = 'text' diff --git a/lib/ansible/module_utils/network/nxos/nxos.py b/lib/ansible/module_utils/network/nxos/nxos.py index 140cabda919..9f8c7ff08a2 100644 --- a/lib/ansible/module_utils/network/nxos/nxos.py +++ b/lib/ansible/module_utils/network/nxos/nxos.py @@ -105,10 +105,15 @@ def get_connection(module): global _DEVICE_CONNECTION if not _DEVICE_CONNECTION: load_params(module) - if is_nxapi(module): - conn = Nxapi(module) + if is_local_nxapi(module): + conn = LocalNxapi(module) else: - conn = Cli(module) + connection_proxy = Connection(module._socket_path) + cap = json.loads(connection_proxy.get_capabilities()) + if cap['network_api'] == 'cliconf': + conn = Cli(module) + elif cap['network_api'] == 'nxapi': + conn = HttpApi(module) _DEVICE_CONNECTION = conn return _DEVICE_CONNECTION @@ -244,7 +249,7 @@ class Cli: return None -class Nxapi: +class LocalNxapi: OUTPUT_TO_COMMAND_TYPE = { 'text': 'cli_show_ascii', @@ -496,22 +501,178 @@ class Nxapi: return None +class HttpApi: + def __init__(self, module): + self._module = module + self._device_configs = {} + self._module_context = {} + self._connection_obj = None + + @property + def _connection(self): + if not self._connection_obj: + self._connection_obj = Connection(self._module._socket_path) + + return self._connection_obj + + def run_commands(self, commands, check_rc=True): + """Runs list of commands on remote device and returns results + """ + try: + out = self._connection.send_request(commands) + except ConnectionError as exc: + if check_rc is True: + raise + out = to_text(exc) + + out = to_list(out) + if not out[0]: + return out + + for index, response in enumerate(out): + if response[0] == '{': + out[index] = json.loads(response) + + return out + + def get_config(self, flags=None): + """Retrieves the current config from the device or cache + """ + flags = [] if flags is None else flags + + cmd = 'show running-config ' + cmd += ' '.join(flags) + cmd = cmd.strip() + + try: + return self._device_configs[cmd] + except KeyError: + try: + out = self._connection.send_request(cmd) + except ConnectionError as exc: + self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + + cfg = to_text(out).strip() + self._device_configs[cmd] = cfg + return cfg + + def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): + diff = {} + + # prepare candidate configuration + candidate_obj = NetworkConfig(indent=2) + candidate_obj.load(candidate) + + if running and diff_match != 'none' and diff_replace != 'config': + # running configuration + running_obj = NetworkConfig(indent=2, contents=running, ignore_lines=diff_ignore_lines) + configdiffobjs = candidate_obj.difference(running_obj, path=path, match=diff_match, replace=diff_replace) + + else: + configdiffobjs = candidate_obj.items + + diff['config_diff'] = dumps(configdiffobjs, 'commands') if configdiffobjs else '' + return diff + + def load_config(self, commands, return_error=False, opts=None, replace=None): + """Sends the ordered set of commands to the device + """ + if opts is None: + opts = {} + + responses = [] + try: + resp = self.edit_config(commands, replace=replace) + except ConnectionError as exc: + code = getattr(exc, 'code', 1) + message = getattr(exc, 'err', exc) + err = to_text(message, errors='surrogate_then_replace') + if opts.get('ignore_timeout') and code: + responses.append(code) + return responses + elif code and 'no graceful-restart' in err: + if 'ISSU/HA will be affected if Graceful Restart is disabled' in err: + msg = [''] + responses.extend(msg) + return responses + else: + self._module.fail_json(msg=err) + elif code: + self._module.fail_json(msg=err) + + responses.extend(resp) + return responses + + def edit_config(self, candidate=None, commit=True, replace=None, comment=None): + resp = list() + + self.check_edit_config_capability(candidate, commit, replace, comment) + + if replace: + candidate = 'config replace {0}'.format(replace) + + responses = self._connection.send_request(candidate, output='config') + for response in to_list(responses): + if response != '{}': + resp.append(response) + if not resp: + resp = [''] + + return resp + + def get_capabilities(self): + """Returns platform info of the remove device + """ + try: + capabilities = self._connection.get_capabilities() + except ConnectionError as exc: + self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + + return json.loads(capabilities) + + def check_edit_config_capability(self, candidate=None, commit=True, replace=None, comment=None): + operations = self._connection.get_device_operations() + + if not candidate and not replace: + raise ValueError("must provide a candidate or replace to load configuration") + + if commit not in (True, False): + raise ValueError("'commit' must be a bool, got %s" % commit) + + if replace and not operations.get('supports_replace'): + raise ValueError("configuration replace is not supported") + + if comment and not operations.get('supports_commit_comment', False): + raise ValueError("commit comment is not supported") + + def read_module_context(self, module_key): + if self._module_context.get(module_key): + return self._module_context[module_key] + + return None + + def save_module_context(self, module_key, module_context): + self._module_context[module_key] = module_context + + return None + + def is_json(cmd): - return str(cmd).endswith('| json') + return to_text(cmd).endswith('| json') def is_text(cmd): return not is_json(cmd) -def is_nxapi(module): +def is_local_nxapi(module): transport = module.params['transport'] provider_transport = (module.params['provider'] or {}).get('transport') return 'nxapi' in (transport, provider_transport) def to_command(module, commands): - if is_nxapi(module): + if is_local_nxapi(module): default_output = 'json' else: default_output = 'text' diff --git a/lib/ansible/plugins/cliconf/eos.py b/lib/ansible/plugins/cliconf/eos.py index a3e7d0ce1f6..bce267d2f7e 100644 --- a/lib/ansible/plugins/cliconf/eos.py +++ b/lib/ansible/plugins/cliconf/eos.py @@ -50,8 +50,6 @@ from ansible.module_utils.common._collections_compat import Mapping from ansible.module_utils.network.common.utils import to_list from ansible.module_utils.network.common.config import NetworkConfig, dumps from ansible.plugins.cliconf import CliconfBase, enable_mode -from ansible.plugins.connection.network_cli import Connection as NetworkCli -from ansible.plugins.connection.httpapi import Connection as HttpApi class Cliconf(CliconfBase): @@ -60,20 +58,6 @@ class Cliconf(CliconfBase): super(Cliconf, self).__init__(*args, **kwargs) self._session_support = None - def send_command(self, command, **kwargs): - """Executes a cli command and returns the results - This method will execute the CLI command on the connection and return - the results to the caller. The command output will be returned as a - string - """ - if isinstance(self._connection, NetworkCli): - resp = super(Cliconf, self).send_command(command, **kwargs) - elif isinstance(self._connection, HttpApi): - resp = self._connection.send_request(command, **kwargs) - else: - raise ValueError("Invalid connection type") - return resp - @enable_mode def get_config(self, source='running', format='text', flags=None): options_values = self.get_option_values() @@ -294,13 +278,8 @@ class Cliconf(CliconfBase): result['device_info'] = self.get_device_info() result['device_operations'] = self.get_device_operations() result.update(self.get_option_values()) + result['network_api'] = 'cliconf' - if isinstance(self._connection, NetworkCli): - result['network_api'] = 'cliconf' - elif isinstance(self._connection, HttpApi): - result['network_api'] = 'eapi' - else: - raise ValueError("Invalid connection type") return json.dumps(result) def _get_command_with_output(self, command, output): diff --git a/lib/ansible/plugins/cliconf/nxos.py b/lib/ansible/plugins/cliconf/nxos.py index c1bf43f4c10..00129eac0e3 100644 --- a/lib/ansible/plugins/cliconf/nxos.py +++ b/lib/ansible/plugins/cliconf/nxos.py @@ -29,8 +29,6 @@ from ansible.module_utils.connection import ConnectionError from ansible.module_utils.network.common.config import NetworkConfig, dumps from ansible.module_utils.network.common.utils import to_list from ansible.plugins.cliconf import CliconfBase, enable_mode -from ansible.plugins.connection.network_cli import Connection as NetworkCli -from ansible.plugins.connection.httpapi import Connection as HttpApi class Cliconf(CliconfBase): @@ -50,20 +48,6 @@ class Cliconf(CliconfBase): return None - def send_command(self, command, **kwargs): - """Executes a cli command and returns the results - This method will execute the CLI command on the connection and return - the results to the caller. The command output will be returned as a - string - """ - if isinstance(self._connection, NetworkCli): - resp = super(Cliconf, self).send_command(command, **kwargs) - elif isinstance(self._connection, HttpApi): - resp = self._connection.send_request(command, **kwargs) - else: - raise ValueError("Invalid connection type") - return resp - def get_device_info(self): device_info = {} @@ -261,13 +245,8 @@ class Cliconf(CliconfBase): result['device_info'] = self.get_device_info() result['device_operations'] = self.get_device_operations() result.update(self.get_option_values()) + result['network_api'] = 'cliconf' - if isinstance(self._connection, NetworkCli): - result['network_api'] = 'cliconf' - elif isinstance(self._connection, HttpApi): - result['network_api'] = 'nxapi' - else: - raise ValueError("Invalid connection type") return json.dumps(result) def _get_command_with_output(self, command, output): diff --git a/lib/ansible/plugins/connection/__init__.py b/lib/ansible/plugins/connection/__init__.py index 43409f4b7a1..298ddbbbbdb 100644 --- a/lib/ansible/plugins/connection/__init__.py +++ b/lib/ansible/plugins/connection/__init__.py @@ -297,7 +297,7 @@ class NetworkConnectionBase(ConnectionBase): self._local = connection_loader.get('local', play_context, '/dev/null') self._local.set_options() - self._sub_plugins = [] + self._sub_plugin = {} self._cached_variables = (None, None, None) # reconstruct the socket_path and set instance values accordingly @@ -309,8 +309,9 @@ class NetworkConnectionBase(ConnectionBase): return self.__dict__[name] except KeyError: if not name.startswith('_'): - for plugin in self._sub_plugins: - method = getattr(plugin['obj'], name, None) + plugin = self._sub_plugin.get('obj') + if plugin: + method = getattr(plugin, name, None) if method is not None: return method raise AttributeError("'%s' object has no attribute '%s'" % (self.__class__.__name__, name)) @@ -342,12 +343,11 @@ class NetworkConnectionBase(ConnectionBase): def set_options(self, task_keys=None, var_options=None, direct=None): super(NetworkConnectionBase, self).set_options(task_keys=task_keys, var_options=var_options, direct=direct) - for plugin in self._sub_plugins: - if plugin['type'] != 'external': - try: - plugin['obj'].set_options(task_keys=task_keys, var_options=var_options, direct=direct) - except AttributeError: - pass + if self._sub_plugin.get('obj') and self._sub_plugin.get('type') != 'external': + try: + self._sub_plugin['obj'].set_options(task_keys=task_keys, var_options=var_options, direct=direct) + except AttributeError: + pass def _update_connection_state(self): ''' diff --git a/lib/ansible/plugins/connection/httpapi.py b/lib/ansible/plugins/connection/httpapi.py index e44e1608cad..91271b1c0d8 100644 --- a/lib/ansible/plugins/connection/httpapi.py +++ b/lib/ansible/plugins/connection/httpapi.py @@ -37,8 +37,8 @@ options: network_os: description: - Configures the device platform network operating system. This value is - used to load the correct httpapi and cliconf plugins to communicate - with the remote device + used to load the correct httpapi plugin to communicate with the remote + device vars: - name: ansible_network_os remote_user: @@ -154,7 +154,7 @@ from ansible.module_utils.six.moves import cPickle from ansible.module_utils.six.moves.urllib.error import HTTPError, URLError from ansible.module_utils.urls import open_url from ansible.playbook.play_context import PlayContext -from ansible.plugins.loader import cliconf_loader, httpapi_loader +from ansible.plugins.loader import httpapi_loader from ansible.plugins.connection import NetworkConnectionBase from ansible.utils.display import Display @@ -177,17 +177,11 @@ class Connection(NetworkConnectionBase): self.httpapi = httpapi_loader.get(self._network_os, self) if self.httpapi: - self._sub_plugins.append({'type': 'httpapi', 'name': self._network_os, 'obj': self.httpapi}) + self._sub_plugin = {'type': 'httpapi', 'name': self._network_os, 'obj': self.httpapi} display.vvvv('loaded API plugin for network_os %s' % self._network_os) else: raise AnsibleConnectionFailure('unable to load API plugin for network_os %s' % self._network_os) - self.cliconf = cliconf_loader.get(self._network_os, self) - if self.cliconf: - self._sub_plugins.append({'type': 'cliconf', 'name': self._network_os, 'obj': self.cliconf}) - display.vvvv('loaded cliconf plugin for network_os %s' % self._network_os) - else: - display.vvvv('unable to load cliconf for network_os %s' % self._network_os) else: raise AnsibleConnectionFailure( 'Unable to automatically determine host network os. Please ' diff --git a/lib/ansible/plugins/connection/napalm.py b/lib/ansible/plugins/connection/napalm.py index e2f1a913fc9..16306c7b555 100644 --- a/lib/ansible/plugins/connection/napalm.py +++ b/lib/ansible/plugins/connection/napalm.py @@ -183,7 +183,7 @@ class Connection(NetworkConnectionBase): self.napalm.open() - self._sub_plugins.append({'type': 'external', 'name': 'napalm', 'obj': self.napalm}) + self._sub_plugin = {'type': 'external', 'name': 'napalm', 'obj': self.napalm} display.vvvv('created napalm device for network_os %s' % self._network_os, host=host) self._connected = True diff --git a/lib/ansible/plugins/connection/netconf.py b/lib/ansible/plugins/connection/netconf.py index 8ac51a990bb..9436a034c92 100644 --- a/lib/ansible/plugins/connection/netconf.py +++ b/lib/ansible/plugins/connection/netconf.py @@ -217,11 +217,11 @@ class Connection(NetworkConnectionBase): netconf = netconf_loader.get(self._network_os, self) if netconf: - self._sub_plugins.append({'type': 'netconf', 'name': self._network_os, 'obj': netconf}) + self._sub_plugin = {'type': 'netconf', 'name': self._network_os, 'obj': netconf} display.display('loaded netconf plugin for network_os %s' % self._network_os, log_only=True) else: netconf = netconf_loader.get("default", self) - self._sub_plugins.append({'type': 'netconf', 'name': 'default', 'obj': netconf}) + self._sub_plugin = {'type': 'netconf', 'name': 'default', 'obj': netconf} display.display('unable to load netconf plugin for network_os %s, falling back to default plugin' % self._network_os) display.display('network_os is set to %s' % self._network_os, log_only=True) diff --git a/lib/ansible/plugins/connection/network_cli.py b/lib/ansible/plugins/connection/network_cli.py index 7b65b65b02f..eb528c85966 100644 --- a/lib/ansible/plugins/connection/network_cli.py +++ b/lib/ansible/plugins/connection/network_cli.py @@ -231,7 +231,7 @@ class Connection(NetworkConnectionBase): self.cliconf = cliconf_loader.get(self._network_os, self) if self.cliconf: display.vvvv('loaded cliconf plugin for network_os %s' % self._network_os) - self._sub_plugins.append({'type': 'cliconf', 'name': self._network_os, 'obj': self.cliconf}) + self._sub_plugin = {'type': 'cliconf', 'name': self._network_os, 'obj': self.cliconf} else: display.vvvv('unable to load cliconf for network_os %s' % self._network_os) else: diff --git a/lib/ansible/plugins/httpapi/eos.py b/lib/ansible/plugins/httpapi/eos.py index c9e7a114b66..741e8a0dbd7 100644 --- a/lib/ansible/plugins/httpapi/eos.py +++ b/lib/ansible/plugins/httpapi/eos.py @@ -4,8 +4,29 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type +DOCUMENTATION = """ +--- +author: Ansible Networking Team +httpapi: eos +short_description: Use eAPI to run command on eos platform +description: + - This eos plugin provides low level abstraction api's for + sending and receiving CLI commands with eos network devices. +version_added: "2.6" +options: + eos_use_sessions: + type: int + default: 1 + description: + - Specifies if sessions should be used on remote host or not + env: + - name: ANSIBLE_EOS_USE_SESSIONS + vars: + - name: ansible_eos_use_sessions + version_added: '2.8' +""" + import json -import time from ansible.module_utils._text import to_text from ansible.module_utils.connection import ConnectionError @@ -16,7 +37,39 @@ from ansible.utils.display import Display display = Display() +OPTIONS = { + 'format': ['text', 'json'], + 'diff_match': ['line', 'strict', 'exact', 'none'], + 'diff_replace': ['line', 'block', 'config'], + 'output': ['text', 'json'] +} + + class HttpApi(HttpApiBase): + def __init__(self, *args, **kwargs): + super(HttpApi, self).__init__(*args, **kwargs) + self._device_info = None + self._session_support = None + + @property + def supports_sessions(self): + use_session = self.get_option('eos_use_sessions') + try: + use_session = int(use_session) + except ValueError: + pass + + if not bool(use_session): + self._session_support = False + else: + if self._session_support: + return self._session_support + + response = self.send_request('show configuration sessions') + self._session_support = 'error' not in response + + return self._session_support + def send_request(self, data, **message_kwargs): data = to_list(data) if self._become: @@ -45,117 +98,51 @@ class HttpApi(HttpApiBase): return results - def get_prompt(self): - # Fake a prompt for @enable_mode - if self._become: - return '#' - return '>' - - # Imported from module_utils - def edit_config(self, 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 - """ - session = 'ansible_%s' % int(time.time()) - result = {'session': session} - banner_cmd = None - banner_input = [] - - commands = ['configure session %s' % session] - if replace: - commands.append('rollback clean-config') - - for command in config: - if command.startswith('banner'): - banner_cmd = command - banner_input = [] - elif banner_cmd: - if command == 'EOF': - command = {'cmd': banner_cmd, 'input': '\n'.join(banner_input)} - banner_cmd = None - commands.append(command) - else: - banner_input.append(command) - continue - else: - commands.append(command) + def get_device_info(self): + if self._device_info: + return self._device_info - try: - response = self.send_request(commands) - except Exception: - commands = ['configure session %s' % session, 'abort'] - response = self.send_request(commands, output='text') - raise - - commands = ['configure session %s' % session, 'show session-config diffs'] - if commit: - commands.append('commit') - else: - commands.append('abort') - - response = self.send_request(commands, output='text') - diff = response[1].strip() - if diff: - result['diff'] = diff - - return result - - def run_commands(self, commands, check_rc=True): - """Runs list of commands on remote device and returns results - """ - output = None - queue = list() - responses = list() - - def run_queue(queue, output): - try: - response = to_list(self.send_request(queue, output=output)) - except Exception as exc: - if check_rc: - raise - return to_text(exc) - - if output == 'json': - response = [json.loads(item) for item in response] - return response - - for item in to_list(commands): - cmd_output = 'text' - if isinstance(item, dict): - command = item['command'] - if 'output' in item: - cmd_output = item['output'] - else: - command = item - - # Emulate '| json' from CLI - if command.endswith('| json'): - command = command.rsplit('|', 1)[0] - cmd_output = 'json' - - if output and output != cmd_output: - responses.extend(run_queue(queue, output)) - queue = list() - - output = cmd_output - queue.append(command) - - if queue: - responses.extend(run_queue(queue, output)) - - return responses - - def load_config(self, 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 - """ - return self.edit_config(config, commit, replace) + device_info = {} + + device_info['network_os'] = 'eos' + reply = self.send_request('show version | json') + data = json.loads(reply) + + device_info['network_os_version'] = data['version'] + device_info['network_os_model'] = data['modelName'] + + reply = self.send_request('show hostname | json') + data = json.loads(reply) + + device_info['network_os_hostname'] = data['hostname'] + + self._device_info = device_info + return self._device_info + + def get_device_operations(self): + return { + 'supports_diff_replace': True, + 'supports_commit': bool(self.supports_sessions), + 'supports_rollback': False, + 'supports_defaults': False, + 'supports_onbox_diff': bool(self.supports_sessions), + 'supports_commit_comment': False, + 'supports_multiline_delimiter': False, + 'supports_diff_match': True, + 'supports_diff_ignore_lines': True, + 'supports_generate_diff': not bool(self.supports_sessions), + 'supports_replace': bool(self.supports_sessions), + } + + def get_capabilities(self): + result = {} + result['rpc'] = [] + result['device_info'] = self.get_device_info() + result['device_operations'] = self.get_device_operations() + result.update(OPTIONS) + result['network_api'] = 'eapi' + + return json.dumps(result) def handle_response(response): @@ -170,6 +157,7 @@ def handle_response(response): raise ConnectionError(error_text, code=error['code']) results = [] + for result in response['result']: if 'messages' in result: results.append(result['messages'][0]) diff --git a/lib/ansible/plugins/httpapi/nxos.py b/lib/ansible/plugins/httpapi/nxos.py index 4d065bf4c84..7e0c61977ad 100644 --- a/lib/ansible/plugins/httpapi/nxos.py +++ b/lib/ansible/plugins/httpapi/nxos.py @@ -4,7 +4,19 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type +DOCUMENTATION = """ +--- +author: Ansible Networking Team +httpapi: nxos +short_description: Use NX-API to run command on nxos platform +description: + - This eos plugin provides low level abstraction api's for + sending and receiving CLI commands with nxos network devices. +version_added: "2.6" +""" + import json +import re from ansible.module_utils._text import to_text from ansible.module_utils.connection import ConnectionError @@ -15,29 +27,18 @@ from ansible.utils.display import Display display = Display() -class HttpApi(HttpApiBase): - def _run_queue(self, queue, output): - if self._become: - display.vvvv('firing event: on_become') - queue.insert(0, 'enable') +OPTIONS = { + 'format': ['text', 'json'], + 'diff_match': ['line', 'strict', 'exact', 'none'], + 'diff_replace': ['line', 'block', 'config'], + 'output': ['text', 'json'] +} - request = request_builder(queue, output) - headers = {'Content-Type': 'application/json'} - response, response_data = self.connection.send('/ins', request, headers=headers, method='POST') - - try: - response_data = json.loads(to_text(response_data.getvalue())) - except ValueError: - raise ConnectionError('Response was not valid JSON, got {0}'.format( - to_text(response_data.getvalue()) - )) - - results = handle_response(response_data) - - if self._become: - results = results[1:] - return results +class HttpApi(HttpApiBase): + def __init__(self, *args, **kwargs): + super(HttpApi, self).__init__(*args, **kwargs) + self._device_info = None def send_request(self, data, **message_kwargs): output = None @@ -72,46 +73,93 @@ class HttpApi(HttpApiBase): return responses[0] return responses - def edit_config(self, candidate=None, commit=True, replace=None, comment=None): - resp = list() - - operations = self.connection.get_device_operations() - self.connection.check_edit_config_capability(operations, candidate, commit, replace, comment) - - if replace: - device_info = self.connection.get_device_info() - if '9K' not in device_info.get('network_os_platform', ''): - raise ConnectionError(msg=u'replace is supported only on Nexus 9K devices') - candidate = 'config replace {0}'.format(replace) + def _run_queue(self, queue, output): + if self._become: + display.vvvv('firing event: on_become') + queue.insert(0, 'enable') - responses = self.send_request(candidate, output='config') - for response in to_list(responses): - if response != '{}': - resp.append(response) - if not resp: - resp = [''] + request = request_builder(queue, output) + headers = {'Content-Type': 'application/json'} - return resp + response, response_data = self.connection.send('/ins', request, headers=headers, method='POST') - def run_commands(self, commands, check_rc=True): - """Runs list of commands on remote device and returns results - """ try: - out = self.send_request(commands) - except ConnectionError as exc: - if check_rc is True: - raise - out = to_text(exc) + response_data = json.loads(to_text(response_data.getvalue())) + except ValueError: + raise ConnectionError('Response was not valid JSON, got {0}'.format( + to_text(response_data.getvalue()) + )) - out = to_list(out) - if not out[0]: - return out + results = handle_response(response_data) - for index, response in enumerate(out): - if response[0] == '{': - out[index] = json.loads(response) + if self._become: + results = results[1:] + return results - return out + def get_device_info(self): + if self._device_info: + return self._device_info + + device_info = {} + + device_info['network_os'] = 'nxos' + reply = self.send_request('show version') + platform_reply = self.send_request('show inventory') + + find_os_version = [r'\s+system:\s+version\s*(\S+)', r'\s+kickstart:\s+version\s*(\S+)', r'\s+NXOS:\s+version\s*(\S+)'] + for regex in find_os_version: + match_ver = re.search(regex, reply, re.M) + if match_ver: + device_info['network_os_version'] = match_ver.group(1) + break + + match_chassis_id = re.search(r'Hardware\n\s+cisco\s*(\S+\s+\S+)', reply, re.M) + if match_chassis_id: + device_info['network_os_model'] = match_chassis_id.group(1) + + match_host_name = re.search(r'\s+Device name:\s*(\S+)', reply, re.M) + if match_host_name: + device_info['network_os_hostname'] = match_host_name.group(1) + + find_os_image = [r'\s+system image file is:\s*(\S+)', r'\s+kickstart image file is:\s*(\S+)', r'\s+NXOS image file is:\s*(\S+)'] + for regex in find_os_image: + match_file_name = re.search(regex, reply, re.M) + if match_file_name: + device_info['network_os_image'] = match_file_name.group(1) + break + + match_os_platform = re.search(r'NAME: "Chassis",\s*DESCR:.*\nPID:\s*(\S+)', platform_reply, re.M) + if match_os_platform: + device_info['network_os_platform'] = match_os_platform.group(1) + + self._device_info = device_info + return self._device_info + + def get_device_operations(self): + platform = self.get_device_info().get('network_os_platform', '') + return { + 'supports_diff_replace': True, + 'supports_commit': False, + 'supports_rollback': False, + 'supports_defaults': True, + 'supports_onbox_diff': False, + 'supports_commit_comment': False, + 'supports_multiline_delimiter': False, + 'supports_diff_match': True, + 'supports_diff_ignore_lines': True, + 'supports_generate_diff': True, + 'supports_replace': True if '9K' in platform else False, + } + + def get_capabilities(self): + result = {} + result['rpc'] = [] + result['device_info'] = self.get_device_info() + result['device_operations'] = self.get_device_operations() + result.update(OPTIONS) + result['network_api'] = 'nxapi' + + return json.dumps(result) def handle_response(response): diff --git a/lib/ansible/utils/jsonrpc.py b/lib/ansible/utils/jsonrpc.py index d285cef688c..fe735450125 100644 --- a/lib/ansible/utils/jsonrpc.py +++ b/lib/ansible/utils/jsonrpc.py @@ -27,17 +27,9 @@ class JsonRpcServer(object): error = self.invalid_request() return json.dumps(error) - params = request.get('params') + args, kwargs = request.get('params') setattr(self, '_identifier', request.get('id')) - args = [] - kwargs = {} - - if all((params, isinstance(params, list))): - args = params - elif all((params, isinstance(params, dict))): - kwargs = params - rpc_method = None for obj in self._objects: rpc_method = getattr(obj, method, None)