From d87fc9605fac22cc02a16aa358f9cf3dac0600a3 Mon Sep 17 00:00:00 2001 From: Nathaniel Case Date: Thu, 2 Feb 2017 13:12:26 -0500 Subject: [PATCH] vyos_config 2.3 (#20577) * Rename vyos2 over vyos * Update vyos_config to LocalAnsibleModule Change result key 'updates' -> 'commands' vyos_config is supported by core * vyos_config tests * Enable bracket config use * Sanitize config before use --- lib/ansible/module_utils/vyos.py | 120 ++++++-------- lib/ansible/module_utils/vyos2.py | 88 ---------- .../modules/network/vyos/vyos_command.py | 2 +- .../modules/network/vyos/vyos_config.py | 156 +++++++++--------- .../vyos/fixtures/vyos_config_config.cfg | 6 + .../network/vyos/fixtures/vyos_config_src.cfg | 5 + .../fixtures/vyos_config_src_brackets.cfg | 13 ++ .../modules/network/vyos/test_vyos_config.py | 148 +++++++++++++++++ 8 files changed, 296 insertions(+), 242 deletions(-) delete mode 100644 lib/ansible/module_utils/vyos2.py create mode 100644 test/units/modules/network/vyos/fixtures/vyos_config_config.cfg create mode 100644 test/units/modules/network/vyos/fixtures/vyos_config_src.cfg create mode 100644 test/units/modules/network/vyos/fixtures/vyos_config_src_brackets.cfg create mode 100644 test/units/modules/network/vyos/test_vyos_config.py diff --git a/lib/ansible/module_utils/vyos.py b/lib/ansible/module_utils/vyos.py index 90a21d4712..fb37ebeee4 100644 --- a/lib/ansible/module_utils/vyos.py +++ b/lib/ansible/module_utils/vyos.py @@ -4,7 +4,7 @@ # still belong to the author of the module, and may assign their own license # to the complete work. # -# Copyright (c) 2015 Peter Sprygada, +# (c) 2016 Red Hat Inc. # # Redistribution and use in source and binary forms, with or without modification, # are permitted provided that the following conditions are met: @@ -26,87 +26,63 @@ # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # -import re -import os +_DEVICE_CONFIGS = {} -from ansible.module_utils.network import NetworkModule, NetworkError -from ansible.module_utils.network import register_transport, to_list -from ansible.module_utils.shell import CliBase +def get_config(module, target='commands'): + cmd = ' '.join(['show configuration', target]) + try: + return _DEVICE_CONFIGS[cmd] + except KeyError: + rc, out, err = module.exec_command(cmd) + if rc != 0: + module.fail_json(msg='unable to retrieve current config', stderr=err) + cfg = str(out).strip() + _DEVICE_CONFIGS[cmd] = cfg + return cfg -class Cli(CliBase): +def run_commands(module, commands, check_rc=True): + responses = list() - CLI_PROMPTS_RE = [ - re.compile(r"[\r\n]?[\w+\-\.:\/\[\]]+(?:\([^\)]+\)){,3}(?:>|#) ?$"), - re.compile(r"\@[\w\-\.]+:\S+?[>#\$] ?$") - ] + for cmd in to_list(commands): + rc, out, err = module.exec_command(cmd) + if check_rc and rc != 0: + module.fail_json(msg=err, rc=rc) + responses.append(out) + return responses - CLI_ERRORS_RE = [ - re.compile(r"\n\s*Invalid command:"), - re.compile(r"\nCommit failed"), - re.compile(r"\n\s+Set failed"), - ] +def load_config(module, commands, commit=False, comment=None, save=False): + rc, out, err = module.exec_command('configure') + if rc != 0: + module.fail_json(msg='unable to enter configuration mode', output=err) - TERMINAL_LENGTH = os.getenv('ANSIBLE_VYOS_TERMINAL_LENGTH', 10000) - - - def connect(self, params, **kwargs): - super(Cli, self).connect(params, kickstart=False, **kwargs) - self.shell.send('set terminal length 0') - self.shell.send('set terminal length %s' % self.TERMINAL_LENGTH) - - - ### implementation of netcli.Cli ### - - def run_commands(self, commands): - commands = to_list(commands) - return self.execute([str(c) for c in commands]) - - ### implementation of netcfg.Config ### - - def configure(self, config): - commands = ['configure'] - commands.extend(config) - commands.extend(['commit', 'exit']) - response = self.execute(commands) - return response[1:-2] - - def load_config(self, config, commit=False, comment=None, save=False, **kwargs): - try: - config.insert(0, 'configure') - self.execute(config) - except NetworkError: + for cmd in to_list(commands): + rc, out, err = module.exec_command(cmd, check_rc=False) + if rc != 0: # discard any changes in case of failure - self.execute(['exit discard']) - raise + module.exec_command('exit discard') + module.fail_json(msg='configuration failed') - if not self.execute('compare')[0].startswith('No changes'): - diff = self.execute(['show'])[0] - else: - diff = None + diff = None + if module._diff: + rc, out, err = module.exec_command('compare') + if not out.startswith('No changes'): + rc, out, err = module.exec_command('show') + diff = str(out).strip() - if commit: - cmd = 'commit' - if comment: - cmd += ' comment "%s"' % comment - self.execute(cmd) + if commit: + cmd = 'commit' + if comment: + cmd += ' comment "%s"' % comment + module.exec_command(cmd) - if save: - self.execute(['save']) + if save: + module.exec_command(cmd) - if not commit: - self.execute(['exit discard']) - else: - self.execute(['exit']) + if not commit: + module.exec_command('exit discard') + else: + module.exec_command('exit') + if diff: return diff - - def get_config(self, output='text', **kwargs): - if output not in ['text', 'set']: - raise ValueError('invalid output format specified') - if output == 'set': - return self.execute(['show configuration commands'])[0] - else: - return self.execute(['show configuration'])[0] - -Cli = register_transport('cli', default=True)(Cli) diff --git a/lib/ansible/module_utils/vyos2.py b/lib/ansible/module_utils/vyos2.py deleted file mode 100644 index 63d772ad02..0000000000 --- a/lib/ansible/module_utils/vyos2.py +++ /dev/null @@ -1,88 +0,0 @@ -# This code is part of Ansible, but is an independent component. -# This particular file snippet, and this file snippet only, is BSD licensed. -# Modules you write using this snippet, which is embedded dynamically by Ansible -# still belong to the author of the module, and may assign their own license -# to the complete work. -# -# (c) 2016 Red Hat Inc. -# -# Redistribution and use in source and binary forms, with or without modification, -# are permitted provided that the following conditions are met: -# -# * Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. -# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, -# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE -# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# - -_DEVICE_CONFIGS = {} - -def get_config(module, target='commands'): - cmd = ' '.join(['show configuration', target]) - - try: - return _DEVICE_CONFIGS[cmd] - except KeyError: - rc, out, err = module.exec_command(cmd) - if rc != 0: - module.fail_json(msg='unable to retrieve current config', stderr=err) - cfg = str(out).strip() - _DEVICE_CONFIGS[cmd] = cfg - return cfg - -def run_commands(module, commands, check_rc=True): - assert isinstance(commands, list), 'commands must be a list' - responses = list() - - for cmd in commands: - rc, out, err = module.exec_command(cmd) - if check_rc and rc != 0: - module.fail_json(msg=err, rc=rc) - responses.append(out) - return responses - -def load_config(module, commands, commit=False, comment=None, save=False): - assert isinstance(commands, list), 'commands must be a list' - commands.insert(0, 'configure') - - for cmd in commands: - rc, out, err = module.exec_command(cmd, check_rc=False) - if rc != 0: - # discard any changes in case of failure - module.exec_command('exit discard') - module.fail_json(msg='configuration failed') - - diff = None - if module._diff: - rc, out, err = module.exec_command('compare') - if not out.startswith('No changes'): - rc, out, err = module.exec_command('show') - diff = str(out).strip() - - if commit: - cmd = 'commit' - if comment: - cmd += ' comment "%s"' % comment - module.exec_command(cmd) - - if save: - module.exec_command(cmd) - - if not commit: - module.exec_command('exit discard') - else: - module.exec_command('exit') - - if diff: - return diff diff --git a/lib/ansible/modules/network/vyos/vyos_command.py b/lib/ansible/modules/network/vyos/vyos_command.py index 5133f917f6..a13421a94e 100644 --- a/lib/ansible/modules/network/vyos/vyos_command.py +++ b/lib/ansible/modules/network/vyos/vyos_command.py @@ -152,7 +152,7 @@ from ansible.module_utils.local import LocalAnsibleModule from ansible.module_utils.netcli import Conditional from ansible.module_utils.network_common import ComplexList from ansible.module_utils.six import string_types -from ansible.module_utils.vyos2 import run_commands +from ansible.module_utils.vyos import run_commands VALID_KEYS = ['command', 'output', 'prompt', 'response'] diff --git a/lib/ansible/modules/network/vyos/vyos_config.py b/lib/ansible/modules/network/vyos/vyos_config.py index 8cf2c3d604..3a196549e8 100644 --- a/lib/ansible/modules/network/vyos/vyos_config.py +++ b/lib/ansible/modules/network/vyos/vyos_config.py @@ -16,15 +16,17 @@ # along with Ansible. If not, see . # -ANSIBLE_METADATA = {'status': ['preview'], - 'supported_by': 'community', - 'version': '1.0'} +ANSIBLE_METADATA = { + 'status': ['preview'], + 'supported_by': 'core', + 'version': '1.0', +} DOCUMENTATION = """ --- module: vyos_config version_added: "2.2" -author: "Peter Sprygada (@privateip)" +author: "Nathaniel Case (@qalthos)" short_description: Manage VyOS configuration on remote device description: - This module provides configuration file management of VyOS @@ -32,7 +34,6 @@ description: configuration file and state of the active configuration. All configuration statements are based on `set` and `delete` commands in the device configuration. -extends_documentation_fragment: vyos options: lines: description: @@ -95,8 +96,22 @@ options: choices: ['yes', 'no'] """ +EXAMPLES = """ +- name: configure the remote device + vyos_config: + lines: + - set system host-name {{ inventory_hostname }} + - set service lldp + - delete service dhcp-server + +- name: backup and load from file + vyos_config: + src: vyos.cfg + backup: yes +""" + RETURN = """ -updates: +commands: description: The list of configuration commands sent to the device returned: always type: list @@ -106,37 +121,27 @@ filtered: returned: always type: list sample: ['...', '...'] -""" - -EXAMPLES = """ -# Note: examples below use the following provider dict to handle -# transport and authentication to the node. -vars: - cli: - host: "{{ inventory_hostname }}" - username: vyos - password: vyos - transport: cli - -- name: configure the remote device - vyos_config: - lines: - - set system host-name {{ inventory_hostname }} - - set service lldp - - delete service dhcp-server - provider: "{{ cli }}" - -- name: backup and load from file - vyos_config: - src: vyos.cfg - backup: yes - provider: "{{ cli }}" +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 re -from ansible.module_utils.network import Command, get_exception -from ansible.module_utils.netcfg import NetworkConfig, dumps -from ansible.module_utils.vyos import NetworkModule, NetworkError +from ansible.module_utils.local import LocalAnsibleModule +from ansible.module_utils.netcfg import NetworkConfig +from ansible.module_utils.vyos import load_config, get_config, run_commands DEFAULT_COMMENT = 'configured by vyos_config' @@ -148,7 +153,7 @@ CONFIG_FILTERS = [ def config_to_commands(config): set_format = config.startswith('set') or config.startswith('delete') - candidate = NetworkConfig(indent=4, contents=config, device_os='junos') + candidate = NetworkConfig(indent=4, contents=config) if not set_format: candidate = [c.line for c in candidate.items] commands = list() @@ -160,19 +165,13 @@ def config_to_commands(config): break commands.append(item) + commands = ['set %s' % cmd.replace(' {', '') for cmd in commands] + else: commands = str(candidate).split('\n') return commands -def get_config(module, result): - contents = module.params['config'] - if not contents: - contents = module.config.get_config(output='set').split('\n') - else: - contents = config_to_commands(contents) - - return contents def get_candidate(module): contents = module.params['src'] or module.params['lines'] @@ -182,8 +181,9 @@ def get_candidate(module): return config_to_commands(contents) + def diff_config(commands, config): - config = [str(c).replace("'", '') for c in config] + config = [str(c).replace("'", '') for c in config.splitlines()] updates = list() visited = set() @@ -209,6 +209,7 @@ def diff_config(commands, config): return list(updates) + def sanitize_config(config, result): result['filtered'] = list() for regex in CONFIG_FILTERS: @@ -217,46 +218,36 @@ def sanitize_config(config, result): result['filtered'].append(line) del config[index] -def load_config(module, commands, result): - comment = module.params['comment'] - commit = not module.check_mode - save = module.params['save'] - - # sanitize loadable config to remove items that will fail - # remove items will be returned in the sanitized keyword - # in the result. - sanitize_config(commands, result) - - diff = module.config.load_config(commands, commit=commit, comment=comment, - save=save) - - if diff: - result['diff'] = dict(prepared=diff) - result['changed'] = True - def run(module, result): # get the current active config from the node or passed in via # the config param - config = get_config(module, result) + config = module.params['config'] or get_config(module) # create the candidate config object from the arguments candidate = get_candidate(module) # create loadable config that includes only the configuration updates - updates = diff_config(candidate, config) + commands = diff_config(candidate, config) + sanitize_config(commands, result) - result['updates'] = updates + result['commands'] = commands - load_config(module, updates, result) + commit = not module.check_mode + comment = module.params['comment'] + save = module.params['save'] - if result.get('filtered'): - result['warnings'].append('Some configuration commands where ' - 'removed, please see the filtered key') + if commands: + load_config(module, commands, commit=commit, comment=comment, save=save) + + if result.get('filtered'): + result['warnings'].append('Some configuration commands were ' + 'removed, please see the filtered key') + + result['changed'] = True def main(): - argument_spec = dict( src=dict(type='path'), lines=dict(type='list'), @@ -267,27 +258,30 @@ def main(): config=dict(), - backup=dict(default=False, type='bool'), - save=dict(default=False, type='bool'), + backup=dict(type='bool', default=False), + save=dict(type='bool', default=False), ) mutually_exclusive = [('lines', 'src')] - module = NetworkModule(argument_spec=argument_spec, - connect_on_load=False, - mutually_exclusive=mutually_exclusive, - supports_check_mode=True) + module = LocalAnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=mutually_exclusive, + supports_check_mode=True + ) - result = dict(changed=False) + result = dict(changed=False, warnings=[]) if module.params['backup']: - result['__backup__'] = module.config.get_config() + result['__backup__'] = get_config(module=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, ['save']) + result['changed'] = True module.exit_json(**result) diff --git a/test/units/modules/network/vyos/fixtures/vyos_config_config.cfg b/test/units/modules/network/vyos/fixtures/vyos_config_config.cfg new file mode 100644 index 0000000000..e6d642c36c --- /dev/null +++ b/test/units/modules/network/vyos/fixtures/vyos_config_config.cfg @@ -0,0 +1,6 @@ +set system host-name router +set interfaces ethernet eth0 address '1.2.3.4/24' +set interfaces ethernet eth0 description 'test string' +set interfaces ethernet eth1 address '6.7.8.9/24' +set interfaces ethernet eth1 description 'test string' +set interfaces ethernet eth1 disable diff --git a/test/units/modules/network/vyos/fixtures/vyos_config_src.cfg b/test/units/modules/network/vyos/fixtures/vyos_config_src.cfg new file mode 100644 index 0000000000..ca6abebc9a --- /dev/null +++ b/test/units/modules/network/vyos/fixtures/vyos_config_src.cfg @@ -0,0 +1,5 @@ +set system host-name foo +delete interfaces ethernet eth0 address +set interfaces ethernet eth1 address '6.7.8.9/24' +set interfaces ethernet eth1 description 'test string' +set interfaces ethernet eth1 disable diff --git a/test/units/modules/network/vyos/fixtures/vyos_config_src_brackets.cfg b/test/units/modules/network/vyos/fixtures/vyos_config_src_brackets.cfg new file mode 100644 index 0000000000..468b32c268 --- /dev/null +++ b/test/units/modules/network/vyos/fixtures/vyos_config_src_brackets.cfg @@ -0,0 +1,13 @@ +interfaces { + ethernet eth0 { + address 10.10.10.10/24 + } + ethernet eth1 { + address 6.7.8.9/24 + description test string + disable + } +} +system { + host-name foo +} diff --git a/test/units/modules/network/vyos/test_vyos_config.py b/test/units/modules/network/vyos/test_vyos_config.py new file mode 100644 index 0000000000..4d876a44f7 --- /dev/null +++ b/test/units/modules/network/vyos/test_vyos_config.py @@ -0,0 +1,148 @@ +# +# (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.vyos import vyos_config +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes + + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +def set_module_args(args): + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +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 TestVyosConfigModule(unittest.TestCase): + + def setUp(self): + self.mock_get_config = patch('ansible.modules.network.vyos.vyos_config.get_config') + self.get_config = self.mock_get_config.start() + + self.mock_load_config = patch('ansible.modules.network.vyos.vyos_config.load_config') + self.load_config = self.mock_load_config.start() + + self.mock_run_commands = patch('ansible.modules.network.vyos.vyos_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 = 'vyos_config_defaults.cfg' if defaults else 'vyos_config_config.cfg' + self.get_config.return_value = load_fixture(config_file) + self.load_config.return_value = None + + with self.assertRaises(AnsibleModuleExit) as exc: + vyos_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['commands']), result['commands']) + else: + self.assertEqual(commands, result['commands'], result['commands']) + + return result + + def test_vyos_config_unchanged(self): + src = load_fixture('vyos_config_config.cfg') + set_module_args(dict(src=src)) + self.execute_module() + + def test_vyos_config_src(self): + src = load_fixture('vyos_config_src.cfg') + set_module_args(dict(src=src)) + commands = ['set system host-name foo', 'delete interfaces ethernet eth0 address'] + self.execute_module(changed=True, commands=commands) + + def test_vyos_config_src_brackets(self): + src = load_fixture('vyos_config_src_brackets.cfg') + set_module_args(dict(src=src)) + commands = ['set interfaces ethernet eth0 address 10.10.10.10/24', 'set system host-name foo'] + self.execute_module(changed=True, commands=commands) + + def test_vyos_config_backup(self): + set_module_args(dict(backup=True)) + result = self.execute_module() + self.assertIn('__backup__', result) + + def test_vyos_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('save', args) + + def test_vyos_config_lines(self): + commands = ['set system host-name foo'] + set_module_args(dict(lines=commands)) + self.execute_module(changed=True, commands=commands) + + def test_vyos_config_config(self): + config = 'set system host-name localhost' + new_config = ['set system host-name router'] + set_module_args(dict(lines=new_config, config=config)) + self.execute_module(changed=True, commands=new_config) + + def test_vyos_config_match_none(self): + lines = ['set system interfaces ethernet eth0 address 1.2.3.4/24', + 'set system interfaces ethernet eth0 description test string'] + set_module_args(dict(lines=lines, match='none')) + self.execute_module(changed=True, commands=lines, sort=False)