From 258c6ada520e72b3040ad9729c8b95f11d5edc22 Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Mon, 9 Jan 2017 11:19:25 -0500 Subject: [PATCH] refactors ios_config to use network_cli plugin (#20042) * updates the ios_config module to use the network_cli plugin * updates the local action plugin to derive from network * add unit test cases for ios_config --- lib/ansible/modules/network/ios/ios_config.py | 178 ++++++++-------- lib/ansible/plugins/action/ios_config.py | 5 +- .../ios/fixtures/ios_config_config.cfg | 12 ++ .../ios/fixtures/ios_config_defaults.cfg | 13 ++ .../network/ios/fixtures/ios_config_src.cfg | 11 + .../modules/network/ios/test_ios_config.py | 197 ++++++++++++++++++ 6 files changed, 319 insertions(+), 97 deletions(-) create mode 100644 test/units/modules/network/ios/fixtures/ios_config_config.cfg create mode 100644 test/units/modules/network/ios/fixtures/ios_config_defaults.cfg create mode 100644 test/units/modules/network/ios/fixtures/ios_config_src.cfg create mode 100644 test/units/modules/network/ios/test_ios_config.py diff --git a/lib/ansible/modules/network/ios/ios_config.py b/lib/ansible/modules/network/ios/ios_config.py index 3467f5ef45..ed4a2d8ed8 100644 --- a/lib/ansible/modules/network/ios/ios_config.py +++ b/lib/ansible/modules/network/ios/ios_config.py @@ -16,9 +16,11 @@ # along with Ansible. If not, see . # -ANSIBLE_METADATA = {'status': ['preview'], - 'supported_by': 'core', - 'version': '1.0'} +ANSIBLE_METADATA = { + 'status': ['preview'], + 'supported_by': 'core', + 'version': '1.0' +} DOCUMENTATION = """ --- @@ -165,19 +167,9 @@ options: """ EXAMPLES = """ -# Note: examples below use the following provider dict to handle -# transport and authentication to the node. -vars: - cli: - host: "{{ inventory_hostname }}" - username: cisco - password: cisco - transport: cli - - name: configure top level configuration ios_config: lines: hostname {{ inventory_hostname }} - provider: "{{ cli }}" - name: configure interface settings ios_config: @@ -185,7 +177,6 @@ vars: - description test interface - ip address 172.31.1.1 255.255.255.0 parents: interface Ethernet1 - provider: "{{ cli }}" - name: load new acl into device ios_config: @@ -198,8 +189,6 @@ vars: parents: ip access-list extended test before: no ip access-list extended test match: exact - provider: "{{ cli }}" - """ RETURN = """ @@ -217,11 +206,11 @@ backup_path: import re import time -from ansible.module_utils.basic import get_exception -from ansible.module_utils.six import iteritems -from ansible.module_utils.ios import NetworkModule, NetworkError +from ansible.module_utils.local import LocalAnsibleModule +from ansible.module_utils.ios import load_config, get_config, run_commands from ansible.module_utils.netcfg import NetworkConfig, dumps -from ansible.module_utils.netcli import Command +from ansible.module_utils.six import iteritems +from ansible.module_utils.network import NET_TRANSPORT_ARGS, _transitional_argument_spec def check_args(module, warnings): @@ -230,9 +219,17 @@ def check_args(module, warnings): module.fail_json(msg='multiline_delimiter value can only be a ' 'single character') if module.params['force']: - warnings.append('The force argument is deprecated, please use ' - 'match=none instead. This argument will be ' - 'removed in the future') + 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') + + for key in NET_TRANSPORT_ARGS: + if module.params[key]: + warnings.append( + 'network provider arguments are no longer supported. Please ' + 'use connection: network_cli for the task' + ) + break def extract_banners(config): banners = {} @@ -265,17 +262,18 @@ def load_banners(module, banners): for key, value in iteritems(banners): key += ' %s' % delimiter for cmd in ['config terminal', key, value, delimiter, 'end']: - cmd += '\r' - module.connection.shell.shell.sendall(cmd) - time.sleep(1) - module.connection.shell.receive() + obj = {'command': cmd, 'sendonly': True} + run_commands(module, [cmd]) + time.sleep(0.1) + run_commands(module, ['\n']) -def get_config(module, result): +def get_running_config(module): contents = module.params['config'] if not contents: - defaults = module.params['defaults'] - contents = module.config.get_config(include_defaults=defaults) - + flags = [] + if module.params['defaults']: + flags.append('all') + contents = get_config(module, flags=flags) contents, banners = extract_banners(contents) return NetworkConfig(indent=1, contents=contents), banners @@ -293,56 +291,9 @@ def get_candidate(module): return candidate, banners -def run(module, result): - match = module.params['match'] - replace = module.params['replace'] - path = module.params['parents'] - - candidate, want_banners = get_candidate(module) - - if match != 'none': - config, have_banners = get_config(module, result) - path = module.params['parents'] - configobjs = candidate.difference(config, path=path,match=match, - replace=replace) - else: - configobjs = candidate.items - have_banners = {} - - banners = diff_banners(want_banners, have_banners) - - 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['after']: - commands.extend(module.params['after']) - - result['updates'] = commands - result['banners'] = banners - - # send the configuration commands to the device and merge - # them with the current running config - if not module.check_mode: - if commands: - module.config(commands) - if banners: - load_banners(module, banners) - - result['changed'] = True - - if module.params['save']: - if not module.check_mode: - module.config.save_config() - result['changed'] = True - def main(): """ main entry point for module execution """ - argument_spec = dict( src=dict(type='path'), @@ -356,7 +307,7 @@ def main(): replace=dict(default='line', choices=['line', 'block']), multiline_delimiter=dict(default='@'), - # this argument is deprecated in favor of setting match: none + # 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'), @@ -364,20 +315,21 @@ def main(): defaults=dict(type='bool', default=False), backup=dict(type='bool', default=False), - save=dict(default=False, type='bool'), + save=dict(type='bool', default=False), ) + argument_spec.update(_transitional_argument_spec()) + mutually_exclusive = [('lines', 'src')] required_if = [('match', 'strict', ['lines']), ('match', 'exact', ['lines']), ('replace', 'block', ['lines'])] - module = NetworkModule(argument_spec=argument_spec, - connect_on_load=False, - mutually_exclusive=mutually_exclusive, - required_if=required_if, - supports_check_mode=True) + module = LocalAnsibleModule(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' @@ -385,19 +337,57 @@ def main(): warnings = list() check_args(module, warnings) - result = dict(changed=False, warnings=warnings) + result = {'changed': False, 'warnings': warnings} + + if any((module.params['lines'], module.params['src'])): + match = module.params['match'] + replace = module.params['replace'] + path = module.params['parents'] + + candidate, want_banners = get_candidate(module) + + if match != 'none': + config, have_banners = get_running_config(module) + path = module.params['parents'] + configobjs = candidate.difference(config, path=path,match=match, + replace=replace) + else: + configobjs = candidate.items + have_banners = {} + + banners = diff_banners(want_banners, have_banners) + + 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['after']: + commands.extend(module.params['after']) + + result['updates'] = commands + result['banners'] = banners + + # 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) + + result['changed'] = True if module.params['backup']: - result['__backup__'] = module.config.get_config() + result['__backup__'] = get_config() - try: - run(module, result) - except NetworkError: - exc = get_exception() - module.disconnect() - module.fail_json(msg=str(exc)) + if module.params['save']: + if not module.check_mode: + run_commands(module, ['copy running-config startup-config']) + result['changed'] = True - module.disconnect() module.exit_json(**result) diff --git a/lib/ansible/plugins/action/ios_config.py b/lib/ansible/plugins/action/ios_config.py index ffcb0f057f..4b2687f5b3 100644 --- a/lib/ansible/plugins/action/ios_config.py +++ b/lib/ansible/plugins/action/ios_config.py @@ -19,10 +19,9 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type -from ansible.plugins.action import ActionBase -from ansible.plugins.action.net_config import ActionModule as NetActionModule +from ansible.plugins.action.net_config import ActionModule as _ActionModule -class ActionModule(NetActionModule, ActionBase): +class ActionModule(_ActionModule): pass diff --git a/test/units/modules/network/ios/fixtures/ios_config_config.cfg b/test/units/modules/network/ios/fixtures/ios_config_config.cfg new file mode 100644 index 0000000000..afad9d08aa --- /dev/null +++ b/test/units/modules/network/ios/fixtures/ios_config_config.cfg @@ -0,0 +1,12 @@ +! +hostname router +! +interface GigabitEthernet0/0 + ip address 1.2.3.4 255.255.255.0 + description test string +! +interface GigabitEthernet0/1 + ip address 6.7.8.9 255.255.255.0 + description test string + shutdown +! diff --git a/test/units/modules/network/ios/fixtures/ios_config_defaults.cfg b/test/units/modules/network/ios/fixtures/ios_config_defaults.cfg new file mode 100644 index 0000000000..e54645ab14 --- /dev/null +++ b/test/units/modules/network/ios/fixtures/ios_config_defaults.cfg @@ -0,0 +1,13 @@ +! +hostname router +! +interface GigabitEthernet0/0 + ip address 1.2.3.4 255.255.255.0 + description test string + no shutdown +! +interface GigabitEthernet0/1 + ip address 6.7.8.9 255.255.255.0 + description test string + shutdown +! diff --git a/test/units/modules/network/ios/fixtures/ios_config_src.cfg b/test/units/modules/network/ios/fixtures/ios_config_src.cfg new file mode 100644 index 0000000000..b3d8961a99 --- /dev/null +++ b/test/units/modules/network/ios/fixtures/ios_config_src.cfg @@ -0,0 +1,11 @@ +! +hostname foo +! +interface GigabitEthernet0/0 + no ip address +! +interface GigabitEthernet0/1 + ip address 6.7.8.9 255.255.255.0 + description test string + shutdown +! diff --git a/test/units/modules/network/ios/test_ios_config.py b/test/units/modules/network/ios/test_ios_config.py new file mode 100644 index 0000000000..efee3be71d --- /dev/null +++ b/test/units/modules/network/ios/test_ios_config.py @@ -0,0 +1,197 @@ +# +# (c) 2016 Red Hat Inc. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os +import json + +from ansible.compat.tests import unittest +from ansible.compat.tests.mock import patch, MagicMock +from ansible.errors import AnsibleModuleExit +from ansible.modules.network.ios import ios_config +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes + + +def set_module_args(args): + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + +def load_fixture(name): + path = os.path.join(fixture_path, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as f: + data = f.read() + + try: + data = json.loads(data) + except: + pass + + fixture_data[path] = data + return data + + +class TestIosConfigModule(unittest.TestCase): + + def setUp(self): + 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_run_commands = patch('ansible.modules.network.ios.ios_config.run_commands') + self.run_commands = self.mock_run_commands.start() + + def tearDown(self): + self.mock_get_config.stop() + self.mock_load_config.stop() + self.mock_run_commands.stop() + + def execute_module(self, failed=False, changed=False, commands=None, + sort=True, defaults=False): + + config_file = 'ios_config_defaults.cfg' if defaults else 'ios_config_config.cfg' + self.get_config.return_value = load_fixture(config_file) + self.load_config.return_value = None + + with self.assertRaises(AnsibleModuleExit) as exc: + ios_config.main() + + result = exc.exception.result + + if failed: + self.assertTrue(result['failed'], result) + else: + self.assertEqual(result.get('changed'), changed, result) + + if commands: + if sort: + self.assertEqual(sorted(commands), sorted(result['updates']), result['updates']) + else: + self.assertEqual(commands, result['updates'], result['updates']) + + return result + + def test_ios_config_unchanged(self): + src = load_fixture('ios_config_config.cfg') + 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)) + commands = ['hostname foo', 'interface GigabitEthernet0/0', + 'no ip address'] + self.execute_module(changed=True, commands=commands) + + def test_ios_config_backup(self): + set_module_args(dict(backup=True)) + result = self.execute_module() + self.assertIn('__backup__', result) + + def test_ios_config_save(self): + set_module_args(dict(save=True)) + 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) + args = self.run_commands.call_args[0][1] + self.assertIn('copy running-config startup-config', args) + + def test_ios_config_lines_wo_parents(self): + set_module_args(dict(lines=['hostname foo'])) + 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'])) + commands = ['interface GigabitEthernet0/0', 'shutdown'] + self.execute_module(changed=True, commands=commands) + + def test_ios_config_defaults(self): + set_module_args(dict(lines=['no shutdown'], parents=['interface GigabitEthernet0/0'], + defaults=True)) + self.execute_module(defaults=True) + + def test_ios_config_before(self): + set_module_args(dict(lines=['hostname foo'], before=['test1','test2'])) + 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'])) + commands = ['hostname foo', 'test1', 'test2'] + self.execute_module(changed=True, commands=commands, sort=False) + + def test_ios_config_before_after_no_chnage(self): + set_module_args(dict(lines=['hostname router'], + before=['test1', 'test2'], + after=['test3','test4'])) + self.execute_module() + + def test_ios_config_config(self): + config = 'hostname localhost' + set_module_args(dict(lines=['hostname router'], config=config)) + commands = ['hostname router'] + self.execute_module(changed=True, commands=commands) + + def test_ios_config_replace_block(self): + lines = ['description test string', 'test string'] + parents = ['interface GigabitEthernet0/0'] + set_module_args(dict(lines=lines, replace='block', parents=parents)) + commands = parents + lines + self.execute_module(changed=True, commands=commands) + + def test_ios_config_force(self): + lines = ['hostname router'] + set_module_args(dict(lines=lines, force=True)) + 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')) + commands = parents + lines + self.execute_module(changed=True, commands=commands, sort=False) + + def test_ios_config_match_strict(self): + lines = ['ip address 1.2.3.4 255.255.255.0', 'description test string', + 'shutdown'] + parents = ['interface GigabitEthernet0/0'] + set_module_args(dict(lines=lines, parents=parents, match='strict')) + commands = parents + ['shutdown'] + self.execute_module(changed=True, commands=commands, sort=False) + + def test_ios_config_match_exact(self): + lines = ['ip address 1.2.3.4 255.255.255.0', 'description test string', + 'shutdown'] + parents = ['interface GigabitEthernet0/0'] + set_module_args(dict(lines=lines, parents=parents, match='exact')) + commands = parents + lines + self.execute_module(changed=True, commands=commands, sort=False)