From 6c89c587ccbcf83847f03288bbcd2d55ca85b57c Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Fri, 27 Jan 2017 16:23:18 -0500 Subject: [PATCH] refactors eos_config module to use network_cli (#20741) * update eos_config to use eapi exclusively and remove cli transport * add unit test cases for eos_config * updates action plugin to handle both eapi and network_cli connections --- lib/ansible/modules/network/eos/eos_config.py | 149 +++++++------ lib/ansible/plugins/action/eos_config.py | 14 +- lib/ansible/plugins/action/net_config.py | 23 +- .../eos/fixtures/eos_config_candidate.cfg | 7 + .../eos/fixtures/eos_config_config.cfg | 26 +++ .../modules/network/eos/test_eos_config.py | 202 ++++++++++++++++++ 6 files changed, 352 insertions(+), 69 deletions(-) create mode 100644 test/units/modules/network/eos/fixtures/eos_config_candidate.cfg create mode 100644 test/units/modules/network/eos/fixtures/eos_config_config.cfg create mode 100644 test/units/modules/network/eos/test_eos_config.py diff --git a/lib/ansible/modules/network/eos/eos_config.py b/lib/ansible/modules/network/eos/eos_config.py index 731b11efb9..50a2db33d2 100644 --- a/lib/ansible/modules/network/eos/eos_config.py +++ b/lib/ansible/modules/network/eos/eos_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 = """ --- @@ -32,7 +34,7 @@ description: 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 +extends_documentation_fragment: eapi options: lines: description: @@ -161,18 +163,8 @@ options: """ 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 - - eos_config: lines: hostname {{ inventory_hostname }} - provider: "{{ cli }}" - eos_config: lines: @@ -184,7 +176,6 @@ vars: parents: ip access-list test before: no ip access-list test match: exact - provider: "{{ cli }}" - eos_config: lines: @@ -195,39 +186,74 @@ vars: parents: ip access-list test before: no ip access-list test replace: block - provider: "{{ cli }}" - name: load configuration from file eos_config: src: eos.cfg - provider: "{{ cli }}" """ RETURN = """ -updates: +commands: description: The set of commands that will be pushed to the remote device returned: Only when lines is specified. type: list - sample: ['...', '...'] + sample: ['hostname switch01', 'interface Ethernet1', 'no shutdown'] backup_path: description: The full path to the backup file returned: when backup is yes type: path sample: /playbooks/ansible/backup/eos_config.2016-07-16@22:28:34 +start: + description: The time the job started + returned: always + type: str + sample: "2016-11-16 10:38:15.126146" +end: + description: The time the job ended + returned: always + type: str + sample: "2016-11-16 10:38:25.595612" +delta: + description: The time elapsed to perform all operations + returned: always + type: str + sample: "0:00:10.469466" """ -import time +from functools import partial +from ansible.module_utils import eos +from ansible.module_utils import eapi +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.local import LocalAnsibleModule from ansible.module_utils.netcfg import NetworkConfig, dumps -from ansible.module_utils.eos import NetworkModule, NetworkError -from ansible.module_utils.basic import get_exception + +SHARED_LIB = 'eos' + +def get_ansible_module(): + if SHARED_LIB == 'eos': + return LocalAnsibleModule + return AnsibleModule + +def invoke(name, *args, **kwargs): + obj = globals().get(SHARED_LIB) + func = getattr(obj, name) + return func(*args, **kwargs) + +run_commands = partial(invoke, 'run_commands') +get_config = partial(invoke, 'get_config') +load_config = partial(invoke, 'load_config') +supports_sessions = partial(invoke, 'supports_sessions') def check_args(module, warnings): + if SHARED_LIB == 'eapi': + eapi.check_args(module) + if module.params['force']: warnings.append('The force argument is deprecated, please use ' 'match=none instead. This argument will be ' 'removed in the future') - if not module.connection.supports_sessions(): + if not supports_sessions(module): warnings.append('The current version of EOS on the remote device does ' 'not support configuration sessions. The commit ' 'argument will be ignored') @@ -241,25 +267,6 @@ def get_candidate(module): candidate.add(module.params['lines'], parents=parents) return candidate -def get_config(module, defaults=False): - contents = module.params['config'] - if not contents: - defaults = module.params['defaults'] - contents = module.config.get_config(include_defaults=defaults) - return NetworkConfig(indent=3, contents=contents) - -def load_config(module, commands, result): - replace = module.params['replace'] == 'config' - commit = not module.check_mode - - diff = module.config.load_config(commands, replace=replace, commit=commit) - - if diff and module.connection.supports_sessions(): - result['diff'] = dict(prepared=diff) - result['changed'] = True - elif diff: - result['changed'] = True - def run(module, result): match = module.params['match'] replace = module.params['replace'] @@ -267,7 +274,8 @@ def run(module, result): candidate = get_candidate(module) if match != 'none' and replace != 'config': - config = get_config(module) + config_text = get_config(module) + config = NetworkConfig(indent=3, contents=config_text) configobjs = candidate.difference(config, match=match, replace=replace) else: configobjs = candidate.items @@ -282,14 +290,17 @@ def run(module, result): if module.params['after']: commands.extend(module.params['after']) - result['updates'] = commands + result['commands'] = commands - module.log('commands: %s' % commands) - load_config(module, commands, result) + 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'] - if module.params['save']: - if not module.check_mode: - module.config.save_config() result['changed'] = True def main(): @@ -307,17 +318,20 @@ def main(): match=dict(default='line', choices=['line', 'strict', 'exact', 'none']), replace=dict(default='line', choices=['line', 'block', 'config']), - # this argument is deprecated in favor of setting match: none - # it will be removed in a future version - force=dict(default=False, type='bool'), - - config=dict(), 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'), ) + argument_spec.update(eapi.eapi_argument_spec) + mutually_exclusive = [('lines', 'src')] required_if = [('match', 'strict', ['lines']), @@ -325,10 +339,12 @@ def main(): ('replace', 'block', ['lines']), ('replace', 'config', ['src'])] - module = NetworkModule(argument_spec=argument_spec, - mutually_exclusive=mutually_exclusive, - required_if=required_if, - supports_check_mode=True) + cls = get_ansible_module() + + module = cls(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' @@ -336,19 +352,24 @@ def main(): warnings = list() check_args(module, warnings) - result = dict(changed=False, warnings=warnings) + result = {'changed': False} + if warnings: + result['warnings'] = warnings if module.params['backup']: - result['__backup__'] = module.config.get_config() + result['__backup__'] = get_config(module) - try: + if any((module.params['src'], module.params['lines'])): run(module, result) - except NetworkError: - exc = get_exception() - module.fail_json(msg=str(exc), **exc.kwargs) + + if module.params['save']: + if not module.check_mode: + run_commands(module, ['copy running-config startup-config']) + result['changed'] = True module.exit_json(**result) if __name__ == '__main__': + SHARED_LIB = 'eapi' main() diff --git a/lib/ansible/plugins/action/eos_config.py b/lib/ansible/plugins/action/eos_config.py index ffcb0f057f..7cc09a67b9 100644 --- a/lib/ansible/plugins/action/eos_config.py +++ b/lib/ansible/plugins/action/eos_config.py @@ -19,10 +19,16 @@ 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 NetworkActionModule -class ActionModule(NetActionModule, ActionBase): - pass +try: + from __main__ import display +except ImportError: + from ansible.utils.display import Display + display = Display() +class ActionModule(NetworkActionModule): + def run(self, tmp=None, task_vars=None): + display.vvvvv('Using connection plugin %s' % self._play_context.connection) + return NetworkActionModule.run(self, tmp, task_vars) diff --git a/lib/ansible/plugins/action/net_config.py b/lib/ansible/plugins/action/net_config.py index 5436ae6e2d..93b8f700a0 100644 --- a/lib/ansible/plugins/action/net_config.py +++ b/lib/ansible/plugins/action/net_config.py @@ -27,6 +27,7 @@ import glob from ansible.plugins.action.network import ActionModule as _ActionModule from ansible.module_utils._text import to_text from ansible.module_utils.six.moves.urllib.parse import urlsplit +from ansible.utils.vars import merge_hash PRIVATE_KEYS_RE = re.compile('__.+__') @@ -42,13 +43,17 @@ class ActionModule(_ActionModule): except ValueError as exc: return dict(failed=True, msg=exc.message) - result = super(ActionModule, self).run(tmp, task_vars) + if self._play_context.connection == 'local': + result = self.normal(tmp, task_vars) + else: + result = super(ActionModule, self).run(tmp, task_vars) if self._task.args.get('backup') and result.get('__backup__'): # User requested backup and no error occurred in module. # NOTE: If there is a parameter error, _backup key may not be in results. filepath = self._write_backup(task_vars['inventory_hostname'], result['__backup__']) + result['backup_path'] = filepath # strip out any keys that have two leading and two trailing @@ -59,6 +64,22 @@ class ActionModule(_ActionModule): return result + def normal(self, tmp=None, task_vars=None): + if task_vars is None: + task_vars = dict() + + #results = super(ActionModule, self).run(tmp, task_vars) + # remove as modules might hide due to nolog + #del results['invocation']['module_args'] + + results = {} + results = merge_hash(results, self._execute_module(tmp=tmp, task_vars=task_vars)) + + # hack to keep --verbose from showing all the setup module results + if self._task.action == 'setup': + results['_ansible_verbose_override'] = True + + return results def _get_working_path(self): cwd = self._loader.get_basedir() if self._task._role is not None: diff --git a/test/units/modules/network/eos/fixtures/eos_config_candidate.cfg b/test/units/modules/network/eos/fixtures/eos_config_candidate.cfg new file mode 100644 index 0000000000..b11bc93e25 --- /dev/null +++ b/test/units/modules/network/eos/fixtures/eos_config_candidate.cfg @@ -0,0 +1,7 @@ +hostname switch01 +! +interface Ethernet1 + description test interface + no shutdown +! +ip routing diff --git a/test/units/modules/network/eos/fixtures/eos_config_config.cfg b/test/units/modules/network/eos/fixtures/eos_config_config.cfg new file mode 100644 index 0000000000..6a471371a5 --- /dev/null +++ b/test/units/modules/network/eos/fixtures/eos_config_config.cfg @@ -0,0 +1,26 @@ +! +hostname localhost +ip domain-name eng.ansible.com +! +vrf definition mgmt +! +vrf definition test +! +interface Management1 + ip address 192.168.1.1/24 +! +interface Ethernet1 + shutdown +! +interface Ethernet2 + shutdown +! +interface Ethernet3 + shutdown +! +interface Ethernet4 + shutdown +! +interface Ethernet5 + shutdown +! diff --git a/test/units/modules/network/eos/test_eos_config.py b/test/units/modules/network/eos/test_eos_config.py new file mode 100644 index 0000000000..be00148ec9 --- /dev/null +++ b/test/units/modules/network/eos/test_eos_config.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python +# +# (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 sys +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.eos import eos_config +from ansible.module_utils.netcfg import NetworkConfig +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes + +PROVIDER_ARGS = { + 'host': 'localhost', + 'username': 'username', + 'password': 'password' +} + +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 TestEosConfigModule(unittest.TestCase): + + def setUp(self): + self.mock_get_config = patch('ansible.modules.network.eos.eos_config.get_config') + self.get_config = self.mock_get_config.start() + + self.mock_load_config = patch('ansible.modules.network.eos.eos_config.load_config') + self.load_config = self.mock_load_config.start() + + self.mock_supports_sessions = patch('ansible.modules.network.eos.eos_config.supports_sessions') + self.supports_sessions = self.mock_supports_sessions.start() + self.supports_sessions.return_value = True + + def tearDown(self): + self.mock_get_config.stop() + self.mock_load_config.stop() + + def execute_module(self, failed=False, changed=False): + + self.get_config.return_value = load_fixture('eos_config_config.cfg') + self.load_config.return_value = dict(diff=None, session='session') + + with self.assertRaises(AnsibleModuleExit) as exc: + eos_config.main() + + result = exc.exception.result + + if failed: + self.assertTrue(result.get('failed')) + else: + self.assertEqual(result.get('changed'), changed, result) + + return result + + def test_eos_config_no_change(self): + args = dict(lines=['hostname localhost']) + args.update(PROVIDER_ARGS) + set_module_args(args) + result = self.execute_module() + + def test_eos_config_src(self): + args = dict(src=load_fixture('eos_config_candidate.cfg')) + args.update(PROVIDER_ARGS) + set_module_args(args) + + result = self.execute_module(changed=True) + config = ['hostname switch01', 'interface Ethernet1', + 'description test interface', 'no shutdown', 'ip routing'] + + self.assertEqual(sorted(config), sorted(result['commands']), result['commands']) + + def test_eos_config_lines(self): + args = dict(lines=['hostname switch01', 'ip domain-name eng.ansible.com']) + args.update(PROVIDER_ARGS) + set_module_args(args) + + result = self.execute_module(changed=True) + config = ['hostname switch01'] + + self.assertEqual(sorted(config), sorted(result['commands']), result['commands']) + + def test_eos_config_before(self): + args = dict(lines=['hostname switch01', 'ip domain-name eng.ansible.com'], + before=['before command']) + + args.update(PROVIDER_ARGS) + set_module_args(args) + + result = self.execute_module(changed=True) + config = ['before command', 'hostname switch01'] + + self.assertEqual(sorted(config), sorted(result['commands']), result['commands']) + self.assertEqual('before command', result['commands'][0]) + + def test_eos_config_after(self): + args = dict(lines=['hostname switch01', 'ip domain-name eng.ansible.com'], + after=['after command']) + + args.update(PROVIDER_ARGS) + set_module_args(args) + + result = self.execute_module(changed=True) + config = ['after command', 'hostname switch01'] + + self.assertEqual(sorted(config), sorted(result['commands']), result['commands']) + self.assertEqual('after command', result['commands'][-1]) + + def test_eos_config_parents(self): + args = dict(lines=['ip address 1.2.3.4/5', 'no shutdown'], parents=['interface Ethernet10']) + args.update(PROVIDER_ARGS) + set_module_args(args) + + result = self.execute_module(changed=True) + config = ['interface Ethernet10', 'ip address 1.2.3.4/5', 'no shutdown'] + + self.assertEqual(config, result['commands'], result['commands']) + + def test_eos_config_src_and_lines_fails(self): + args = dict(src='foo', lines='foo') + args.update(PROVIDER_ARGS) + set_module_args(args) + result = self.execute_module(failed=True) + + def test_eos_config_match_exact_requires_lines(self): + args = dict(match='exact') + args.update(PROVIDER_ARGS) + set_module_args(args) + result = self.execute_module(failed=True) + + def test_eos_config_match_strict_requires_lines(self): + args = dict(match='strict') + args.update(PROVIDER_ARGS) + set_module_args(args) + result = self.execute_module(failed=True) + + def test_eos_config_replace_block_requires_lines(self): + args = dict(replace='block') + args.update(PROVIDER_ARGS) + set_module_args(args) + result = self.execute_module(failed=True) + + def test_eos_config_replace_config_requires_src(self): + args = dict(replace='config') + args.update(PROVIDER_ARGS) + set_module_args(args) + result = self.execute_module(failed=True) + + def test_eos_config_backup_returns__backup__(self): + args = dict(backup=True) + args.update(PROVIDER_ARGS) + set_module_args(args) + result = self.execute_module() + self.assertIn('__backup__', result) + + +