From ba4b12358c7a1401e058012dffda18c1dc1b8e00 Mon Sep 17 00:00:00 2001 From: Ganesh Nalawade Date: Wed, 6 Jun 2018 11:12:45 +0530 Subject: [PATCH] Refactor ios cliconf plugin and ios_config module (#39695) * Refactor ios cliconf plugin and ios_config module * Refactor ios cliconf plugin to support generic network_config module * Refactor ios_config module to work with cliconf api's * Enable command and response logging in cliconf pulgin * cliconf api documentation * Fix unit test and other minor changes * Doc update * Fix CI failure * Add default flag related changes * Minor changes * redact input command logging by default --- .../module_utils/network/common/config.py | 4 +- lib/ansible/module_utils/network/ios/ios.py | 2 +- lib/ansible/modules/network/ios/ios_config.py | 109 +++------ lib/ansible/plugins/cliconf/__init__.py | 219 +++++++++++++---- lib/ansible/plugins/cliconf/ios.py | 228 +++++++++++++++--- lib/ansible/plugins/connection/network_cli.py | 4 + .../modules/network/ios/test_ios_config.py | 84 +++++-- test/units/plugins/cliconf/test_slxos.py | 5 +- 8 files changed, 473 insertions(+), 182 deletions(-) diff --git a/lib/ansible/module_utils/network/common/config.py b/lib/ansible/module_utils/network/common/config.py index 23b4df6a85f..3e910955488 100644 --- a/lib/ansible/module_utils/network/common/config.py +++ b/lib/ansible/module_utils/network/common/config.py @@ -135,10 +135,12 @@ def dumps(objects, output='block', comments=False): items = _obj_to_block(objects) elif output == 'commands': items = _obj_to_text(objects) + elif output == 'raw': + items = _obj_to_raw(objects) else: raise TypeError('unknown value supplied for keyword output') - if output != 'commands': + if output == 'block': if comments: for index, item in enumerate(items): nextitem = index + 1 diff --git a/lib/ansible/module_utils/network/ios/ios.py b/lib/ansible/module_utils/network/ios/ios.py index 1cbac6bcecc..bb4d857b198 100644 --- a/lib/ansible/module_utils/network/ios/ios.py +++ b/lib/ansible/module_utils/network/ios/ios.py @@ -114,7 +114,7 @@ def get_config(module, flags=None): return _DEVICE_CONFIGS[flag_str] except KeyError: connection = get_connection(module) - out = connection.get_config(flags=flags) + out = connection.get_config(filter=flags) cfg = to_text(out, errors='surrogate_then_replace').strip() _DEVICE_CONFIGS[flag_str] = cfg return cfg diff --git a/lib/ansible/modules/network/ios/ios_config.py b/lib/ansible/modules/network/ios/ios_config.py index bbeda37fa69..b48e1362026 100644 --- a/lib/ansible/modules/network/ios/ios_config.py +++ b/lib/ansible/modules/network/ios/ios_config.py @@ -291,17 +291,14 @@ backup_path: type: string sample: /playbooks/ansible/backup/ios_config.2016-07-16@22:28:34 """ -import re -import time +import json -from ansible.module_utils.network.ios.ios import run_commands, get_config, load_config -from ansible.module_utils.network.ios.ios import get_defaults_flag +from ansible.module_utils.network.ios.ios import run_commands, get_config +from ansible.module_utils.network.ios.ios import get_defaults_flag, get_connection from ansible.module_utils.network.ios.ios import ios_argument_spec from ansible.module_utils.network.ios.ios import check_args as ios_check_args from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.network.common.parsing import Conditional from ansible.module_utils.network.common.config import NetworkConfig, dumps -from ansible.module_utils.six import iteritems def check_args(module, warnings): @@ -312,70 +309,29 @@ def check_args(module, warnings): 'single character') -def extract_banners(config): - banners = {} - banner_cmds = re.findall(r'^banner (\w+)', config, re.M) - for cmd in banner_cmds: - regex = r'banner %s \^C(.+?)(?=\^C)' % cmd - match = re.search(regex, config, re.S) - if match: - key = 'banner %s' % cmd - banners[key] = match.group(1).strip() - - for cmd in banner_cmds: - regex = r'banner %s \^C(.+?)(?=\^C)' % cmd - match = re.search(regex, config, re.S) - if match: - config = config.replace(str(match.group(1)), '') - - config = re.sub(r'banner \w+ \^C\^C', '!! banner removed', config) - return (config, banners) +def get_candidate_config(module): + candidate = '' + if module.params['src']: + candidate = module.params['src'] + elif module.params['lines']: + candidate_obj = NetworkConfig(indent=1) + parents = module.params['parents'] or list() + candidate_obj.add(module.params['lines'], parents=parents) + candidate = dumps(candidate_obj, 'raw') -def diff_banners(want, have): - candidate = {} - for key, value in iteritems(want): - if value != have.get(key): - candidate[key] = value return candidate -def load_banners(module, banners): - delimiter = module.params['multiline_delimiter'] - for key, value in iteritems(banners): - key += ' %s' % delimiter - for cmd in ['config terminal', key, value, delimiter, 'end']: - obj = {'command': cmd, 'sendonly': True} - run_commands(module, [cmd]) - time.sleep(0.1) - run_commands(module, ['\n']) - - def get_running_config(module, current_config=None, flags=None): - contents = module.params['running_config'] - - if not contents: + running = module.params['running_config'] + if not running: if not module.params['defaults'] and current_config: - contents, banners = extract_banners(current_config.config_text) + running = current_config else: - contents = get_config(module, flags=flags) - contents, banners = extract_banners(contents) - return NetworkConfig(indent=1, contents=contents), banners - + running = get_config(module, flags=flags) -def get_candidate(module): - candidate = NetworkConfig(indent=1) - banners = {} - - if module.params['src']: - src, banners = extract_banners(module.params['src']) - candidate.load(src) - - elif module.params['lines']: - parents = module.params['parents'] or list() - candidate.add(module.params['lines'], parents=parents) - - return candidate, banners + return running def save_config(module, result): @@ -445,7 +401,9 @@ def main(): result['warnings'] = warnings config = None + contents = None flags = get_defaults_flag(module) 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, flags=flags) @@ -458,20 +416,16 @@ def main(): replace = module.params['replace'] path = module.params['parents'] - candidate, want_banners = get_candidate(module) - - if match != 'none': - config, have_banners = get_running_config(module, config, flags=flags) - path = module.params['parents'] - configobjs = candidate.difference(config, path=path, match=match, replace=replace) - else: - configobjs = candidate.items - have_banners = {} + candidate = get_candidate_config(module) + running = get_running_config(module, contents, flags=flags) - banners = diff_banners(want_banners, have_banners) + 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'] - if configobjs or banners: - commands = dumps(configobjs, 'commands').split('\n') + if config_diff or banner_diff: + commands = config_diff.split('\n') if module.params['before']: commands[:0] = module.params['before'] @@ -481,15 +435,15 @@ def main(): result['commands'] = commands result['updates'] = commands - result['banners'] = banners + result['banners'] = banner_diff # send the configuration commands to the device and merge # them with the current running config if not module.check_mode: if commands: - load_config(module, commands) - if banners: - load_banners(module, banners) + connection.edit_config(commands) + if banner_diff: + connection.edit_banner(json.dumps(banner_diff), multiline_delimiter=module.params['multiline_delimiter']) result['changed'] = True @@ -556,6 +510,5 @@ def main(): module.exit_json(**result) - if __name__ == '__main__': main() diff --git a/lib/ansible/plugins/cliconf/__init__.py b/lib/ansible/plugins/cliconf/__init__.py index 2ca50b60a05..b83d6d9dd81 100644 --- a/lib/ansible/plugins/cliconf/__init__.py +++ b/lib/ansible/plugins/cliconf/__init__.py @@ -19,8 +19,6 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type -import signal - from abc import ABCMeta, abstractmethod from functools import wraps @@ -83,8 +81,12 @@ class CliconfBase(with_metaclass(ABCMeta, object)): conn.edit_config(['hostname test', 'netconf ssh']) """ + __rpc__ = ['get_config', 'edit_config', 'get_capabilities', 'get', 'enable_response_logging', 'disable_response_logging'] + def __init__(self, connection): self._connection = connection + self.history = list() + self.response_logging = False def _alarm_handler(self, signum, frame): """Alarm handler raised in case of command timeout """ @@ -92,94 +94,208 @@ class CliconfBase(with_metaclass(ABCMeta, object)): self.close() def send_command(self, command, prompt=None, answer=None, sendonly=False, newline=True, prompt_retry_check=False): - """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 + """Executes a command over the device connection + + This method will execute a command over the device connection and + return the results to the caller. This method will also perform + logging of any commands based on the `nolog` argument. + + :param command: The command to send over the connection to the device + :param prompt: A regex pattern to evalue the expected prompt from the command + :param answer: The answer to respond with if the prompt is matched. + :param sendonly: Bool value that will send the command but not wait for a result. + :param newline: Bool value that will append the newline character to the command + :param prompt_retry_check: Bool value for trying to detect more prompts + + :returns: The output from the device after executing the command """ - kwargs = {'command': to_bytes(command), 'sendonly': sendonly, - 'newline': newline, 'prompt_retry_check': prompt_retry_check} + 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) resp = self._connection.send(**kwargs) + + if not self.response_logging: + self.history.append(('*****', '*****')) + else: + self.history.append((kwargs['command'], resp)) + return resp def get_base_rpc(self): """Returns list of base rpc method supported by remote device""" - return ['get_config', 'edit_config', 'get_capabilities', 'get'] + return self.__rpc__ + + def get_history(self): + """ Returns the history file for all commands + + This will return a log of all the commands that have been sent to + the device and all of the output received. By default, all commands + and output will be redacted unless explicitly configured otherwise. + + :return: An ordered list of command, output pairs + """ + return self.history + + def reset_history(self): + """ Resets the history of run commands + :return: None + """ + self.history = list() + + def enable_response_logging(self): + """Enable logging command response""" + self.response_logging = True + + def disable_response_logging(self): + """Disable logging command response""" + self.response_logging = False @abstractmethod - def get_config(self, source='running', format='text'): + def get_config(self, source='running', filter=None, format='text'): """Retrieves the specified configuration from the device + This method will retrieve the configuration specified by source and return it to the caller as a string. Subsequent calls to this method will retrieve a new configuration from the device - :args: - arg[0] source: Datastore from which configuration should be retrieved eg: running/candidate/startup. (optional) - default is running. - arg[1] format: Output format in which configuration is retrieved - Note: Specified datastore should be supported by remote device. - :kwargs: - Keywords supported - :command: the command string to execute - :source: Datastore from which configuration should be retrieved - :format: Output format in which configuration is retrieved - :returns: Returns output received from remote device as byte string + + :param source: The configuration source to return from the device. + This argument accepts either `running` or `startup` as valid values. + + :param filter: For devices that support configuration filtering, this + keyword argument is used to filter the returned configuration. + The use of this keyword argument is device dependent adn will be + silently ignored on devices that do not support it. + + :param format: For devices that support fetching different configuration + format, this keyword argument is used to specify the format in which + configuration is to be retrieved. + + :return: The device configuration as specified by the source argument. """ pass @abstractmethod - def edit_config(self, commands=None): - """Loads the specified commands into the remote device - This method will load the commands into the remote device. This - method will make sure the device is in the proper context before - send the commands (eg config mode) - :args: - arg[0] command: List of configuration commands - :kwargs: - Keywords supported - :command: the command string to execute - :returns: Returns output received from remote device as byte string + def edit_config(self, candidate, check_mode=False, replace=None): + """Loads the candidate configuration into the network device + + This method will load the specified candidate config into the device + and merge with the current configuration unless replace is set to + True. If the device does not support config replace an errors + is returned. + + :param candidate: The configuration to load into the device and merge + with the current running configuration + + :param check_mode: Boolean value that indicates if the device candidate + configuration should be pushed in the running configuration or discarded. + + :param replace: Specifies the way in which provided config value should replace + the configuration running on the remote device. If the device + doesn't support config replace, an error is return. + + :return: Returns response of executing the configuration command received + from remote host """ pass @abstractmethod - def get(self, command=None, prompt=None, answer=None, sendonly=False, newline=True): + def get(self, command, prompt=None, answer=None, sendonly=False, newline=True): """Execute specified command on remote device This method will retrieve the specified data and return it to the caller as a string. - :args: - command: command in string format to be executed on remote device - prompt: the expected prompt generated by executing command. - This can be a string or a list of strings (optional) - answer: the string to respond to the prompt with (optional) - sendonly: bool to disable waiting for response, default is false (optional) - :returns: Returns output received from remote device as byte string + :param command: command in string format to be executed on remote device + :param prompt: the expected prompt generated by executing command, this can + be a string or a list of strings + :param answer: the string to respond to the prompt with + :param sendonly: bool to disable waiting for response, default is false + :param newline: bool to indicate if newline should be added at end of answer or not + :return: """ pass @abstractmethod def get_capabilities(self): - """Retrieves device information and supported - rpc methods by device platform and return result - as a string - :returns: Returns output received from remote device as byte string + """Returns the basic capabilities of the network device + This method will provide some basic facts about the device and + what capabilities it has to modify the configuration. The minimum + return from this method takes the following format. + eg: + { + + 'rpc': [list of supported rpcs], + 'network_api': , # the name of the transport + 'device_info': { + 'network_os': , + 'network_os_version': , + 'network_os_model': , + 'network_os_hostname': , + 'network_os_image': , + 'network_os_platform': , + }, + 'device_operations': { + 'supports_replace': , # identify if config should be merged or replaced is supported + 'supports_commit': , # identify if commit is supported by device or not + 'supports_rollback': , # identify if rollback is supported or not + 'supports_defaults': , # identify if fetching running config with default is supported + 'supports_commit_comment': , # identify if adding comment to commit is supported of not + 'supports_onbox_diff: , # identify if on box diff capability is supported or not + 'supports_generate_diff: , # identify if diff capability is supported within plugin + 'supports_multiline_delimiter: , # identify if multiline demiliter is supported within config + 'support_match: , # identify if match is supported + 'support_diff_ignore_lines: , # identify if ignore line in diff is supported + } + 'format': [list of supported configuration format], + 'match': ['line', 'strict', 'exact', 'none'], + 'replace': ['line', 'block', 'config'], + } + :return: capability as json string """ pass def commit(self, comment=None): - """Commit configuration changes""" + """Commit configuration changes + + This method will perform the commit operation on a previously loaded + candidate configuration that was loaded using `edit_config()`. If + there is a candidate configuration, it will be committed to the + active configuration. If there is not a candidate configuration, this + method should just silently return. + + :return: None + """ return self._connection.method_not_found("commit is not supported by network_os %s" % self._play_context.network_os) def discard_changes(self): - "Discard changes in candidate datastore" + """Discard candidate configuration + + This method will discard the current candidate configuration if one + is present. If there is no candidate configuration currently loaded, + then this method should just silently return + + :returns: None + """ return self._connection.method_not_found("discard_changes is not supported by network_os %s" % self._play_context.network_os) def copy_file(self, source=None, destination=None, proto='scp', timeout=30): - """Copies file over scp/sftp to remote device""" + """Copies file over scp/sftp to remote device + + :param source: Source file path + :param destination: Destination file path on remote device + :param proto: Protocol to be used for file transfer, + supported protocol: scp and sftp + :param timeout: Specifies the wait time to receive response from + remote host before triggering timeout exception + :return: None + """ ssh = self._connection.paramiko_conn._connect_uncached() if proto == 'scp': if not HAS_SCP: @@ -191,6 +307,15 @@ class CliconfBase(with_metaclass(ABCMeta, object)): sftp.put(source, destination) def get_file(self, source=None, destination=None, proto='scp', timeout=30): + """Fetch file over scp/sftp from remote device + :param source: Source file path + :param destination: Destination file path + :param proto: Protocol to be used for file transfer, + supported protocol: scp and sftp + :param timeout: Specifies the wait time to receive response from + remote host before triggering timeout exception + :return: None + """ """Fetch file over scp/sftp from remote device""" ssh = self._connection.paramiko_conn._connect_uncached() if proto == 'scp': diff --git a/lib/ansible/plugins/cliconf/ios.py b/lib/ansible/plugins/cliconf/ios.py index 28839072471..5e2e79e71fa 100644 --- a/lib/ansible/plugins/cliconf/ios.py +++ b/lib/ansible/plugins/cliconf/ios.py @@ -19,18 +19,134 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type +import collections import re +import time import json from itertools import chain 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 from ansible.module_utils.network.common.utils import to_list from ansible.plugins.cliconf import CliconfBase, enable_mode class Cliconf(CliconfBase): + @enable_mode + def get_config(self, source='running', filter=None, format='text'): + if source not in ('running', 'startup'): + return self.invalid_params("fetching configuration from %s is not supported" % source) + + if not filter: + filter = [] + if source == 'running': + cmd = 'show running-config ' + else: + cmd = 'show startup-config ' + + cmd += ' '.join(to_list(filter)) + cmd = cmd.strip() + + return self.send_command(cmd) + + def get_diff(self, candidate=None, running=None, match='line', diff_ignore_lines=None, path=None, replace='line'): + """ + Generate diff between candidate and running configuration. If the + remote host supports onbox diff capabilities ie. supports_onbox_diff in that case + candidate and running configurations are not required to be passed as argument. + In case if onbox diff capability is not supported candidate argument is mandatory + and running argument is optional. + :param candidate: The configuration which is expected to be present on remote host. + :param running: The base configuration which is used to generate diff. + :param match: Instructs how to match the candidate configuration with current device configuration + Valid values are 'line', 'strict', 'exact', 'none'. + 'line' - commands are matched line by line + 'strict' - command lines are matched with respect to position + 'exact' - command lines must be an equal match + 'none' - will not compare the candidate configuration with + the running configuration on the remote device + :param diff_ignore_lines: Use this argument to specify one or more lines that should be + ignored during the diff. This is used for lines in the configuration + that are automatically updated by the system. This argument takes + a list of regular expressions or exact line matches. + :param path: The ordered set of parents that uniquely identify the section or hierarchy + the commands should be checked against. If the parents argument + is omitted, the commands are checked against the set of top + level or global commands. + :param replace: Instructs on the way to perform the configuration on the device. + If the replace argument is set to I(line) then the modified lines are + pushed to the device in configuration mode. If the replace argument is + set to I(block) then the entire command block is pushed to the device in + configuration mode if any line is not correct. + :return: Configuration diff in json format. + { + 'config_diff': '', + 'banner_diff': '' + } + + """ + diff = {} + device_operations = self.get_device_operations() + + if candidate is None and not device_operations['supports_onbox_diff']: + raise ValueError('candidate configuration is required to generate diff') + + # prepare candidate configuration + candidate_obj = NetworkConfig(indent=1) + want_src, want_banners = self._extract_banners(candidate) + candidate_obj.load(want_src) + + if running and match != 'none': + # running configuration + have_src, have_banners = self._extract_banners(running) + running_obj = NetworkConfig(indent=1, contents=have_src, ignore_lines=diff_ignore_lines) + configdiffobjs = candidate_obj.difference(running_obj, path=path, match=match, replace=replace) + + else: + configdiffobjs = candidate_obj.items + have_banners = {} + + configdiff = dumps(configdiffobjs, 'commands') if configdiffobjs else '' + diff['config_diff'] = configdiff if configdiffobjs else {} + + banners = self._diff_banners(want_banners, have_banners) + + diff['banner_diff'] = banners if banners else {} + return json.dumps(diff) + + @enable_mode + def edit_config(self, candidate, check_mode=False, replace=None): + + if not candidate: + raise ValueError('must provide a candidate config to load') + + if check_mode not in (True, False): + raise ValueError('`check_mode` must be a bool, got %s' % check_mode) + + device_operations = self.get_device_operations() + options = self.get_options() + if replace and replace not in options['replace']: + raise ValueError('`replace` value %s in invalid, valid values are %s' % (replace, options['replace'])) + + results = [] + if not check_mode: + for line in chain(['configure terminal'], to_list(candidate)): + if line != 'end' and line[0] != '!': + if not isinstance(line, collections.Mapping): + line = {'command': line} + + results.append(self.send_command(**line)) + + results.append(self.send_command('end')) + + return results[1:-1] + + def get(self, command, prompt=None, answer=None, sendonly=False): + return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly) + def get_device_info(self): device_info = {} @@ -52,48 +168,82 @@ class Cliconf(CliconfBase): return device_info - @enable_mode - def get_config(self, source='running', format='text', flags=None): - if source not in ('running', 'startup'): - return self.invalid_params("fetching configuration from %s is not supported" % source) - - if not flags: - flags = [] + def get_device_operations(self): + return { + 'supports_replace': True, + 'supports_commit': False, + 'supports_rollback': False, + 'supports_defaults': True, + 'supports_onbox_diff': False, + 'supports_commit_comment': False, + 'supports_multiline_delimiter': False, + 'support_match': True, + 'support_diff_ignore_lines': True, + 'supports_generate_diff': True, + } + + def get_options(self): + return { + 'format': ['text'], + 'match': ['line', 'strict', 'exact', 'none'], + 'replace': ['line', 'block'] + } - if source == 'running': - cmd = 'show running-config ' - else: - cmd = 'show startup-config ' + def get_capabilities(self): + result = dict() + result['rpc'] = self.get_base_rpc() + ['edit_banner'] + result['network_api'] = 'cliconf' + result['device_info'] = self.get_device_info() + result['device_operations'] = self.get_device_operations() + result.update(self.get_options()) + return json.dumps(result) - cmd += ' '.join(to_list(flags)) - cmd = cmd.strip() + def edit_banner(self, banners, multiline_delimiter="@", check_mode=False): + """ + Edit banner on remote device + :param banners: Banners to be loaded in json format + :param multiline_delimiter: Line delimiter for banner + :param check_mode: Boolean value that indicates if the device candidate + configuration should be pushed in the running configuration or discarded. + :return: Returns response of executing the configuration command received + from remote host + """ + banners_obj = json.loads(banners) + results = [] + if not check_mode: + for key, value in iteritems(banners_obj): + key += ' %s' % multiline_delimiter + for cmd in ['config terminal', key, value, multiline_delimiter, 'end']: + obj = {'command': cmd, 'sendonly': True} + results.append(self.send_command(**obj)) - return self.send_command(cmd) + time.sleep(0.1) + results.append(self.send_command('\n')) - @enable_mode - def edit_config(self, command): - results = [] - for cmd in chain(['configure terminal'], to_list(command), ['end']): - if isinstance(cmd, dict): - command = cmd['command'] - prompt = cmd['prompt'] - answer = cmd['answer'] - newline = cmd.get('newline', True) - else: - command = cmd - prompt = None - answer = None - newline = True - - results.append(self.send_command(command, prompt, answer, False, newline)) return results[1:-1] - def get(self, command, prompt=None, answer=None, sendonly=False): - return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly) - - def get_capabilities(self): - result = {} - result['rpc'] = self.get_base_rpc() - result['network_api'] = 'cliconf' - result['device_info'] = self.get_device_info() - return json.dumps(result) + def _extract_banners(self, config): + banners = {} + banner_cmds = re.findall(r'^banner (\w+)', config, re.M) + for cmd in banner_cmds: + regex = r'banner %s \^C(.+?)(?=\^C)' % cmd + match = re.search(regex, config, re.S) + if match: + key = 'banner %s' % cmd + banners[key] = match.group(1).strip() + + for cmd in banner_cmds: + regex = r'banner %s \^C(.+?)(?=\^C)' % cmd + match = re.search(regex, config, re.S) + if match: + config = config.replace(str(match.group(1)), '') + + config = re.sub(r'banner \w+ \^C\^C', '!! banner removed', config) + return config, banners + + def _diff_banners(self, want, have): + candidate = {} + for key, value in iteritems(want): + if value != have.get(key): + candidate[key] = value + return candidate diff --git a/lib/ansible/plugins/connection/network_cli.py b/lib/ansible/plugins/connection/network_cli.py index 96968900835..26a7974bad1 100644 --- a/lib/ansible/plugins/connection/network_cli.py +++ b/lib/ansible/plugins/connection/network_cli.py @@ -282,6 +282,10 @@ class Connection(ConnectionBase): messages.append('deauthorizing connection') self._play_context = play_context + + self.reset_history() + self.disable_response_logging() + return messages def _connect(self): diff --git a/test/units/modules/network/ios/test_ios_config.py b/test/units/modules/network/ios/test_ios_config.py index 96ca1dd11df..4311057e18e 100644 --- a/test/units/modules/network/ios/test_ios_config.py +++ b/test/units/modules/network/ios/test_ios_config.py @@ -20,8 +20,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.ios import ios_config +from ansible.plugins.cliconf.ios import Cliconf from units.modules.utils import set_module_args from .ios_module import TestIosModule, load_fixture @@ -36,31 +37,39 @@ class TestIosConfigModule(TestIosModule): self.mock_get_config = patch('ansible.modules.network.ios.ios_config.get_config') self.get_config = self.mock_get_config.start() - self.mock_load_config = patch('ansible.modules.network.ios.ios_config.load_config') - self.load_config = self.mock_load_config.start() + self.mock_get_connection = patch('ansible.modules.network.ios.ios_config.get_connection') + self.get_connection = self.mock_get_connection.start() + + self.conn = self.get_connection() + self.conn.edit_config = MagicMock() self.mock_run_commands = patch('ansible.modules.network.ios.ios_config.run_commands') self.run_commands = self.mock_run_commands.start() + self.cliconf_obj = Cliconf(MagicMock()) + self.running_config = load_fixture('ios_config_config.cfg') + def tearDown(self): super(TestIosConfigModule, self).tearDown() self.mock_get_config.stop() - self.mock_load_config.stop() self.mock_run_commands.stop() + self.mock_get_connection.stop() def load_fixtures(self, commands=None): config_file = 'ios_config_config.cfg' self.get_config.return_value = load_fixture(config_file) - self.load_config.return_value = None + self.get_connection.edit_config.return_value = None def test_ios_config_unchanged(self): src = load_fixture('ios_config_config.cfg') + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(src, src)) set_module_args(dict(src=src)) self.execute_module() def test_ios_config_src(self): src = load_fixture('ios_config_src.cfg') set_module_args(dict(src=src)) + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(src, self.running_config)) commands = ['hostname foo', 'interface GigabitEthernet0/0', 'no ip address'] self.execute_module(changed=True, commands=commands) @@ -76,7 +85,7 @@ class TestIosConfigModule(TestIosModule): self.execute_module(changed=True) self.assertEqual(self.run_commands.call_count, 1) self.assertEqual(self.get_config.call_count, 0) - self.assertEqual(self.load_config.call_count, 0) + self.assertEqual(self.conn.edit_config.call_count, 0) args = self.run_commands.call_args[0][1] self.assertIn('copy running-config startup-config\r', args) @@ -84,10 +93,11 @@ class TestIosConfigModule(TestIosModule): src = load_fixture('ios_config_src.cfg') set_module_args(dict(src=src, save_when='changed')) commands = ['hostname foo', 'interface GigabitEthernet0/0', 'no ip address'] + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(src, self.running_config)) self.execute_module(changed=True, commands=commands) self.assertEqual(self.run_commands.call_count, 1) self.assertEqual(self.get_config.call_count, 1) - self.assertEqual(self.load_config.call_count, 1) + self.assertEqual(self.conn.edit_config.call_count, 1) args = self.run_commands.call_args[0][1] self.assertIn('copy running-config startup-config\r', args) @@ -96,7 +106,7 @@ class TestIosConfigModule(TestIosModule): self.execute_module(changed=False) self.assertEqual(self.run_commands.call_count, 0) self.assertEqual(self.get_config.call_count, 0) - self.assertEqual(self.load_config.call_count, 0) + self.assertEqual(self.conn.edit_config.call_count, 0) def test_ios_config_save(self): self.run_commands.return_value = "hostname foo" @@ -104,39 +114,57 @@ class TestIosConfigModule(TestIosModule): self.execute_module(changed=True) self.assertEqual(self.run_commands.call_count, 1) self.assertEqual(self.get_config.call_count, 0) - self.assertEqual(self.load_config.call_count, 0) + self.assertEqual(self.conn.edit_config.call_count, 0) args = self.run_commands.call_args[0][1] self.assertIn('copy running-config startup-config\r', args) def test_ios_config_lines_wo_parents(self): - set_module_args(dict(lines=['hostname foo'])) + lines = ['hostname foo'] + set_module_args(dict(lines=lines)) + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff('\n'.join(lines), self.running_config)) commands = ['hostname foo'] self.execute_module(changed=True, commands=commands) def test_ios_config_lines_w_parents(self): - set_module_args(dict(lines=['shutdown'], parents=['interface GigabitEthernet0/0'])) + lines = ['shutdown'] + parents = ['interface GigabitEthernet0/0'] + set_module_args(dict(lines=lines, parents=parents)) + module = MagicMock() + module.params = {'lines': lines, 'parents': parents, 'src': None} + candidate_config = ios_config.get_candidate_config(module) + + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(candidate_config, self.running_config)) + commands = ['interface GigabitEthernet0/0', 'shutdown'] self.execute_module(changed=True, commands=commands) def test_ios_config_before(self): - set_module_args(dict(lines=['hostname foo'], before=['test1', 'test2'])) + lines = ['hostname foo'] + set_module_args(dict(lines=lines, before=['test1', 'test2'])) + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff('\n'.join(lines), self.running_config)) commands = ['test1', 'test2', 'hostname foo'] self.execute_module(changed=True, commands=commands, sort=False) def test_ios_config_after(self): - set_module_args(dict(lines=['hostname foo'], after=['test1', 'test2'])) + lines = ['hostname foo'] + set_module_args(dict(lines=lines, after=['test1', 'test2'])) + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff('\n'.join(lines), self.running_config)) commands = ['hostname foo', 'test1', 'test2'] self.execute_module(changed=True, commands=commands, sort=False) def test_ios_config_before_after_no_change(self): - set_module_args(dict(lines=['hostname router'], + lines = ['hostname router'] + set_module_args(dict(lines=lines, before=['test1', 'test2'], after=['test3', 'test4'])) + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff('\n'.join(lines), self.running_config)) self.execute_module() def test_ios_config_config(self): config = 'hostname localhost' - set_module_args(dict(lines=['hostname router'], config=config)) + lines = ['hostname router'] + set_module_args(dict(lines=lines, config=config)) + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff('\n'.join(lines), config)) commands = ['hostname router'] self.execute_module(changed=True, commands=commands) @@ -144,18 +172,32 @@ class TestIosConfigModule(TestIosModule): lines = ['description test string', 'test string'] parents = ['interface GigabitEthernet0/0'] set_module_args(dict(lines=lines, replace='block', parents=parents)) + + module = MagicMock() + module.params = {'lines': lines, 'parents': parents, 'src': None} + candidate_config = ios_config.get_candidate_config(module) + + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(candidate_config, self.running_config, replace='block', path=parents)) + commands = parents + lines self.execute_module(changed=True, commands=commands) def test_ios_config_match_none(self): lines = ['hostname router'] set_module_args(dict(lines=lines, match='none')) + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff('\n'.join(lines), self.running_config, match='none')) self.execute_module(changed=True, commands=lines) def test_ios_config_match_none(self): lines = ['ip address 1.2.3.4 255.255.255.0', 'description test string'] parents = ['interface GigabitEthernet0/0'] set_module_args(dict(lines=lines, parents=parents, match='none')) + + module = MagicMock() + module.params = {'lines': lines, 'parents': parents, 'src': None} + candidate_config = ios_config.get_candidate_config(module) + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(candidate_config, self.running_config, match='none', path=parents)) + commands = parents + lines self.execute_module(changed=True, commands=commands, sort=False) @@ -164,6 +206,12 @@ class TestIosConfigModule(TestIosModule): 'shutdown'] parents = ['interface GigabitEthernet0/0'] set_module_args(dict(lines=lines, parents=parents, match='strict')) + + module = MagicMock() + module.params = {'lines': lines, 'parents': parents, 'src': None} + candidate_config = ios_config.get_candidate_config(module) + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(candidate_config, self.running_config, match='strict', path=parents)) + commands = parents + ['shutdown'] self.execute_module(changed=True, commands=commands, sort=False) @@ -172,6 +220,12 @@ class TestIosConfigModule(TestIosModule): 'shutdown'] parents = ['interface GigabitEthernet0/0'] set_module_args(dict(lines=lines, parents=parents, match='exact')) + + module = MagicMock() + module.params = {'lines': lines, 'parents': parents, 'src': None} + candidate_config = ios_config.get_candidate_config(module) + self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(candidate_config, self.running_config, match='exact', path=parents)) + commands = parents + lines self.execute_module(changed=True, commands=commands, sort=False) diff --git a/test/units/plugins/cliconf/test_slxos.py b/test/units/plugins/cliconf/test_slxos.py index ac5f073fa9d..706fe9bcbb1 100644 --- a/test/units/plugins/cliconf/test_slxos.py +++ b/test/units/plugins/cliconf/test_slxos.py @@ -62,6 +62,7 @@ class TestPluginCLIConfSLXOS(unittest.TestCase): self._mock_connection = MagicMock() self._mock_connection.send.side_effect = _connection_side_effect self._cliconf = slxos.Cliconf(self._mock_connection) + self.maxDiff = None def tearDown(self): pass @@ -125,7 +126,9 @@ class TestPluginCLIConfSLXOS(unittest.TestCase): 'get_config', 'edit_config', 'get_capabilities', - 'get' + 'get', + 'enable_response_logging', + 'disable_response_logging' ], 'device_info': { 'network_os_model': 'BR-SLX9140',