diff --git a/lib/ansible/constants.py b/lib/ansible/constants.py index 12d34d87462..02adef40e37 100644 --- a/lib/ansible/constants.py +++ b/lib/ansible/constants.py @@ -104,7 +104,7 @@ DEFAULT_REMOTE_PASS = None DEFAULT_SUBSET = None DEFAULT_SU_PASS = None # FIXME: expand to other plugins, but never doc fragments -CONFIGURABLE_PLUGINS = ('cache', 'callback', 'connection', 'inventory', 'lookup', 'shell') +CONFIGURABLE_PLUGINS = ('cache', 'callback', 'connection', 'inventory', 'lookup', 'shell', 'cliconf') # NOTE: always update the docs/docsite/Makefile to match DOCUMENTABLE_PLUGINS = CONFIGURABLE_PLUGINS + ('module', 'strategy', 'vars') IGNORE_FILES = ("COPYING", "CONTRIBUTING", "LICENSE", "README", "VERSION", "GUIDELINES") # ignore during module search diff --git a/lib/ansible/module_utils/network/eos/eos.py b/lib/ansible/module_utils/network/eos/eos.py index 7c8843c0040..28b37f205f7 100644 --- a/lib/ansible/module_utils/network/eos/eos.py +++ b/lib/ansible/module_utils/network/eos/eos.py @@ -33,6 +33,7 @@ import time from ansible.module_utils._text import to_text, to_native from ansible.module_utils.basic import env_fallback, return_values from ansible.module_utils.connection import Connection, ConnectionError +from ansible.module_utils.network.common.config import NetworkConfig, dumps from ansible.module_utils.network.common.utils import to_list, ComplexList from ansible.module_utils.six import iteritems from ansible.module_utils.urls import fetch_url @@ -121,27 +122,6 @@ class Cli: return self._connection - def close_session(self, session): - conn = self._get_connection() - # to close session gracefully execute abort in top level session prompt. - conn.get('end') - conn.get('configure session %s' % session) - conn.get('abort') - - @property - def supports_sessions(self): - if self._session_support is not None: - return self._session_support - conn = self._get_connection() - - self._session_support = True - try: - out = conn.get('show configuration sessions') - except: - self._session_support = False - - return self._session_support - def get_config(self, flags=None): """Retrieves the current config from the device or cache """ @@ -155,7 +135,7 @@ class Cli: return self._device_configs[cmd] except KeyError: conn = self._get_connection() - out = conn.get_config(flags=flags) + out = conn.get_config(filter=flags) cfg = to_text(out, errors='surrogate_then_replace').strip() self._device_configs[cmd] = cfg return cfg @@ -164,48 +144,27 @@ class Cli: """Run list of commands on remote device and return results """ connection = self._get_connection() - return connection.run_commands(commands, check_rc) - - def configure(self, commands): - """Sends configuration commands to the remote device - """ - conn = get_connection(self) - - out = conn.get('configure') - - try: - self.send_config(commands) - except ConnectionError as exc: - conn.get('end') - message = getattr(exc, 'err', exc) - self._module.fail_json(msg="Error on executing commands %s" % commands, data=to_text(message, errors='surrogate_then_replace')) - - conn.get('end') - return {} + return connection.run_commands(commands=commands, check_rc=check_rc) def load_config(self, commands, commit=False, replace=False): """Loads the config commands onto the remote device """ - use_session = os.getenv('ANSIBLE_EOS_USE_SESSIONS', True) - try: - use_session = int(use_session) - except ValueError: - pass - - if not all((bool(use_session), self.supports_sessions)): - if commit: - return self.configure(commands) - else: - self._module.warn("EOS can not check config without config session") - result = {'changed': True} - return result - conn = self._get_connection() try: - return conn.load_config(commands, commit, replace) + response = conn.edit_config(commands, commit, replace) except ConnectionError as exc: message = getattr(exc, 'err', exc) - self._module.fail_json(msg="%s" % message, data=to_text(message, errors='surrogate_then_replace')) + if "check mode is not supported without configuration session" in message: + self._module.warn("EOS can not check config without config session") + response = {'changed': True} + else: + self._module.fail_json(msg="%s" % message, data=to_text(message, errors='surrogate_then_replace')) + + return response + + def get_diff(self, candidate=None, running=None, match='line', diff_ignore_lines=None, path=None, replace='line'): + conn = self._get_connection() + return conn.get_diff(candidate=candidate, running=running, match=match, diff_ignore_lines=diff_ignore_lines, path=path, replace=replace) class Eapi: @@ -397,6 +356,26 @@ class Eapi: return result + # get_diff added here to support connection=local and transport=eapi scenario + def get_diff(self, candidate, running=None, match='line', diff_ignore_lines=None, path=None, replace='line'): + diff = {} + + # prepare candidate configuration + candidate_obj = NetworkConfig(indent=3) + candidate_obj.load(candidate) + + if running and match != 'none' and 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=match, replace=replace) + + else: + configdiffobjs = candidate_obj.items + + configdiff = dumps(configdiffobjs, 'commands') if configdiffobjs else '' + diff['config_diff'] = configdiff if configdiffobjs else {} + return diff + def is_json(cmd): return to_native(cmd, errors='surrogate_then_replace').endswith('| json') @@ -431,11 +410,16 @@ def get_config(module, flags=None): return conn.get_config(flags) -def run_commands(module, commands): +def run_commands(module, commands, check_rc=True): conn = get_connection(module) - return conn.run_commands(to_command(module, commands)) + return conn.run_commands(to_command(module, commands), check_rc=check_rc) def load_config(module, config, commit=False, replace=False): conn = get_connection(module) return conn.load_config(config, commit, replace) + + +def get_diff(self, candidate=None, running=None, match='line', diff_ignore_lines=None, path=None, replace='line'): + conn = self.get_connection() + return conn.get_diff(candidate=candidate, running=running, match=match, diff_ignore_lines=diff_ignore_lines, path=path, replace=replace) diff --git a/lib/ansible/module_utils/network/ios/ios.py b/lib/ansible/module_utils/network/ios/ios.py index 494c4e84c30..f6da25ac81f 100644 --- a/lib/ansible/module_utils/network/ios/ios.py +++ b/lib/ansible/module_utils/network/ios/ios.py @@ -131,25 +131,8 @@ def to_commands(module, commands): def run_commands(module, commands, check_rc=True): - responses = list() connection = get_connection(module) - - try: - outputs = connection.run_commands(commands) - except ConnectionError as exc: - if check_rc: - module.fail_json(msg=to_text(exc)) - else: - outputs = exc - - for item in to_list(outputs): - try: - item = to_text(item, errors='surrogate_or_strict') - except UnicodeError: - module.fail_json(msg=u'Failed to decode output from %s: %s' % (item, to_text(item))) - - responses.append(item) - return responses + return connection.run_commands(commands=commands, check_rc=check_rc) def load_config(module, commands): diff --git a/lib/ansible/module_utils/network/vyos/vyos.py b/lib/ansible/module_utils/network/vyos/vyos.py index 64b6c5efa5c..50ce73fa64f 100644 --- a/lib/ansible/module_utils/network/vyos/vyos.py +++ b/lib/ansible/module_utils/network/vyos/vyos.py @@ -26,9 +26,9 @@ # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # import json + from ansible.module_utils._text import to_text from ansible.module_utils.basic import env_fallback, return_values -from ansible.module_utils.network.common.utils import to_list from ansible.module_utils.connection import Connection, ConnectionError _DEVICE_CONFIGS = {} @@ -100,26 +100,8 @@ def get_config(module): def run_commands(module, commands, check_rc=True): - responses = list() connection = get_connection(module) - - try: - outputs = connection.run_commands(commands) - except ConnectionError as exc: - if check_rc: - module.fail_json(msg=to_text(exc)) - else: - outputs = exc - - for item in to_list(outputs): - try: - item = to_text(item, errors='surrogate_or_strict') - except UnicodeError: - module.fail_json(msg=u'Failed to decode output from %s: %s' % (item, to_text(item))) - - responses.append(item) - - return responses + return connection.run_commands(commands=commands, check_rc=check_rc) def load_config(module, commands, commit=False, comment=None): @@ -127,7 +109,6 @@ def load_config(module, commands, commit=False, comment=None): try: resp = connection.edit_config(candidate=commands, commit=commit, comment=comment) - resp = json.loads(resp) except ConnectionError as exc: module.fail_json(msg=to_text(exc)) diff --git a/lib/ansible/modules/network/eos/eos_config.py b/lib/ansible/modules/network/eos/eos_config.py index 0268bf2fdec..db28e9131f1 100644 --- a/lib/ansible/modules/network/eos/eos_config.py +++ b/lib/ansible/modules/network/eos/eos_config.py @@ -266,33 +266,32 @@ backup_path: """ from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.network.common.config import NetworkConfig, dumps -from ansible.module_utils.network.eos.eos import get_config, load_config +from ansible.module_utils.network.eos.eos import get_config, load_config, get_connection from ansible.module_utils.network.eos.eos import run_commands from ansible.module_utils.network.eos.eos import eos_argument_spec from ansible.module_utils.network.eos.eos import check_args def get_candidate(module): - candidate = NetworkConfig(indent=3) + candidate = '' if module.params['src']: - candidate.load(module.params['src']) + candidate = module.params['src'] elif module.params['lines']: + candidate_obj = NetworkConfig(indent=3) parents = module.params['parents'] or list() - candidate.add(module.params['lines'], parents=parents) + candidate_obj.add(module.params['lines'], parents=parents) + candidate = dumps(candidate_obj, 'raw') return candidate -def get_running_config(module, config=None): +def get_running_config(module, config=None, flags=None): contents = module.params['running_config'] if not contents: if config: contents = config else: - flags = [] - if module.params['defaults']: - flags.append('all') contents = get_config(module, flags=flags) - return NetworkConfig(indent=3, contents=contents) + return contents def save_config(module, result): @@ -363,30 +362,31 @@ def main(): if warnings: result['warnings'] = warnings + diff_ignore_lines = module.params['diff_ignore_lines'] config = None + contents = None + flags = ['all'] if module.params['defaults'] else [] + connection = get_connection(module) if module.params['backup'] or (module._diff and module.params['diff_against'] == 'running'): - contents = get_config(module) - config = NetworkConfig(indent=3, contents=contents) + contents = get_config(module, flags=flags) + config = NetworkConfig(indent=1, contents=contents) if module.params['backup']: result['__backup__'] = contents if any((module.params['src'], module.params['lines'])): match = module.params['match'] replace = module.params['replace'] + path = module.params['parents'] candidate = get_candidate(module) + running = get_running_config(module, contents, flags=flags) - if match != 'none' and replace != 'config': - config_text = get_running_config(module) - config = NetworkConfig(indent=3, contents=config_text) - path = module.params['parents'] - configobjs = candidate.difference(config, match=match, replace=replace, path=path) - else: - configobjs = candidate.items + response = connection.get_diff(candidate=candidate, running=running, match=match, diff_ignore_lines=diff_ignore_lines, path=path, replace=replace) + config_diff = response['config_diff'] - if configobjs: - commands = dumps(configobjs, 'commands').split('\n') + if config_diff: + commands = config_diff.split('\n') if module.params['before']: commands[:0] = module.params['before'] @@ -413,16 +413,14 @@ def main(): running_config = module.params['running_config'] startup_config = None - diff_ignore_lines = module.params['diff_ignore_lines'] - if module.params['save_when'] == 'always' or module.params['save']: save_config(module, result) elif module.params['save_when'] == 'modified': output = run_commands(module, [{'command': 'show running-config', 'output': 'text'}, {'command': 'show startup-config', 'output': 'text'}]) - running_config = NetworkConfig(indent=1, contents=output[0], ignore_lines=diff_ignore_lines) - startup_config = NetworkConfig(indent=1, contents=output[1], ignore_lines=diff_ignore_lines) + running_config = NetworkConfig(indent=3, contents=output[0], ignore_lines=diff_ignore_lines) + startup_config = NetworkConfig(indent=3, contents=output[1], ignore_lines=diff_ignore_lines) if running_config.sha1 != startup_config.sha1: save_config(module, result) @@ -438,7 +436,7 @@ def main(): contents = running_config # recreate the object in order to process diff_ignore_lines - running_config = NetworkConfig(indent=1, contents=contents, ignore_lines=diff_ignore_lines) + running_config = NetworkConfig(indent=3, contents=contents, ignore_lines=diff_ignore_lines) if module.params['diff_against'] == 'running': if module.check_mode: @@ -458,7 +456,7 @@ def main(): contents = module.params['intended_config'] if contents is not None: - base_config = NetworkConfig(indent=1, contents=contents, ignore_lines=diff_ignore_lines) + base_config = NetworkConfig(indent=3, contents=contents, ignore_lines=diff_ignore_lines) if running_config.sha1 != base_config.sha1: if module.params['diff_against'] == 'intended': diff --git a/lib/ansible/modules/network/ios/ios_config.py b/lib/ansible/modules/network/ios/ios_config.py index 0ddcd42a2d1..e858d936441 100644 --- a/lib/ansible/modules/network/ios/ios_config.py +++ b/lib/ansible/modules/network/ios/ios_config.py @@ -400,6 +400,7 @@ def main(): check_args(module, warnings) result['warnings'] = warnings + diff_ignore_lines = module.params['diff_ignore_lines'] config = None contents = None flags = get_defaults_flag(module) if module.params['defaults'] else [] @@ -419,10 +420,9 @@ def main(): candidate = get_candidate_config(module) running = get_running_config(module, contents, flags=flags) - response = connection.get_diff(candidate=candidate, running=running, match=match, diff_ignore_lines=None, path=path, replace=replace) - diff = json.loads(response) - config_diff = diff['config_diff'] - banner_diff = diff['banner_diff'] + response = connection.get_diff(candidate=candidate, running=running, match=match, diff_ignore_lines=diff_ignore_lines, path=path, replace=replace) + config_diff = response['config_diff'] + banner_diff = response['banner_diff'] if config_diff or banner_diff: commands = config_diff.split('\n') @@ -450,8 +450,6 @@ def main(): running_config = module.params['running_config'] startup_config = None - diff_ignore_lines = module.params['diff_ignore_lines'] - if module.params['save_when'] == 'always' or module.params['save']: save_config(module, result) elif module.params['save_when'] == 'modified': diff --git a/lib/ansible/modules/network/vyos/vyos_config.py b/lib/ansible/modules/network/vyos/vyos_config.py index a639449320c..6c2b5f07d49 100644 --- a/lib/ansible/modules/network/vyos/vyos_config.py +++ b/lib/ansible/modules/network/vyos/vyos_config.py @@ -130,7 +130,6 @@ backup_path: sample: /playbooks/ansible/backup/vyos_config.2016-07-16@22:28:34 """ import re -import json from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.network.vyos.vyos import load_config, get_config, run_commands @@ -205,8 +204,7 @@ def run(module, result): # create loadable config that includes only the configuration updates connection = get_connection(module) response = connection.get_diff(candidate=candidate, running=config, match=module.params['match']) - diff_obj = json.loads(response) - commands = diff_obj.get('config_diff') + commands = response.get('config_diff') sanitize_config(commands, result) result['commands'] = commands diff --git a/lib/ansible/plugins/cliconf/__init__.py b/lib/ansible/plugins/cliconf/__init__.py index eff529303e8..8ed620e31e2 100644 --- a/lib/ansible/plugins/cliconf/__init__.py +++ b/lib/ansible/plugins/cliconf/__init__.py @@ -19,12 +19,13 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type -from abc import ABCMeta, abstractmethod +from abc import abstractmethod from functools import wraps +from ansible.plugins import AnsiblePlugin from ansible.errors import AnsibleError, AnsibleConnectionFailure from ansible.module_utils._text import to_bytes, to_text -from ansible.module_utils.six import with_metaclass + try: from scp import SCPClient @@ -49,7 +50,7 @@ def enable_mode(func): return wrapped -class CliconfBase(with_metaclass(ABCMeta, object)): +class CliconfBase(AnsiblePlugin): """ A base class for implementing cli connections @@ -84,6 +85,7 @@ class CliconfBase(with_metaclass(ABCMeta, object)): __rpc__ = ['get_config', 'edit_config', 'get_capabilities', 'get', 'enable_response_logging', 'disable_response_logging'] def __init__(self, connection): + super(CliconfBase, self).__init__() self._connection = connection self.history = list() self.response_logging = False @@ -375,7 +377,7 @@ class CliconfBase(with_metaclass(ABCMeta, object)): """ pass - def run_commands(self, commands): + def run_commands(self, commands=None, check_rc=True): """ Execute a list of commands on remote host and return the list of response :param commands: The list of command that needs to be executed on remote host. @@ -385,10 +387,12 @@ class CliconfBase(with_metaclass(ABCMeta, object)): 'command': 'prompt': , 'answer': , - 'output': , + 'output': , 'sendonly': } - + :param check_rc: Boolean flag to check if returned response should be checked for error or not. + If check_rc is False the error output is appended in return response list, else if the + value is True an exception is raised. :return: List of returned response """ pass diff --git a/lib/ansible/plugins/cliconf/eos.py b/lib/ansible/plugins/cliconf/eos.py index 5a8e9736206..860f788051c 100644 --- a/lib/ansible/plugins/cliconf/eos.py +++ b/lib/ansible/plugins/cliconf/eos.py @@ -19,58 +19,82 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type +DOCUMENTATION = """ +--- +author: Ansible Networking Team +cliconf: eos +short_description: Use eos cliconf to run command on eos platform +description: + - This eos plugin provides low level abstraction api's for + sending and receiving CLI commands from eos network devices. +version_added: "2.7" +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.7' +""" + +import collections import json import time -from itertools import chain - from ansible.errors import AnsibleConnectionFailure -from ansible.module_utils._text import to_bytes +from ansible.module_utils._text import to_text 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): - def send_command(self, command, prompt=None, answer=None, sendonly=False, newline=True, prompt_retry_check=False): + def __init__(self, *args, **kwargs): + super(Cliconf, self).__init__(*args, **kwargs) + self._session_support = None + if isinstance(self._connection, NetworkCli): + self.network_api = 'network_cli' + elif isinstance(self._connection, HttpApi): + self.network_api = 'eapi' + else: + raise ValueError("Invalid connection type") + + def _get_command_with_output(self, command, output): + options_values = self.get_option_values() + if output not in options_values['output']: + raise ValueError("'output' value %s is invalid. Valid values are %s" % (output, ','.join(options_values['output']))) + + if output == 'json' and not command.endswith('| json'): + cmd = '%s | json' % command + else: + cmd = command + return cmd + + 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 """ - kwargs = {'command': to_bytes(command), 'sendonly': sendonly, - 'newline': newline, 'prompt_retry_check': prompt_retry_check} - if prompt is not None: - kwargs['prompt'] = to_bytes(prompt) - if answer is not None: - kwargs['answer'] = to_bytes(answer) - - if isinstance(self._connection, NetworkCli): - resp = self._connection.send(**kwargs) + if self.network_api == 'network_cli': + resp = super(Cliconf, self).send_command(command, **kwargs) else: resp = self._connection.send_request(command, **kwargs) return resp - def get_device_info(self): - device_info = {} - - device_info['network_os'] = 'eos' - reply = self.get('show version | json') - data = json.loads(reply) - - device_info['network_os_version'] = data['version'] - device_info['network_os_model'] = data['modelName'] - - reply = self.get('show hostname | json') - data = json.loads(reply) - - device_info['network_os_hostname'] = data['hostname'] - - return device_info - @enable_mode - def get_config(self, source='running', format='text', flags=None): + def get_config(self, source='running', format='text', filter=None): + options_values = self.get_option_values() + if format not in options_values['format']: + raise ValueError("'format' value %s is invalid. Valid values are %s" % (format, ','.join(options_values['format']))) + lookup = {'running': 'running-config', 'startup': 'startup-config'} if source not in lookup: return self.invalid_params("fetching configuration from %s is not supported" % source) @@ -79,60 +103,112 @@ class Cliconf(CliconfBase): if format and format is not 'text': cmd += '| %s ' % format - cmd += ' '.join(to_list(flags)) + cmd += ' '.join(to_list(filter)) cmd = cmd.strip() return self.send_command(cmd) @enable_mode - def edit_config(self, command): - for cmd in chain(['configure'], to_list(command), ['end']): - self.send_command(cmd) + def edit_config(self, candidate=None, commit=True, replace=False, comment=None): - def get(self, command, prompt=None, answer=None, sendonly=False): - return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly) + if not candidate: + raise ValueError("must provide a candidate config to load") - def get_capabilities(self): - result = {} - result['rpc'] = self.get_base_rpc() - result['device_info'] = self.get_device_info() - if isinstance(self._connection, NetworkCli): - result['network_api'] = 'cliconf' - else: - result['network_api'] = 'eapi' - return json.dumps(result) + if commit not in (True, False): + raise ValueError("'commit' must be a bool, got %s" % commit) - # Imported from module_utils - def close_session(self, session): - # to close session gracefully execute abort in top level session prompt. - self.get('end') - self.get('configure session %s' % session) - self.get('abort') + operations = self.get_device_operations() + if replace not in (True, False): + raise ValueError("'replace' must be a bool, got %s" % replace) - def run_commands(self, commands, check_rc=True): - """Run list of commands on remote device and return results - """ - responses = list() - multiline = False + if replace and not operations['supports_replace']: + raise ValueError("configuration replace is supported only with configuration session") - for cmd in to_list(commands): - if isinstance(cmd, dict): - command = cmd['command'] - prompt = cmd['prompt'] - answer = cmd['answer'] - else: - command = cmd - prompt = None - answer = None + if comment and not operations['supports_commit_comment']: + raise ValueError("commit comment is not supported") + + if (commit is False) and (not self.supports_sessions): + raise ValueError('check mode is not supported without configuration session') + + response = {} + session = None + if self.supports_sessions: + session = 'ansible_%s' % int(time.time()) + response.update({'session': session}) + self.send_command('configure session %s' % session) + if replace: + self.send_command('rollback clean-config') + else: + self.send_command('configure') + + results = [] + multiline = False + for line in to_list(candidate): + if not isinstance(line, collections.Mapping): + line = {'command': line} - if command == 'end': + cmd = line['command'] + if cmd == 'end': continue - elif command.startswith('banner') or multiline: + elif cmd.startswith('banner') or multiline: multiline = True - elif command == 'EOF' and multiline: + elif cmd == 'EOF' and multiline: multiline = False + if multiline: + line['sendonly'] = True + + if cmd != 'end' and cmd[0] != '!': + try: + results.append(self.send_command(**line)) + except AnsibleConnectionFailure as e: + self.discard_changes(session) + raise AnsibleConnectionFailure(e.message) + + response['response'] = results + if self.supports_sessions: + out = self.send_command('show session-config diffs') + if out: + response['diff'] = out.strip() + + if commit: + self.commit() + else: + self.discard_changes(session) + else: + self.send_command('end') + return response + + def get(self, command, prompt=None, answer=None, sendonly=False, output=None): + if output: + command = self._get_command_with_output(command, output) + return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly) + + def commit(self): + self.send_command('commit') + + def discard_changes(self, session=None): + commands = ['end'] + if self.supports_sessions: + # to close session gracefully execute abort in top level session prompt. + commands.extend(['configure session %s' % session, 'abort']) + + for cmd in commands: + self.send_command(cmd) + + def run_commands(self, commands=None, check_rc=True): + if commands is None: + raise ValueError("'commands' value is required") + responses = list() + for cmd in to_list(commands): + if not isinstance(cmd, collections.Mapping): + cmd = {'command': cmd} + + output = cmd.pop('output', None) + if output: + cmd['command'] = self._get_command_with_output(cmd['command'], output) + try: - out = self.get(command, prompt, answer, multiline) + out = self.send_command(**cmd) except AnsibleConnectionFailure as e: if check_rc: raise @@ -142,35 +218,106 @@ class Cliconf(CliconfBase): try: out = json.loads(out) except ValueError: - out = str(out).strip() + out = to_text(out, errors='surrogate_or_strict').strip() responses.append(out) - return responses - def load_config(self, commands, commit=False, replace=False): - """Loads the config commands onto the remote device - """ - session = 'ansible_%s' % int(time.time()) - result = {'session': session} + def get_diff(self, candidate=None, running=None, match='line', diff_ignore_lines=None, path=None, replace='line'): + diff = {} + device_operations = self.get_device_operations() + option_values = self.get_option_values() - self.get('configure session %s' % session) - if replace: - self.get('rollback clean-config') + if candidate is None and device_operations['supports_generate_diff']: + raise ValueError("candidate configuration is required to generate diff") - try: - self.run_commands(commands) - except AnsibleConnectionFailure: - self.close_session(session) - raise + if match not in option_values['diff_match']: + raise ValueError("'match' value %s in invalid, valid values are %s" % (match, ', '.join(option_values['diff_match']))) + + if replace not in option_values['diff_replace']: + raise ValueError("'replace' value %s in invalid, valid values are %s" % (replace, ', '.join(option_values['diff_replace']))) + + # prepare candidate configuration + candidate_obj = NetworkConfig(indent=3) + candidate_obj.load(candidate) + + if running and match != 'none' and 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=match, replace=replace) - out = self.get('show session-config diffs') - if out: - result['diff'] = out.strip() + else: + configdiffobjs = candidate_obj.items + + configdiff = dumps(configdiffobjs, 'commands') if configdiffobjs else '' + diff['config_diff'] = configdiff if configdiffobjs else {} + return diff + + @property + def supports_sessions(self): + use_session = self.get_option('eos_use_sessions') + try: + use_session = int(use_session) + except ValueError: + pass - if commit: - self.get('commit') + if not bool(use_session): + self._session_support = False else: - self.close_session(session) + if self._session_support: + return self._session_support + + response = self.get('show configuration sessions') + self._session_support = 'error' not in response + + return self._session_support - return result + def get_device_info(self): + device_info = {} + + device_info['network_os'] = 'eos' + reply = self.get('show version | json') + data = json.loads(reply) + + device_info['network_os_version'] = data['version'] + device_info['network_os_model'] = data['modelName'] + + reply = self.get('show hostname | json') + data = json.loads(reply) + + device_info['network_os_hostname'] = data['hostname'] + + return device_info + + def get_device_operations(self): + return { + 'supports_diff_replace': True, + 'supports_commit': True if self.supports_sessions else False, + 'supports_rollback': True if self.supports_sessions else False, + 'supports_defaults': False, + 'supports_onbox_diff': True if self.supports_sessions else False, + 'supports_commit_comment': False, + 'supports_multiline_delimiter': False, + 'support_diff_match': True, + 'support_diff_ignore_lines': True, + 'supports_generate_diff': True, + 'supports_replace': True if self.supports_sessions else False + } + + def get_option_values(self): + return { + 'format': ['text', 'json'], + 'diff_match': ['line', 'strict', 'exact', 'none'], + 'diff_replace': ['line', 'block', 'config'], + 'output': ['text', 'json'] + } + + def get_capabilities(self): + result = {} + result['rpc'] = self.get_base_rpc() + result['device_info'] = self.get_device_info() + result['network_api'] = self.network_api + result['device_info'] = self.get_device_info() + result['device_operations'] = self.get_device_operations() + result.update(self.get_option_values()) + return json.dumps(result) diff --git a/lib/ansible/plugins/cliconf/ios.py b/lib/ansible/plugins/cliconf/ios.py index 23f5174ba2e..2478617b7fd 100644 --- a/lib/ansible/plugins/cliconf/ios.py +++ b/lib/ansible/plugins/cliconf/ios.py @@ -26,6 +26,7 @@ import json from itertools import chain +from ansible.errors import AnsibleConnectionFailure from ansible.module_utils._text import to_text from ansible.module_utils.six import iteritems from ansible.module_utils.network.common.config import NetworkConfig, dumps @@ -41,7 +42,7 @@ class Cliconf(CliconfBase): return self.invalid_params("fetching configuration from %s is not supported" % source) if format: - raise ValueError("'format' value %s is not supported on ios" % format) + raise ValueError("'format' value %s is not supported for get_config" % format) if not filter: filter = [] @@ -94,14 +95,14 @@ class Cliconf(CliconfBase): device_operations = self.get_device_operations() option_values = self.get_option_values() - if candidate is None and not device_operations['supports_onbox_diff']: + if candidate is None and device_operations['supports_generate_diff']: raise ValueError("candidate configuration is required to generate diff") if match not in option_values['diff_match']: - raise ValueError("'match' value %s in invalid, valid values are %s" % (match, option_values['diff_match'])) + raise ValueError("'match' value %s in invalid, valid values are %s" % (match, ', '.join(option_values['diff_match']))) if replace not in option_values['diff_replace']: - raise ValueError("'replace' value %s in invalid, valid values are %s" % (replace, option_values['diff_replace'])) + raise ValueError("'replace' value %s in invalid, valid values are %s" % (replace, ', '.join(option_values['diff_replace']))) # prepare candidate configuration candidate_obj = NetworkConfig(indent=1) @@ -124,11 +125,13 @@ class Cliconf(CliconfBase): banners = self._diff_banners(want_banners, have_banners) diff['banner_diff'] = banners if banners else {} - return json.dumps(diff) + return diff @enable_mode def edit_config(self, candidate=None, commit=True, replace=False, comment=None): resp = {} + operations = self.get_device_operations() + if not candidate: raise ValueError("must provide a candidate config to load") @@ -138,9 +141,12 @@ class Cliconf(CliconfBase): if replace not in (True, False): raise ValueError("'replace' must be a bool, got %s" % replace) + if comment and not operations['supports_commit_comment']: + raise ValueError("commit comment is not supported") + operations = self.get_device_operations() if replace and not operations['supports_replace']: - raise ValueError("configuration replace is not supported on ios") + raise ValueError("configuration replace is not supported") results = [] if commit: @@ -153,6 +159,8 @@ class Cliconf(CliconfBase): results.append(self.send_command(**line)) results.append(self.send_command('end')) + else: + raise ValueError('check mode is not supported') resp['response'] = results[1:-1] return resp @@ -161,7 +169,7 @@ class Cliconf(CliconfBase): if not command: raise ValueError('must provide value of command to execute') if output: - raise ValueError("'output' value %s is not supported on ios" % output) + raise ValueError("'output' value %s is not supported for get" % output) return self.send_command(command=command, prompt=prompt, answer=answer, sendonly=sendonly) @@ -247,7 +255,10 @@ class Cliconf(CliconfBase): return resp - def run_commands(self, commands): + def run_commands(self, commands=None, check_rc=True): + if commands is None: + raise ValueError("'commands' value is required") + responses = list() for cmd in to_list(commands): if not isinstance(cmd, collections.Mapping): @@ -255,9 +266,17 @@ class Cliconf(CliconfBase): output = cmd.pop('output', None) if output: - raise ValueError("'output' value %s is not supported on ios" % output) + raise ValueError("'output' value %s is not supported for run_commands" % output) + + try: + out = self.send_command(**cmd) + except AnsibleConnectionFailure as e: + if check_rc: + raise + out = getattr(e, 'err', e) + + responses.append(out) - responses.append(self.send_command(**cmd)) return responses def _extract_banners(self, config): diff --git a/lib/ansible/plugins/cliconf/vyos.py b/lib/ansible/plugins/cliconf/vyos.py index 4432bf699f5..acc611b273b 100644 --- a/lib/ansible/plugins/cliconf/vyos.py +++ b/lib/ansible/plugins/cliconf/vyos.py @@ -58,7 +58,7 @@ class Cliconf(CliconfBase): if format: option_values = self.get_option_values() if format not in option_values['format']: - raise ValueError("'format' value %s is invalid. Valid values of format are %s" % (format, ','.join(option_values['format']))) + raise ValueError("'format' value %s is invalid. Valid values of format are %s" % (format, ', '.join(option_values['format']))) if format == 'text': out = self.send_command('show configuration') @@ -79,7 +79,7 @@ class Cliconf(CliconfBase): operations = self.get_device_operations() if replace and not operations['supports_replace']: - raise ValueError("configuration replace is not supported on vyos") + raise ValueError("configuration replace is not supported") results = [] @@ -110,14 +110,14 @@ class Cliconf(CliconfBase): resp['diff'] = diff_config resp['response'] = results[1:-1] - return json.dumps(resp) + return resp def get(self, command=None, prompt=None, answer=None, sendonly=False, output=None): if not command: raise ValueError('must provide value of command to execute') if output: - raise ValueError("'output' value %s is not supported on vyos" % output) + raise ValueError("'output' value %s is not supported for get" % output) return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly) @@ -136,20 +136,20 @@ class Cliconf(CliconfBase): device_operations = self.get_device_operations() option_values = self.get_option_values() - if candidate is None and not device_operations['supports_onbox_diff']: + if candidate is None and device_operations['supports_generate_diff']: raise ValueError("candidate configuration is required to generate diff") if match not in option_values['diff_match']: - raise ValueError("'match' value %s in invalid, valid values are %s" % (match, option_values['diff_match'])) + raise ValueError("'match' value %s in invalid, valid values are %s" % (match, ', '.join(option_values['diff_match']))) if replace: - raise ValueError("'replace' in diff is not supported on vyos") + raise ValueError("'replace' in diff is not supported") if diff_ignore_lines: - raise ValueError("'diff_ignore_lines' in diff is not supported on vyos") + raise ValueError("'diff_ignore_lines' in diff is not supported") if path: - raise ValueError("'path' in diff is not supported on vyos") + raise ValueError("'path' in diff is not supported") set_format = candidate.startswith('set') or candidate.startswith('delete') candidate_obj = NetworkConfig(indent=4, contents=candidate) @@ -171,7 +171,7 @@ class Cliconf(CliconfBase): if match == 'none': diff['config_diff'] = list(candidate_commands) - return json.dumps(diff) + return diff running_commands = [str(c).replace("'", '') for c in running.splitlines()] @@ -198,9 +198,12 @@ class Cliconf(CliconfBase): visited.add(line) diff['config_diff'] = list(updates) - return json.dumps(diff) + return diff + + def run_commands(self, commands=None, check_rc=True): + if commands is None: + raise ValueError("'commands' value is required") - def run_commands(self, commands): responses = list() for cmd in to_list(commands): if not isinstance(cmd, collections.Mapping): @@ -208,9 +211,17 @@ class Cliconf(CliconfBase): output = cmd.pop('output', None) if output: - raise ValueError("'output' value %s is not supported on vyos" % output) + raise ValueError("'output' value %s is not supported for run_commands" % output) + + try: + out = self.send_command(**cmd) + except AnsibleConnectionFailure as e: + if check_rc: + raise + out = getattr(e, 'err', e) + + responses.append(out) - responses.append(self.send_command(**cmd)) return responses def get_device_operations(self): @@ -219,7 +230,7 @@ class Cliconf(CliconfBase): 'supports_commit': True, 'supports_rollback': True, 'supports_defaults': False, - 'supports_onbox_diff': False, + 'supports_onbox_diff': True, 'supports_commit_comment': True, 'supports_multiline_delimiter': False, 'support_diff_match': True, diff --git a/lib/ansible/plugins/connection/network_cli.py b/lib/ansible/plugins/connection/network_cli.py index 5051ae132f9..d9689bb30fd 100644 --- a/lib/ansible/plugins/connection/network_cli.py +++ b/lib/ansible/plugins/connection/network_cli.py @@ -198,6 +198,7 @@ class Connection(NetworkConnectionBase): self._history = list() self._terminal = None + self.cliconf = None self.paramiko_conn = None if self._play_context.verbosity > 3: @@ -258,7 +259,6 @@ class Connection(NetworkConnectionBase): self.reset_history() self.disable_response_logging() - return messages def _connect(self): @@ -291,10 +291,11 @@ class Connection(NetworkConnectionBase): display.vvvv('loaded terminal plugin for network_os %s' % self._network_os, host=host) - cliconf = cliconf_loader.get(self._network_os, self) - if cliconf: + self.cliconf = cliconf_loader.get(self._network_os, self) + if self.cliconf: display.vvvv('loaded cliconf plugin for network_os %s' % self._network_os, host=host) - self._implementation_plugins.append(cliconf) + self._implementation_plugins.append(self.cliconf) + self.cliconf.set_options() else: display.vvvv('unable to load cliconf for network_os %s' % self._network_os) diff --git a/test/integration/targets/eos_config/tests/cli/check_mode.yaml b/test/integration/targets/eos_config/tests/cli/check_mode.yaml index 7d52f0ed50f..ddb3aa75efb 100644 --- a/test/integration/targets/eos_config/tests/cli/check_mode.yaml +++ b/test/integration/targets/eos_config/tests/cli/check_mode.yaml @@ -8,8 +8,8 @@ parents: interface Loopback911 become: yes check_mode: 1 - environment: - ANSIBLE_EOS_USE_SESSIONS: 1 + vars: + ansible_eos_use_sessions: 1 register: result ignore_errors: yes @@ -46,8 +46,8 @@ parents: interface Loopback911 become: yes check_mode: 1 - environment: - ANSIBLE_EOS_USE_SESSIONS: 0 + vars: + ansible_eos_use_sessions: 0 register: result ignore_errors: yes @@ -63,8 +63,8 @@ become: yes check_mode: yes register: result - environment: - ANSIBLE_EOS_USE_SESSIONS: 0 + vars: + ansible_eos_use_sessions: 0 - assert: that: diff --git a/test/units/modules/network/eos/test_eos_config.py b/test/units/modules/network/eos/test_eos_config.py index 7d497818db2..1691bcd81fb 100644 --- a/test/units/modules/network/eos/test_eos_config.py +++ b/test/units/modules/network/eos/test_eos_config.py @@ -19,8 +19,9 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type -from ansible.compat.tests.mock import patch +from ansible.compat.tests.mock import patch, MagicMock from ansible.modules.network.eos import eos_config +from ansible.plugins.cliconf.ios import Cliconf from units.modules.utils import set_module_args from .eos_module import TestEosModule, load_fixture @@ -34,62 +35,78 @@ class TestEosConfigModule(TestEosModule): self.mock_get_config = patch('ansible.modules.network.eos.eos_config.get_config') self.get_config = self.mock_get_config.start() + self.mock_get_connection = patch('ansible.modules.network.eos.eos_config.get_connection') + self.get_connection = self.mock_get_connection.start() + self.mock_load_config = patch('ansible.modules.network.eos.eos_config.load_config') self.load_config = self.mock_load_config.start() self.mock_run_commands = patch('ansible.modules.network.eos.eos_config.run_commands') self.run_commands = self.mock_run_commands.start() + self.conn = self.get_connection() + self.conn.edit_config = MagicMock() + + self.cliconf_obj = Cliconf(MagicMock()) + self.running_config = load_fixture('eos_config_config.cfg') + def tearDown(self): super(TestEosConfigModule, self).tearDown() self.mock_get_config.stop() self.mock_load_config.stop() + self.mock_get_connection.stop() def load_fixtures(self, commands=None, transport='cli'): self.get_config.return_value = load_fixture('eos_config_config.cfg') self.load_config.return_value = dict(diff=None, session='session') def test_eos_config_no_change(self): - args = dict(lines=['hostname localhost']) + lines = ['hostname localhost'] + config = '\n'.join(lines) + args = dict(lines=lines) set_module_args(args) + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(config, config)) result = self.execute_module() def test_eos_config_src(self): - args = dict(src=load_fixture('eos_config_candidate.cfg')) + src = load_fixture('eos_config_candidate.cfg') + args = dict(src=src) set_module_args(args) - + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(src, self.running_config)) result = self.execute_module(changed=True) config = ['hostname switch01', 'interface Ethernet1', 'description test interface', 'no shutdown', 'ip routing'] - self.assertEqual(sorted(config), sorted(result['commands']), result['commands']) def test_eos_config_lines(self): - args = dict(lines=['hostname switch01', 'ip domain-name eng.ansible.com']) + lines = ['hostname switch01', 'ip domain-name eng.ansible.com'] + args = dict(lines=lines) set_module_args(args) - + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff('\n'.join(lines), self.running_config)) result = self.execute_module(changed=True) config = ['hostname switch01'] self.assertEqual(sorted(config), sorted(result['commands']), result['commands']) def test_eos_config_before(self): - args = dict(lines=['hostname switch01', 'ip domain-name eng.ansible.com'], - before=['before command']) - + lines = ['hostname switch01', 'ip domain-name eng.ansible.com'] + before = ['before command'] + args = dict(lines=lines, + before=before) set_module_args(args) + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff('\n'.join(lines), self.running_config)) result = self.execute_module(changed=True) config = ['before command', 'hostname switch01'] - self.assertEqual(sorted(config), sorted(result['commands']), result['commands']) self.assertEqual('before command', result['commands'][0]) def test_eos_config_after(self): - args = dict(lines=['hostname switch01', 'ip domain-name eng.ansible.com'], + lines = ['hostname switch01', 'ip domain-name eng.ansible.com'] + args = dict(lines=lines, after=['after command']) set_module_args(args) - + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff('\n'.join(lines), self.running_config)) result = self.execute_module(changed=True) config = ['after command', 'hostname switch01'] @@ -97,8 +114,12 @@ class TestEosConfigModule(TestEosModule): self.assertEqual('after command', result['commands'][-1]) def test_eos_config_parents(self): - args = dict(lines=['ip address 1.2.3.4/5', 'no shutdown'], parents=['interface Ethernet10']) + lines = ['ip address 1.2.3.4/5', 'no shutdown'] + parents = ['interface Ethernet10'] + args = dict(lines=lines, parents=parents) + candidate = parents + lines set_module_args(args) + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff('\n'.join(candidate), self.running_config)) result = self.execute_module(changed=True) config = ['interface Ethernet10', 'ip address 1.2.3.4/5', 'no shutdown']