From 0b6f0e6c0d95e271cf4173b28ece4a5e58902f9c Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Tue, 11 Jul 2017 20:34:20 -0400 Subject: [PATCH] adds more intelligent save logic and diff to network config modules (#26565) * adds more intelligent save logic and diff to network config modules * adds sha1 property to NetworkConfig * adds new argument save_when to argument_spec * adds new argument diff_against to argument_spec * adds new argument intended_config to argument_spec * renames config argument to running_config with alias to config * deprecates the use of the save argument * before and after now work with src argument * misc module clean Modules updated * nxos_config * ios_config * eos_config Most notably this makes the save mechanism more intelligent for config modules for devices that need to copy the ephemeral config to non-volatile storage. The diff_against argument allows the playbook task to control what the device's running-config is diff'ed against. By default it will return the diff of the startup-config. * removes ios_config from pep8/legacy_files.txt * extends the ignore lines argument to the module * clean up CI errors * add missing list brackets * fixes typo * fixes unit test cases * remove last line break when returning config contents * encode config string to bytes before hashing * fix typo * addresses feedback in PR * update unit test cases --- lib/ansible/module_utils/netcfg.py | 31 +- lib/ansible/modules/network/eos/eos_config.py | 301 ++++++++++++------ lib/ansible/modules/network/ios/ios_config.py | 236 +++++++++++--- .../modules/network/nxos/nxos_config.py | 282 +++++++++++----- test/sanity/pep8/legacy-files.txt | 1 - .../modules/network/ios/test_ios_config.py | 10 +- 6 files changed, 628 insertions(+), 233 deletions(-) diff --git a/lib/ansible/module_utils/netcfg.py b/lib/ansible/module_utils/netcfg.py index f712ea3f72..e9bcc10035 100644 --- a/lib/ansible/module_utils/netcfg.py +++ b/lib/ansible/module_utils/netcfg.py @@ -26,12 +26,20 @@ # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # import re +import hashlib from ansible.module_utils.six.moves import zip +from ansible.module_utils._text import to_bytes from ansible.module_utils.network_common import to_list DEFAULT_COMMENT_TOKENS = ['#', '!', '/*', '*/', 'echo'] +DEFAULT_IGNORE_LINES_RE = set([ + re.compile("Using \d+ out of \d+ bytes"), + re.compile("Building configuration"), + re.compile("Current configuration : \d+ bytes") +]) + class ConfigLine(object): @@ -97,6 +105,9 @@ def ignore_line(text, tokens=None): for item in (tokens or DEFAULT_COMMENT_TOKENS): if text.startswith(item): return True + for regex in DEFAULT_IGNORE_LINES_RE: + if regex.match(text): + return True def _obj_to_text(x): @@ -141,9 +152,16 @@ def dumps(objects, output='block', comments=False): class NetworkConfig(object): - def __init__(self, indent=1, contents=None): + def __init__(self, indent=1, contents=None, ignore_lines=None): self._indent = indent self._items = list() + self._config_text = None + + if ignore_lines: + for item in ignore_lines: + if not isinstance(item, re._pattern_type): + item = re.compile(item) + DEFAULT_IGNORE_LINES_RE.add(item) if contents: self.load(contents) @@ -152,6 +170,16 @@ class NetworkConfig(object): def items(self): return self._items + @property + def config_text(self): + return self._config_text + + @property + def sha1(self): + sha1 = hashlib.sha1() + sha1.update(to_bytes(str(self), errors='surrogate_or_strict')) + return sha1.digest() + def __getitem__(self, key): for line in self: if line.text == key: @@ -168,6 +196,7 @@ class NetworkConfig(object): return len(self._items) def load(self, s): + self._config_text = s self._items = self.parse(s) def loadfp(self, fp): diff --git a/lib/ansible/modules/network/eos/eos_config.py b/lib/ansible/modules/network/eos/eos_config.py index 7163c59dbb..460887a4f2 100644 --- a/lib/ansible/modules/network/eos/eos_config.py +++ b/lib/ansible/modules/network/eos/eos_config.py @@ -30,7 +30,7 @@ short_description: Manage Arista EOS configuration sections description: - Arista EOS configurations use a simple block indent file syntax for segmenting configuration into sections. This module provides - an implementation for working with eos configuration sections in + an implementation for working with EOS configuration sections in a deterministic way. This module works with either CLI or eAPI transports. extends_documentation_fragment: eos @@ -115,7 +115,7 @@ options: will be removed in a future release. required: false default: false - choices: ['yes', 'no'] + type: bool backup: description: - This argument will cause the module to create a full backup of @@ -125,19 +125,21 @@ options: exist, it is created. required: false default: no - choices: ['yes', 'no'] + type: bool version_added: "2.2" - config: + running_config: description: - The module, by default, will connect to the remote device and retrieve the current running-config to use as a base for comparing against the contents of source. There are times when it is not desirable to have the task get the current running-config for - every task in a playbook. The I(config) argument allows the + every task in a playbook. The I(running_config) argument allows the implementer to pass in the configuration to use as the base - config for comparison. + config for this module. required: false default: null + aliases: ['config'] + version_added: "2.4" defaults: description: - The I(defaults) argument will influence how the running-config @@ -147,6 +149,7 @@ options: is issued without the all keyword required: false default: false + type: bool version_added: "2.2" save: description: @@ -156,27 +159,78 @@ options: no changes are made, the configuration is still saved to the startup config. This option will always cause the module to return changed. + - This option is deprecated as of Ansible 2.4, use C(save_when) required: false default: false + type: bool version_added: "2.2" + save_when: + description: + - When changes are made to the device running-configuration, the + changes are not copied to non-volatile storage by default. Using + this argument will change that before. If the argument is set to + I(always), then the running-config will always be copied to the + startup-config and the I(changed) flag will always be set to + True. If the argument is set to I(changed), then the running-config + will only be copied to the startup-config if it has changed since + the last save to startup-config. If the argument is set to + I(never), the running-config will never be copied to the the + startup-config + required: false + default: never + choices: ['always', 'never', 'changed'] + version_added: "2.4" + diff_against: + description: + - When using the C(ansible-playbook --diff) command line argument the i + module can generate diffs against different sources. + - When this option is configure as I(startup), the module will return + the diff of the running-config against the startup-config. + - When this option is configured as I(intended), the module will + return the diff of the running-config against the configuration + provided in the C(intended_config) argument. + - When this option is configured as I(running), the module will + return the before and after diff of the running-config with respect + to any changes made to the device configuration. + - When this option is configured as C(session), the diff returned will + be based on the configuration session. + required: false + default: session + choices: ['startup', 'running', 'intended', 'session'] + version_added: "2.4" + diff_ignore_lines: + description: + - 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. + required: false + version_added: "2.4" + intended_config: + description: + - The C(intended_config) provides the master configuration that + the node should conform to and is used to check the final + running-config against. This argument will not modify any settings + on the remote device and is strictly used to check the compliance + of the current device's configuration against. When specifying this + argument, the task should also modify the C(diff_against) value and + set it to I(intended). + required: false + version_added: "2.4" """ EXAMPLES = """ -- eos_config: +- name: configure top level settings + eos_config: lines: hostname {{ inventory_hostname }} -- eos_config: - lines: - - 10 permit ip 1.1.1.1/32 any log - - 20 permit ip 2.2.2.2/32 any log - - 30 permit ip 3.3.3.3/32 any log - - 40 permit ip 4.4.4.4/32 any log - - 50 permit ip 5.5.5.5/32 any log - parents: ip access-list test - before: no ip access-list test - match: exact +- name: diff against a provided master config + eos_config: + diff_against: config + config: "{{ lookup('file', 'master.cfg') }}" -- eos_config: +- name: load an acl into the device + eos_config: lines: - 10 permit ip 1.1.1.1/32 any log - 20 permit ip 2.2.2.2/32 any log @@ -189,12 +243,22 @@ EXAMPLES = """ - name: load configuration from file eos_config: src: eos.cfg + +- name: diff the running config against a master config + eos_config: + diff_against: intended + intended_config: "{{ lookup('file', 'master.cfg') }}" """ RETURN = """ commands: description: The set of commands that will be pushed to the remote device - returned: Only when lines is specified. + returned: always + type: list + sample: ['hostname switch01', 'interface Ethernet1', 'no shutdown'] +updates: + description: The set of commands that will be pushed to the remote device + returned: always type: list sample: ['hostname switch01', 'interface Ethernet1', 'no shutdown'] backup_path: @@ -208,14 +272,8 @@ from ansible.module_utils.netcfg import NetworkConfig, dumps from ansible.module_utils.eos import get_config, load_config from ansible.module_utils.eos import run_commands from ansible.module_utils.eos import eos_argument_spec -from ansible.module_utils.eos import check_args as eos_check_args +from ansible.module_utils.eos import check_args -def check_args(module, warnings): - eos_check_args(module, warnings) - if module.params['force']: - warnings.append('The force argument is deprecated, please use ' - 'match=none instead. This argument will be ' - 'removed in the future') def get_candidate(module): candidate = NetworkConfig(indent=3) @@ -226,51 +284,17 @@ def get_candidate(module): candidate.add(module.params['lines'], parents=parents) return candidate -def get_running_config(module): - flags = [] - if module.params['defaults'] is True: - flags.append('all') - return get_config(module, flags) -def run(module, result): - match = module.params['match'] - replace = module.params['replace'] +def get_running_config(module, config=None): + contents = module.params['running_config'] + if not contents: + if not module.params['defaults'] and config: + contents = config + else: + flags = ['all'] + contents = get_config(module, flags=flags) + return NetworkConfig(indent=3, contents=contents) - candidate = get_candidate(module) - - 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 - - if configobjs: - commands = dumps(configobjs, 'commands').split('\n') - - if module.params['lines']: - if module.params['before']: - commands[:0] = module.params['before'] - - if module.params['after']: - commands.extend(module.params['after']) - - result['commands'] = commands - result['updates'] = commands - - replace = module.params['replace'] == 'config' - commit = not module.check_mode - - response = load_config(module, commands, replace=replace, commit=commit) - - if 'diff' in response: - result['diff'] = {'prepared': response['diff']} - - if 'session' in response: - result['session'] = response['session'] - - result['changed'] = True def main(): """ main entry point for module execution @@ -288,34 +312,39 @@ def main(): replace=dict(default='line', choices=['line', 'block', 'config']), defaults=dict(type='bool', default=False), - backup=dict(type='bool', default=False), - save=dict(default=False, type='bool'), - # deprecated arguments (Ansible 2.3) - config=dict(), - # this argument is deprecated in favor of setting match: none - # it will be removed in a future version - force=dict(default=False, type='bool'), + save_when=dict(choices=['always', 'never', 'changed'], default='never'), + + diff_against=dict(choices=['startup', 'session', 'intended', 'running'], default='session'), + diff_ignore_lines=dict(type='list'), + + running_config=dict(aliases=['config']), + intended_config=dict(), + + # save is deprecated as of ans2.4, use save_when instead + save=dict(default=False, type='bool', removed_in_version='2.4'), + + # force argument deprecated in ans2.2 + force=dict(default=False, type='bool', removed_in_version='2.2') ) argument_spec.update(eos_argument_spec) - mutually_exclusive = [('lines', 'src')] + mutually_exclusive = [('lines', 'src'), + ('save', 'save_when')] required_if = [('match', 'strict', ['lines']), ('match', 'exact', ['lines']), ('replace', 'block', ['lines']), - ('replace', 'config', ['src'])] + ('replace', 'config', ['src']), + ('diff_against', 'intended', ['intended_config'])] module = AnsibleModule(argument_spec=argument_spec, mutually_exclusive=mutually_exclusive, required_if=required_if, supports_check_mode=True) - if module.params['force'] is True: - module.params['match'] = 'none' - warnings = list() check_args(module, warnings) @@ -323,18 +352,110 @@ def main(): if warnings: result['warnings'] = warnings - if module.params['backup']: - result['__backup__'] = get_config(module) + config = None + + if module.params['backup'] or (module._diff and module.params['diff_against'] == 'running'): + contents = get_config(module) + config = NetworkConfig(indent=2, contents=contents) + if module.params['backup']: + result['__backup__'] = contents if any((module.params['src'], module.params['lines'])): - run(module, result) + match = module.params['match'] + replace = module.params['replace'] - if module.params['save']: - if not module.check_mode: - response = run_commands(module, ['show running-config diffs']) - if len(response[0]): - run_commands(module, ['copy running-config startup-config']) - result['changed'] = True + candidate = get_candidate(module) + + 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 + + if configobjs: + commands = dumps(configobjs, 'commands').split('\n') + + if module.params['before']: + commands[:0] = module.params['before'] + + if module.params['after']: + commands.extend(module.params['after']) + + result['commands'] = commands + result['updates'] = commands + + replace = module.params['replace'] == 'config' + commit = not module.check_mode + + response = load_config(module, commands, replace=replace, commit=commit) + + if 'diff' in response and module.params['diff_against'] == 'session': + result['diff'] = {'prepared': response['diff']} + + if 'session' in response: + result['session'] = response['session'] + + result['changed'] = True + + + running_config = None + startup_config = None + + diff_ignore_lines = module.params['diff_ignore_lines'] + + if module.params['save_when'] != 'never': + output = run_commands(module, ['show running-config', 'show startup-config']) + + 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) + + if running_config.sha1 != startup_config.sha1 or module.params['save_when'] == 'always': + result['changed'] = True + if not module.check_mode: + cmd = {'command': 'copy running-config startup-config', 'output': 'text'} + run_commands(module, [cmd]) + else: + module.warn('Skipping command `copy running-config startup-config` ' + 'due to check_mode. Configuration not copied to ' + 'non-volatile storage') + + if module._diff: + if not running_config: + output = run_commands(module, 'show running-config') + contents = output[0] + else: + contents = running_config.config_text + + # recreate the object in order to process diff_ignore_lines + running_config = NetworkConfig(indent=1, contents=config_text, ignore_lines=diff_ignore_lines) + + if module.params['diff_against'] == 'running': + if module.check_mode: + module.warn("unable to perform diff against running-config due to check mode") + contents = None + else: + contents = config.config_text + + elif module.params['diff_against'] == 'startup': + if not startup_config: + output = run_commands(module, 'show startup-config') + contents = output[0] + else: + contents = startup_config.config_text + + elif module.params['diff_against'] == 'intended': + contents = module.params['intended_config'] + + if contents is not None: + base_config = NetworkConfig(indent=1, contents=contents, ignore_lines=diff_ignore_lines) + + if running_config.sha1 != base_config.sha1: + result.update({ + 'changed': True, + 'diff': {'before': str(base_config), 'after': str(running_config)} + }) module.exit_json(**result) diff --git a/lib/ansible/modules/network/ios/ios_config.py b/lib/ansible/modules/network/ios/ios_config.py index d3f8a0f155..511481f281 100644 --- a/lib/ansible/modules/network/ios/ios_config.py +++ b/lib/ansible/modules/network/ios/ios_config.py @@ -123,7 +123,7 @@ options: will be removed in a future release. required: false default: false - choices: ["true", "false"] + type: bool backup: description: - This argument will cause the module to create a full backup of @@ -133,17 +133,21 @@ options: exist, it is created. required: false default: no - choices: ['yes', 'no'] + type: bool version_added: "2.2" - config: + running_config: description: - - The C(config) argument allows the playbook designer to supply - the base configuration to be used to validate configuration - changes necessary. If this argument is provided, the module - will not download the running-config from the remote node. + - The module, by default, will connect to the remote device and + retrieve the current running-config to use as a base for comparing + against the contents of source. There are times when it is not + desirable to have the task get the current running-config for + every task in a playbook. The I(running_config) argument allows the + implementer to pass in the configuration to use as the base + config for comparison. required: false default: null - version_added: "2.2" + aliases: ['config'] + version_added: "2.4" defaults: description: - This argument specifies whether or not to collect all defaults @@ -152,17 +156,68 @@ options: C(show running-config all). required: false default: no - choices: ['yes', 'no'] + type: bool version_added: "2.2" save: description: - The C(save) argument instructs the module to save the running- config to the startup-config at the conclusion of the module running. If check mode is specified, this argument is ignored. + - This option is deprecated as of Ansible 2.4, use C(save_when) required: false - default: no - choices: ['yes', 'no'] + default: false + type: bool version_added: "2.2" + save_when: + description: + - When changes are made to the device running-configuration, the + changes are not copied to non-volatile storage by default. Using + this argument will change that before. If the argument is set to + I(always), then the running-config will always be copied to the + startup-config and the I(changed) flag will always be set to + True. If the argument is set to I(changed), then the running-config + will only be copied to the startup-config if it has changed since + the last save to startup-config. If the argument is set to + I(never), the running-config will never be copied to the the + startup-config + required: false + default: never + choices: ['always', 'never', 'changed'] + version_added: "2.4" + diff_against: + description: + - When using the C(ansible-playbook --diff) command line argument + the module can generate diffs against different sources. + - When this option is configure as I(startup), the module will return + the diff of the running-config against the startup-config. + - When this option is configured as I(intended), the module will + return the diff of the running-config against the configuration + provided in the C(intended_config) argument. + - When this option is configured as I(running), the module will + return the before and after diff of the running-config with respect + to any changes made to the device configuration. + required: false + choices: ['running', 'startup', 'intended'] + version_added: "2.4" + diff_ignore_lines: + description: + - 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. + required: false + version_added: "2.4" + intended_config: + description: + - The C(intended_config) provides the master configuration that + the node should conform to and is used to check the final + running-config against. This argument will not modify any settings + on the remote device and is strictly used to check the compliance + of the current device's configuration against. When specifying this + argument, the task should also modify the C(diff_against) value and + set it to I(intended). + required: false + version_added: "2.4" """ EXAMPLES = """ @@ -188,14 +243,34 @@ EXAMPLES = """ parents: ip access-list extended test before: no ip access-list extended test match: exact + +- name: check the running-config against master config + ios_config: + diff_config: intended + intended_config: "{{ lookup('file', 'master.cfg') }}" + +- name: check the startup-config against the running-config + ios_config: + diff_against: startup + diff_ignore_lines: + - ntp clock .* + +- name: save running to startup when changed + ios_config: + save_when: changed """ RETURN = """ updates: description: The set of commands that will be pushed to the remote device - returned: Only when lines is specified. + returned: always type: list - sample: ['...', '...'] + sample: ['hostname foo', 'router ospf 1', 'router-id 1.1.1.1'] +commands: + description: The set of commands that will be pushed to the remote device + returned: always + type: list + sample: ['hostname foo', 'router ospf 1', 'router-id 1.1.1.1'] backup_path: description: The full path to the backup file returned: when backup is yes @@ -221,10 +296,7 @@ def check_args(module, warnings): if len(module.params['multiline_delimiter']) != 1: module.fail_json(msg='multiline_delimiter value can only be a ' 'single character') - if module.params['force']: - warnings.append('The force argument is deprecated as of Ansible 2.2, ' - 'please use match=none instead. This argument will ' - 'be removed in the future') + def extract_banners(config): banners = {} @@ -245,6 +317,7 @@ def extract_banners(config): config = re.sub(r'banner \w+ \^C\^C', '!! banner removed', config) return (config, banners) + def diff_banners(want, have): candidate = {} for key, value in iteritems(want): @@ -252,6 +325,7 @@ def diff_banners(want, have): candidate[key] = value return candidate + def load_banners(module, banners): delimiter = module.params['multiline_delimiter'] for key, value in iteritems(banners): @@ -262,16 +336,19 @@ def load_banners(module, banners): time.sleep(0.1) run_commands(module, ['\n']) -def get_running_config(module): - contents = module.params['config'] + +def get_running_config(module, current_config=None): + contents = module.params['running_config'] if not contents: - flags = [] - if module.params['defaults']: - flags.append(get_defaults_flag(module)) - contents = get_config(module, flags=flags) + if not module.params['defaults'] and current_config: + contents, banners = extract_banners(current_config.config_text) + else: + flags = get_defaults_flag(module) if module.params['defaults'] else None + contents = get_config(module, flags=flags) contents, banners = extract_banners(contents) return NetworkConfig(indent=1, contents=contents), banners + def get_candidate(module): candidate = NetworkConfig(indent=1) banners = {} @@ -286,6 +363,7 @@ def get_candidate(module): return candidate, banners + def main(): """ main entry point for module execution """ @@ -302,39 +380,53 @@ def main(): replace=dict(default='line', choices=['line', 'block']), multiline_delimiter=dict(default='@'), - # this argument is deprecated (2.2) in favor of setting match: none - # it will be removed in a future version - force=dict(default=False, type='bool'), + running_config=dict(aliases=['config']), + intended_config=dict(), - config=dict(), defaults=dict(type='bool', default=False), - backup=dict(type='bool', default=False), - save=dict(type='bool', default=False), + + save_when=dict(choices=['always', 'never', 'changed'], default='never'), + + diff_against=dict(choices=['startup', 'intended', 'running']), + diff_ignore_lines=dict(type='list'), + + # save is deprecated as of ans2.4, use save_when instead + save=dict(default=False, type='bool', removed_in_version='2.4'), + + # force argument deprecated in ans2.2 + force=dict(default=False, type='bool', removed_in_version='2.2') ) argument_spec.update(ios_argument_spec) - mutually_exclusive = [('lines', 'src')] + mutually_exclusive = [('lines', 'src'), + ('save', 'save_when')] required_if = [('match', 'strict', ['lines']), ('match', 'exact', ['lines']), - ('replace', 'block', ['lines'])] + ('replace', 'block', ['lines']), + ('diff_against', 'intended', ['intended_config'])] module = AnsibleModule(argument_spec=argument_spec, mutually_exclusive=mutually_exclusive, required_if=required_if, supports_check_mode=True) - if module.params['force'] is True: - module.params['match'] = 'none' - result = {'changed': False} warnings = list() check_args(module, warnings) result['warnings'] = warnings + config = None + + if module.params['backup'] or (module._diff and module.params['diff_against'] == 'running'): + contents = get_config(module) + config = NetworkConfig(indent=1, contents=contents) + if module.params['backup']: + result['__backup__'] = contents + if any((module.params['lines'], module.params['src'])): match = module.params['match'] replace = module.params['replace'] @@ -343,10 +435,9 @@ def main(): candidate, want_banners = get_candidate(module) if match != 'none': - config, have_banners = get_running_config(module) + config, have_banners = get_running_config(module, config) path = module.params['parents'] - configobjs = candidate.difference(config, path=path, match=match, - replace=replace) + configobjs = candidate.difference(config, path=path, match=match, replace=replace) else: configobjs = candidate.items have_banners = {} @@ -356,12 +447,11 @@ def main(): if configobjs or banners: commands = dumps(configobjs, 'commands').split('\n') - if module.params['lines']: - if module.params['before']: - commands[:0] = module.params['before'] + if module.params['before']: + commands[:0] = module.params['before'] - if module.params['after']: - commands.extend(module.params['after']) + if module.params['after']: + commands.extend(module.params['after']) result['commands'] = commands result['updates'] = commands @@ -377,15 +467,61 @@ def main(): result['changed'] = True - if module.params['backup']: - result['__backup__'] = get_config(module=module) + running_config = None + startup_config = None - if module.params['save']: - if not module.check_mode: - response = run_commands(module, ['show archive config differences']) - if response[0].find('!No changes were found') < 0: - run_commands(module, ['copy running-config startup-config\r']) - result['changed'] = True + diff_ignore_lines = module.params['diff_ignore_lines'] + + if module.params['save_when'] != 'never': + output = run_commands(module, ['show running-config', 'show startup-config']) + + 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) + + if running_config.sha1 != startup_config.sha1 or module.params['save_when'] == 'always': + result['changed'] = True + if not module.check_mode: + run_commands(module, 'copy running-config startup-config') + else: + module.warn('Skipping command `copy running-config startup-config` ' + 'due to check_mode. Configuration not copied to ' + 'non-volatile storage') + + if module._diff: + if not running_config: + output = run_commands(module, 'show running-config') + contents = output[0] + else: + contents = running_config.config_text + + # recreate the object in order to process diff_ignore_lines + running_config = NetworkConfig(indent=1, contents=config_text, ignore_lines=diff_ignore_lines) + + if module.params['diff_against'] == 'running': + if module.check_mode: + module.warn("unable to perform diff against running-config due to check mode") + contents = None + else: + contents = config.config_text + + elif module.params['diff_against'] == 'startup': + if not startup_config: + output = run_commands(module, 'show startup-config') + contents = output[0] + else: + contents = startup_config.config_text + + elif module.params['diff_against'] == 'intended': + contents = module.params['intended_config'] + + if contents is not None: + base_config = NetworkConfig(indent=1, contents=contents, ignore_lines=diff_ignore_lines) + + if running_config.sha1 != base_config.sha1: + result.update({ + 'changed': True, + 'diff': {'before': str(base_config), 'after': str(running_config)} + }) module.exit_json(**result) diff --git a/lib/ansible/modules/network/nxos/nxos_config.py b/lib/ansible/modules/network/nxos/nxos_config.py index 7c7b002a48..7f31f69f05 100644 --- a/lib/ansible/modules/network/nxos/nxos_config.py +++ b/lib/ansible/modules/network/nxos/nxos_config.py @@ -102,7 +102,7 @@ options: command block is pushed to the device in configuration mode if any line is not correct. required: false - default: line + default: lineo choices: ['line', 'block'] force: description: @@ -115,7 +115,7 @@ options: will be removed in a future release. required: false default: false - choices: [ "true", "false" ] + type: bool backup: description: - This argument will cause the module to create a full backup of @@ -124,20 +124,22 @@ options: folder in the playbook root directory. If the directory does not exist, it is created. required: false - default: no - choices: ['yes', 'no'] + default: false + type: bool version_added: "2.2" - config: + running_config: description: - The module, by default, will connect to the remote device and retrieve the current running-config to use as a base for comparing against the contents of source. There are times when it is not desirable to have the task get the current running-config for - every task in a playbook. The I(config) argument allows the + every task in a playbook. The I(running_config) argument allows the implementer to pass in the configuration to use as the base config for comparison. required: false default: null + aliases: ['config'] + version_added: "2.4" defaults: description: - The I(defaults) argument will influence how the running-config @@ -147,6 +149,7 @@ options: is issued without the all keyword required: false default: false + type: bool version_added: "2.2" save: description: @@ -156,28 +159,75 @@ options: no changes are made, the configuration is still saved to the startup config. This option will always cause the module to return changed. + - This option is deprecated as of Ansible 2.4, use C(save_when) required: false default: false + type: bool version_added: "2.2" + save_when: + description: + - When changes are made to the device running-configuration, the + changes are not copied to non-volatile storage by default. Using + this argument will change that before. If the argument is set to + I(always), then the running-config will always be copied to the + startup-config and the I(changed) flag will always be set to + True. If the argument is set to I(changed), then the running-config + will only be copied to the startup-config if it has changed since + the last save to startup-config. If the argument is set to + I(never), the running-config will never be copied to the the + startup-config + required: false + default: never + choices: ['always', 'never', 'changed'] + version_added: "2.4" + diff_against: + description: + - When using the C(ansible-playbook --diff) command line argument + the module can generate diffs against different sources. + - When this option is configure as I(startup), the module will return + the diff of the running-config against the startup-config. + - When this option is configured as I(intended), the module will + return the diff of the running-config against the configuration + provided in the C(intended_config) argument. + - When this option is configured as I(running), the module will + return the before and after diff of the running-config with respect + to any changes made to the device configuration. + required: false + default: startup + choices: ['startup', 'intended', 'running'] + version_added: "2.4" + diff_ignore_lines: + description: + - 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. + required: false + version_added: "2.4" + intended_config: + description: + - The C(intended_config) provides the master configuration that + the node should conform to and is used to check the final + running-config against. This argument will not modify any settings + on the remote device and is strictly used to check the compliance + of the current device's configuration against. When specifying this + argument, the task should also modify the C(diff_against) value and + set it to I(intended). + required: false + version_added: "2.4" """ EXAMPLES = """ -# Note: examples below use the following provider dict to handle -# transport and authentication to the node. ---- -vars: - cli: - host: "{{ inventory_hostname }}" - username: admin - password: admin - transport: cli - --- - name: configure top level configuration and save it nxos_config: lines: hostname {{ inventory_hostname }} - save: yes - provider: "{{ cli }}" + save_when: changed + +- name: diff the running-config against a provided config + nxos_config: + diff_against: intended + intended: "{{ lookup('file', 'master.cfg') }}" - nxos_config: lines: @@ -189,7 +239,6 @@ vars: parents: ip access-list test before: no ip access-list test match: exact - provider: "{{ cli }}" - nxos_config: lines: @@ -200,15 +249,19 @@ vars: parents: ip access-list test before: no ip access-list test replace: block - provider: "{{ cli }}" """ RETURN = """ +commands: + description: The set of commands that will be pushed to the remote device + returned: always + type: list + sample: ['hostname foo', 'vlan 1', 'name default'] updates: description: The set of commands that will be pushed to the remote device - returned: Only when lines is specified. + returned: always type: list - sample: ['...', '...'] + sample: ['hostname foo', 'vlan 1', 'name default'] backup_path: description: The full path to the backup file returned: when backup is yes @@ -221,21 +274,17 @@ from ansible.module_utils.nxos import get_config, load_config, run_commands from ansible.module_utils.nxos import nxos_argument_spec from ansible.module_utils.nxos import check_args as nxos_check_args -def check_args(module, warnings): - nxos_check_args(module, warnings) - if module.params['force']: - warnings.append('The force argument is deprecated, please use ' - 'match=none instead. This argument will be ' - 'removed in the future') -def get_running_config(module): - contents = module.params['config'] +def get_running_config(module, config=None): + contents = module.params['running_config'] if not contents: - flags = [] - if module.params['defaults']: - flags.append('all') - contents = get_config(module, flags=flags) - return NetworkConfig(indent=2, contents=contents) + if not module.params['defaults'] and config: + contents = config + else: + flags = ['all'] + contents = get_config(module, flags=flags) + return NetworkConfig(indent=3, contents=contents) + def get_candidate(module): candidate = NetworkConfig(indent=2) @@ -246,36 +295,6 @@ def get_candidate(module): candidate.add(module.params['lines'], parents=parents) return candidate -def run(module, result): - match = module.params['match'] - replace = module.params['replace'] - - candidate = get_candidate(module) - - if match != 'none': - config = get_running_config(module) - path = module.params['parents'] - configobjs = candidate.difference(config, match=match, replace=replace, path=path) - else: - configobjs = candidate.items - - if configobjs: - commands = dumps(configobjs, 'commands').split('\n') - - if module.params['lines']: - if module.params['before']: - commands[:0] = module.params['before'] - - if module.params['after']: - commands.extend(module.params['after']) - - result['commands'] = commands - result['updates'] = commands - - if not module.check_mode: - load_config(module, commands) - - result['changed'] = True def main(): """ main entry point for module execution @@ -292,49 +311,140 @@ def main(): match=dict(default='line', choices=['line', 'strict', 'exact', 'none']), replace=dict(default='line', choices=['line', 'block']), - # this argument is deprecated in favor of setting match: none - # it will be removed in a future version - force=dict(default=False, type='bool'), + running_config=dict(aliases=['config']), + intended_config=dict(), - config=dict(), defaults=dict(type='bool', default=False), - backup=dict(type='bool', default=False), - save=dict(type='bool', default=False), + + save_when=dict(choices=['always', 'never', 'changed'], default='never'), + + diff_against=dict(choices=['running', 'startup', 'intended']), + diff_ignore_lines=dict(type='list'), + + # save is deprecated as of ans2.4, use save_when instead + save=dict(default=False, type='bool', removed_in_version='2.4'), + + # force argument deprecated in ans2.2 + force=dict(default=False, type='bool', removed_in_version='2.2') ) argument_spec.update(nxos_argument_spec) - mutually_exclusive = [('lines', 'src')] + mutually_exclusive = [('lines', 'src'), + ('save', 'save_when')] required_if = [('match', 'strict', ['lines']), ('match', 'exact', ['lines']), - ('replace', 'block', ['lines'])] + ('replace', 'block', ['lines']), + ('diff_against', 'intended', ['intended_config'])] module = AnsibleModule(argument_spec=argument_spec, mutually_exclusive=mutually_exclusive, required_if=required_if, supports_check_mode=True) - if module.params['force'] is True: - module.params['match'] = 'none' - warnings = list() - check_args(module, warnings) + nxos_check_args(module, warnings) - result = dict(changed=False, warnings=warnings) + result = {'changed': False, 'warnings': warnings} - if module.params['backup']: - result['__backup__'] = get_config(module) + config = None + + if module.params['backup'] or (module._diff and module.params['diff_against'] == 'running'): + contents = get_config(module) + config = NetworkConfig(indent=2, contents=contents) + if module.params['backup']: + result['__backup__'] = contents if any((module.params['src'], module.params['lines'])): - run(module, result) + match = module.params['match'] + replace = module.params['replace'] + + candidate = get_candidate(module) + + if match != 'none': + config = get_running_config(module, config) + path = module.params['parents'] + configobjs = candidate.difference(config, match=match, replace=replace, path=path) + else: + configobjs = candidate.items + + if configobjs: + commands = dumps(configobjs, 'commands').split('\n') + + if module.params['before']: + commands[:0] = module.params['before'] + + if module.params['after']: + commands.extend(module.params['after']) + + result['commands'] = commands + result['updates'] = commands + + if not module.check_mode: + load_config(module, commands) + + result['changed'] = True + + running_config = None + startup_config = None + + diff_ignore_lines = module.params['diff_ignore_lines'] + + if module.params['save_when'] != 'never': + output = run_commands(module, ['show running-config', 'startup-config']) + + 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) + + if running_config.sha1 != startup_config.sha1 or module.params['save_when'] == 'always': + result['changed'] = True + if not module.check_mode: + cmd = {'command': 'copy running-config startup-config', 'output': 'text'} + run_commands(module, [cmd]) + else: + module.warn('Skipping command `copy running-config startup-config` ' + 'due to check_mode. Configuration not copied to ' + 'non-volatile storage') + + if module._diff: + if not running_config: + output = run_commands(module, 'show running-config') + contents = output[0] + else: + contents = running_config.config_text + + # recreate the object in order to process diff_ignore_lines + running_config = NetworkConfig(indent=1, contents=config_text, ignore_lines=diff_ignore_lines) + + if module.params['diff_against'] == 'running': + if module.check_mode: + module.warn("unable to perform diff against running-config due to check mode") + contents = None + else: + contents = config.config_text + + elif module.params['diff_against'] == 'startup': + if not startup_config: + output = run_commands(module, 'show startup-config') + contents = output[0] + else: + contents = output[0] + contents = startup_config.config_text + + elif module.params['diff_against'] == 'intended': + contents = module.params['intended_config'] + + if contents is not None: + base_config = NetworkConfig(indent=1, contents=contents, ignore_lines=diff_ignore_lines) + + if running_config.sha1 != base_config.sha1: + result.update({ + 'changed': True, + 'diff': {'before': str(base_config), 'after': str(running_config)} + }) - if module.params['save']: - if not module.check_mode: - cmd = {'command': 'copy running-config startup-config', 'output': 'text'} - run_commands(module, [cmd]) - result['changed'] = True module.exit_json(**result) diff --git a/test/sanity/pep8/legacy-files.txt b/test/sanity/pep8/legacy-files.txt index f1b2aa1e2d..48169e7bb2 100644 --- a/test/sanity/pep8/legacy-files.txt +++ b/test/sanity/pep8/legacy-files.txt @@ -321,7 +321,6 @@ lib/ansible/modules/network/illumos/dladm_linkprop.py lib/ansible/modules/network/ios/_ios_template.py lib/ansible/modules/network/ios/ios_banner.py lib/ansible/modules/network/ios/ios_command.py -lib/ansible/modules/network/ios/ios_config.py lib/ansible/modules/network/ios/ios_facts.py lib/ansible/modules/network/ios/ios_system.py lib/ansible/modules/network/ios/ios_vrf.py diff --git a/test/units/modules/network/ios/test_ios_config.py b/test/units/modules/network/ios/test_ios_config.py index d77c035950..bf66212461 100644 --- a/test/units/modules/network/ios/test_ios_config.py +++ b/test/units/modules/network/ios/test_ios_config.py @@ -68,15 +68,15 @@ class TestIosConfigModule(TestIosModule): result = self.execute_module() self.assertIn('__backup__', result) - def test_ios_config_save(self): + def test_ios_config_save_always(self): self.run_commands.return_value = "Hostname foo" - set_module_args(dict(save=True)) + set_module_args(dict(save_when='always')) self.execute_module(changed=True) self.assertEqual(self.run_commands.call_count, 2) self.assertEqual(self.get_config.call_count, 0) self.assertEqual(self.load_config.call_count, 0) args = self.run_commands.call_args[0][1] - self.assertIn('copy running-config startup-config\r', args) + self.assertIn('copy running-config startup-config', args) def test_ios_config_lines_wo_parents(self): set_module_args(dict(lines=['hostname foo'])) @@ -117,9 +117,9 @@ class TestIosConfigModule(TestIosModule): commands = parents + lines self.execute_module(changed=True, commands=commands) - def test_ios_config_force(self): + def test_ios_config_match_none(self): lines = ['hostname router'] - set_module_args(dict(lines=lines, force=True)) + set_module_args(dict(lines=lines, match='none')) self.execute_module(changed=True, commands=lines) def test_ios_config_match_none(self):