From 6037e73c1691e89f9f432197ace65c8696639fcd Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Wed, 7 Feb 2018 21:12:15 -0500 Subject: [PATCH] Add EdgeOS config module (#35867) * Add edgeos_config module * Add edgeos_config module * Remove debugging statements * Add future import and correct module name in docs --- .../modules/network/edgeos/edgeos_config.py | 276 ++++++++++++++++++ .../edgeos/fixtures/edgeos_config_config.cfg | 10 + .../edgeos/fixtures/edgeos_config_src.cfg | 5 + .../fixtures/edgeos_config_src_brackets.cfg | 13 + .../network/edgeos/test_edgeos_config.py | 94 ++++++ 5 files changed, 398 insertions(+) create mode 100644 lib/ansible/modules/network/edgeos/edgeos_config.py create mode 100644 test/units/modules/network/edgeos/fixtures/edgeos_config_config.cfg create mode 100644 test/units/modules/network/edgeos/fixtures/edgeos_config_src.cfg create mode 100644 test/units/modules/network/edgeos/fixtures/edgeos_config_src_brackets.cfg create mode 100644 test/units/modules/network/edgeos/test_edgeos_config.py diff --git a/lib/ansible/modules/network/edgeos/edgeos_config.py b/lib/ansible/modules/network/edgeos/edgeos_config.py new file mode 100644 index 0000000000..ae124591f7 --- /dev/null +++ b/lib/ansible/modules/network/edgeos/edgeos_config.py @@ -0,0 +1,276 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2018 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'network'} + +DOCUMENTATION = """ +--- +module: edgeos_config +version_added: "2.5" +author: + - "Nathaniel Case (@qalthos)" + - "Sam Doran (@samdoran)" +short_description: Manage EdgeOS configuration on remote device +description: + - This module provides configuration file management of EdgeOS + devices. It provides arguments for managing both the + configuration file and state of the active configuration. All + configuration statements are based on `set` and `delete` commands + in the device configuration. +notes: + - Tested against EdgeOS 1.9.7 +options: + lines: + description: + - The ordered set of configuration lines to be managed and + compared with the existing configuration on the remote + device. + required: false + default: null + src: + description: + - The C(src) argument specifies the path to the source config + file to load. The source config file can either be in + bracket format or set format. The source file can include + Jinja2 template variables. + required: no + default: null + match: + description: + - The C(match) argument controls the method used to match + against the current active configuration. By default, the + desired config is matched against the active config and the + deltas are loaded. If the C(match) argument is set to C(none) + the active configuration is ignored and the configuration is + always loaded. + required: false + default: line + choices: ['line', 'none'] + backup: + description: + - The C(backup) argument will backup the current devices active + configuration to the Ansible control host prior to making any + changes. The backup file will be located in the backup folder + in the root of the playbook + required: false + default: false + choices: ['yes', 'no'] + comment: + description: + - Allows a commit description to be specified to be included + when the configuration is committed. If the configuration is + not changed or committed, this argument is ignored. + required: false + default: 'configured by edgeos_config' + config: + description: + - The C(config) argument specifies the base configuration to use + to compare against the desired configuration. If this value + is not specified, the module will automatically retrieve the + current active configuration from the remote device. + required: false + default: null + save: + description: + - The C(save) argument controls whether or not changes made + to the active configuration are saved to disk. This is + independent of committing the config. When set to C(True), the + active configuration is saved. + required: false + default: false + choices: ['yes', 'no'] +""" + +EXAMPLES = """ +- name: configure the remote device + edgeos_config: + lines: + - set system host-name {{ inventory_hostname }} + - set service lldp + - delete service dhcp-server + +- name: backup and load from file + edgeos_config: + src: edgeos.cfg + backup: yes +""" + +RETURN = """ +commands: + description: The list of configuration commands sent to the device + returned: always + type: list + sample: ['...', '...'] +filtered: + description: The list of configuration commands removed to avoid a load failure + returned: always + type: list + sample: ['...', '...'] +backup_path: + description: The full path to the backup file + returned: when backup is yes + type: string + sample: /playbooks/ansible/backup/edgeos_config.2016-07-16@22:28:34 +""" + +import re + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.network.common.config import NetworkConfig +from ansible.module_utils.network.edgeos.edgeos import load_config, get_config, run_commands + +DEFAULT_COMMENT = 'configured by edgeos_config' + +CONFIG_FILTERS = [ + re.compile(r'set system login user \S+ authentication encrypted-password') +] + + +def config_to_commands(config): + set_format = config.startswith('set') or config.startswith('delete') + candidate = NetworkConfig(indent=4, contents=config) + if not set_format: + candidate = [c.line for c in candidate.items] + commands = list() + # this filters out less specific lines + for item in candidate: + for index, entry in enumerate(commands): + if item.startswith(entry): + del commands[index] + break + commands.append(item) + + commands = ['set %s' % cmd.replace(' {', '') for cmd in commands] + + else: + commands = str(candidate).split('\n') + + return commands + + +def get_candidate(module): + contents = module.params['src'] or module.params['lines'] + + if module.params['lines']: + contents = '\n'.join(contents) + + return config_to_commands(contents) + + +def diff_config(commands, config): + config = [str(c).replace("'", '') for c in config.splitlines()] + + updates = list() + visited = set() + + for line in commands: + item = str(line).replace("'", '') + + if not item.startswith('set') and not item.startswith('delete'): + raise ValueError('line must start with either `set` or `delete`') + + elif item.startswith('set') and item not in config: + updates.append(line) + + elif item.startswith('delete'): + if not config: + updates.append(line) + else: + item = re.sub(r'delete', 'set', item) + for entry in config: + if entry.startswith(item) and line not in visited: + updates.append(line) + visited.add(line) + + return list(updates) + + +def sanitize_config(config, result): + result['filtered'] = list() + for regex in CONFIG_FILTERS: + for index, line in enumerate(list(config)): + if regex.search(line): + result['filtered'].append(line) + del config[index] + + +def run(module, result): + # get the current active config from the node or passed in via + # the config param + 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 + commands = diff_config(candidate, config) + sanitize_config(commands, result) + + result['commands'] = commands + + commit = not module.check_mode + comment = module.params['comment'] + + if commands: + load_config(module, commands, commit=commit, comment=comment) + + if result.get('filtered'): + result['warnings'].append('Some configuration commands were ' + 'removed, please see the filtered key') + + result['changed'] = True + + +def main(): + spec = dict( + src=dict(type='path'), + lines=dict(type='list'), + + match=dict(default='line', choices=['line', 'none']), + + comment=dict(default=DEFAULT_COMMENT), + + config=dict(), + + backup=dict(type='bool', default=False), + save=dict(type='bool', default=False), + ) + + mutually_exclusive = [('lines', 'src')] + + module = AnsibleModule( + argument_spec=spec, + mutually_exclusive=mutually_exclusive, + supports_check_mode=True + ) + + warnings = list() + + result = dict(changed=False, warnings=warnings) + + if module.params['backup']: + result['__backup__'] = get_config(module=module) + + if any((module.params['src'], module.params['lines'])): + run(module, result) + + if module.params['save']: + diff = run_commands(module, commands=['configure', 'compare saved'])[1] + if diff != '[edit]': + run_commands(module, commands=['save']) + result['changed'] = True + run_commands(module, commands=['exit']) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/edgeos/fixtures/edgeos_config_config.cfg b/test/units/modules/network/edgeos/fixtures/edgeos_config_config.cfg new file mode 100644 index 0000000000..ac11e35f7b --- /dev/null +++ b/test/units/modules/network/edgeos/fixtures/edgeos_config_config.cfg @@ -0,0 +1,10 @@ +set system host-name 'router' +set system domain-name 'acme.com' +set system domain-search domain 'acme.com' +set system name-server '208.67.220.220' +set system name-server '208.67.222.222' +set interfaces ethernet eth0 address '1.2.3.4/24' +set interfaces ethernet eth0 description 'Outside' +set interfaces ethernet eth1 address '10.77.88.1/24' +set interfaces ethernet eth1 description 'Inside' +set interfaces ethernet eth1 disable diff --git a/test/units/modules/network/edgeos/fixtures/edgeos_config_src.cfg b/test/units/modules/network/edgeos/fixtures/edgeos_config_src.cfg new file mode 100644 index 0000000000..f2b7fde4f4 --- /dev/null +++ b/test/units/modules/network/edgeos/fixtures/edgeos_config_src.cfg @@ -0,0 +1,5 @@ +set system host-name er01 +delete interfaces ethernet eth0 address +set interfaces ethernet eth1 address '10.77.88.1/24' +set interfaces ethernet eth1 description 'Inside' +set interfaces ethernet eth1 disable diff --git a/test/units/modules/network/edgeos/fixtures/edgeos_config_src_brackets.cfg b/test/units/modules/network/edgeos/fixtures/edgeos_config_src_brackets.cfg new file mode 100644 index 0000000000..e3c9d820e0 --- /dev/null +++ b/test/units/modules/network/edgeos/fixtures/edgeos_config_src_brackets.cfg @@ -0,0 +1,13 @@ +interfaces { + ethernet eth0 { + address 10.10.10.10/24 + } + ethernet eth1 { + address 10.77.88.1/24 + description Inside + disable + } +} +system { + host-name er01 +} diff --git a/test/units/modules/network/edgeos/test_edgeos_config.py b/test/units/modules/network/edgeos/test_edgeos_config.py new file mode 100644 index 0000000000..aec3f47dd7 --- /dev/null +++ b/test/units/modules/network/edgeos/test_edgeos_config.py @@ -0,0 +1,94 @@ +# +# (c) 2018 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 + +from ansible.compat.tests.mock import patch +from ansible.modules.network.edgeos import edgeos_config +from units.modules.utils import set_module_args +from .edgeos_module import TestEdgeosModule, load_fixture + + +class TestEdgeosConfigModule(TestEdgeosModule): + + module = edgeos_config + + def setUp(self): + super(TestEdgeosConfigModule, self).setUp() + + self.mock_get_config = patch('ansible.modules.network.edgeos.edgeos_config.get_config') + self.get_config = self.mock_get_config.start() + + self.mock_load_config = patch('ansible.modules.network.edgeos.edgeos_config.load_config') + self.load_config = self.mock_load_config.start() + + self.mock_run_commands = patch('ansible.modules.network.edgeos.edgeos_config.run_commands') + self.run_commands = self.mock_run_commands.start() + + def tearDown(self): + super(TestEdgeosConfigModule, self).tearDown() + + self.mock_get_config.stop() + self.mock_load_config.stop() + self.mock_run_commands.stop() + + def load_fixtures(self, commands=None): + config_file = 'edgeos_config_config.cfg' + self.get_config.return_value = load_fixture(config_file) + self.load_config.return_value = None + + def test_edgeos_config_unchanged(self): + src = load_fixture('edgeos_config_config.cfg') + set_module_args(dict(src=src)) + self.execute_module() + + def test_edgeos_config_src(self): + src = load_fixture('edgeos_config_src.cfg') + set_module_args(dict(src=src)) + commands = ['set system host-name er01', 'delete interfaces ethernet eth0 address'] + self.execute_module(changed=True, commands=commands) + + def test_edgeos_config_src_brackets(self): + src = load_fixture('edgeos_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 er01'] + self.execute_module(changed=True, commands=commands) + + def test_edgeos_config_backup(self): + set_module_args(dict(backup=True)) + result = self.execute_module() + self.assertIn('__backup__', result) + + def test_edgeos_config_lines(self): + commands = ['set system host-name er01'] + set_module_args(dict(lines=commands)) + self.execute_module(changed=True, commands=commands) + + def test_edgeos_config_config(self): + config = 'set system host-name localhost' + new_config = ['set system host-name er01'] + set_module_args(dict(lines=new_config, config=config)) + self.execute_module(changed=True, commands=new_config) + + def test_edgeos_config_match_none(self): + lines = ['set system interfaces ethernet eth0 address 1.2.3.4/24', + 'set system interfaces ethernet eth0 description Outside'] + set_module_args(dict(lines=lines, match='none')) + self.execute_module(changed=True, commands=lines, sort=False)