1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2024-09-14 20:13:21 +02:00

vyos and ios cliconf plugin refactor (#41846)

* vyos and ios cliconf plugin refactor

*  Refactor vyos cliconf plugin
*  Change vyos module_utils and vyos_config as per refactor
*  Minor changes in ios cliconf plugin

* Fix unit test failure

* Fix sanity issues

* Add get_diff to rpc list
This commit is contained in:
Ganesh Nalawade 2018-06-25 09:43:37 +05:30 committed by GitHub
parent 9c5d40ff15
commit 773c031d33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 285 additions and 101 deletions

View file

@ -166,6 +166,7 @@ def load_config(module, commands):
connection = get_connection(module)
try:
return connection.edit_config(commands)
diff, response = connection.edit_config(commands)
return response
except ConnectionError as exc:
module.fail_json(msg=to_text(exc))

View file

@ -133,29 +133,8 @@ def load_config(module, commands, commit=False, comment=None):
connection = get_connection(module)
try:
out = connection.edit_config(commands)
diff_config, resp = connection.edit_config(candidate=commands, commit=commit, diff=module._diff, comment=comment)
except ConnectionError as exc:
module.fail_json(msg=to_text(exc))
diff = None
if module._diff:
out = connection.get('compare')
out = to_text(out, errors='surrogate_or_strict')
if not out.startswith('No changes'):
out = connection.get('show')
diff = to_text(out, errors='surrogate_or_strict').strip()
if commit:
try:
out = connection.commit(comment)
except ConnectionError:
connection.discard_changes()
module.fail_json(msg='commit failed: %s' % out)
else:
connection.get('exit')
else:
connection.discard_changes()
if diff:
return diff
return diff_config

View file

@ -441,9 +441,9 @@ def main():
# them with the current running config
if not module.check_mode:
if commands:
connection.edit_config(commands)
connection.edit_config(candidate=commands)
if banner_diff:
connection.edit_banner(json.dumps(banner_diff), multiline_delimiter=module.params['multiline_delimiter'])
connection.edit_banner(candidate=json.dumps(banner_diff), multiline_delimiter=module.params['multiline_delimiter'])
result['changed'] = True

View file

@ -130,11 +130,11 @@ backup_path:
sample: /playbooks/ansible/backup/vyos_config.2016-07-16@22:28:34
"""
import re
import json
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.network.common.config import NetworkConfig
from ansible.module_utils.network.vyos.vyos import load_config, get_config, run_commands
from ansible.module_utils.network.vyos.vyos import vyos_argument_spec
from ansible.module_utils.network.vyos.vyos import vyos_argument_spec, get_connection
DEFAULT_COMMENT = 'configured by vyos_config'
@ -144,35 +144,13 @@ CONFIG_FILTERS = [
]
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)
return contents
def diff_config(commands, config):
@ -225,7 +203,10 @@ def run(module, result):
candidate = get_candidate(module)
# create loadable config that includes only the configuration updates
commands = diff_config(candidate, config)
connection = get_connection(module)
response = connection.get_diff(candidate=candidate, running=config, match=module.params['match'])
diff_obj = json.loads(response)
commands = diff_obj.get('config_diff')
sanitize_config(commands, result)
result['commands'] = commands
@ -233,8 +214,9 @@ def run(module, result):
commit = not module.check_mode
comment = module.params['comment']
diff = None
if commands:
load_config(module, commands, commit=commit, comment=comment)
diff = load_config(module, commands, commit=commit, comment=comment)
if result.get('filtered'):
result['warnings'].append('Some configuration commands were '
@ -242,6 +224,9 @@ def run(module, result):
result['changed'] = True
if module._diff:
result['diff'] = diff
def main():
argument_spec = dict(

View file

@ -93,7 +93,7 @@ class CliconfBase(with_metaclass(ABCMeta, object)):
display.display('closing shell due to command timeout (%s seconds).' % self._connection._play_context.timeout, log_only=True)
self.close()
def send_command(self, command, prompt=None, answer=None, sendonly=False, newline=True, prompt_retry_check=False):
def send_command(self, command=None, prompt=None, answer=None, sendonly=False, newline=True, prompt_retry_check=False):
"""Executes a command over the device connection
This method will execute a command over the device connection and
@ -184,7 +184,7 @@ class CliconfBase(with_metaclass(ABCMeta, object)):
pass
@abstractmethod
def edit_config(self, candidate, check_mode=False, replace=None):
def edit_config(self, candidate=None, commit=True, replace=False, diff=False, comment=None):
"""Loads the candidate configuration into the network device
This method will load the specified candidate config into the device
@ -195,20 +195,22 @@ class CliconfBase(with_metaclass(ABCMeta, object)):
:param candidate: The configuration to load into the device and merge
with the current running configuration
:param check_mode: Boolean value that indicates if the device candidate
:param commit: Boolean value that indicates if the device candidate
configuration should be pushed in the running configuration or discarded.
:param replace: Specifies the way in which provided config value should replace
the configuration running on the remote device. If the device
doesn't support config replace, an error is return.
:return: Returns response of executing the configuration command received
from remote host
:param replace: Boolean flag to indicate if running configuration should be completely
replace by candidate configuration.
:param diff: Boolean flag to indicate if configuration that is applied on remote host should
generated and returned in response or not
:param comment: Commit comment provided it is supported by remote host
:return: Returns a tuple, the first entry of tupe is configuration diff if diff flag is enable else
it is None. Second entry is the list of response received from remote host on executing
configuration commands.
"""
pass
@abstractmethod
def get(self, command, prompt=None, answer=None, sendonly=False, newline=True):
def get(self, command=None, prompt=None, answer=None, sendonly=False, newline=True):
"""Execute specified command on remote device
This method will retrieve the specified data and
return it to the caller as a string.
@ -242,7 +244,7 @@ class CliconfBase(with_metaclass(ABCMeta, object)):
'network_os_platform': <str>,
},
'device_operations': {
'supports_replace': <bool>, # identify if config should be merged or replaced is supported
'supports_diff_replace': <bool>, # identify if config should be merged or replaced is supported
'supports_commit': <bool>, # identify if commit is supported by device or not
'supports_rollback': <bool>, # identify if rollback is supported or not
'supports_defaults': <bool>, # identify if fetching running config with default is supported
@ -250,12 +252,13 @@ class CliconfBase(with_metaclass(ABCMeta, object)):
'supports_onbox_diff: <bool>, # identify if on box diff capability is supported or not
'supports_generate_diff: <bool>, # identify if diff capability is supported within plugin
'supports_multiline_delimiter: <bool>, # identify if multiline demiliter is supported within config
'support_match: <bool>, # identify if match is supported
'support_diff_match: <bool>, # identify if match is supported
'support_diff_ignore_lines: <bool>, # identify if ignore line in diff is supported
'support_config_replace': <bool>, # identify if running config replace with candidate config is supported
}
'format': [list of supported configuration format],
'match': ['line', 'strict', 'exact', 'none'],
'replace': ['line', 'block', 'config'],
'diff_match': [list of supported match values],
'diff_replace': [list of supported replace values],
}
:return: capability as json string
"""
@ -326,3 +329,38 @@ class CliconfBase(with_metaclass(ABCMeta, object)):
elif proto == 'sftp':
with ssh.open_sftp() as sftp:
sftp.get(source, destination)
def get_diff(self, candidate=None, running=None, match=None, diff_ignore_lines=None, path=None, replace=None):
"""
Generate diff between candidate and running configuration. If the
remote host supports onbox diff capabilities ie. supports_onbox_diff in that case
candidate and running configurations are not required to be passed as argument.
In case if onbox diff capability is not supported candidate argument is mandatory
and running argument is optional.
:param candidate: The configuration which is expected to be present on remote host.
:param running: The base configuration which is used to generate diff.
:param match: Instructs how to match the candidate configuration with current device configuration
Valid values are 'line', 'strict', 'exact', 'none'.
'line' - commands are matched line by line
'strict' - command lines are matched with respect to position
'exact' - command lines must be an equal match
'none' - will not compare the candidate configuration with the running configuration
:param diff_ignore_lines: 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.
:param path: The ordered set of parents that uniquely identify the section or hierarchy
the commands should be checked against. If the parents argument
is omitted, the commands are checked against the set of top
level or global commands.
:param replace: Instructs on the way to perform the configuration on the device.
If the replace argument is set to I(line) then the modified lines are
pushed to the device in configuration mode. If the replace argument is
set to I(block) then the entire command block is pushed to the device in
configuration mode if any line is not correct.
:return: Configuration and/or banner diff in json format.
{
'config_diff': ''
}
"""

View file

@ -66,8 +66,7 @@ class Cliconf(CliconfBase):
'line' - commands are matched line by line
'strict' - command lines are matched with respect to position
'exact' - command lines must be an equal match
'none' - will not compare the candidate configuration with
the running configuration on the remote device
'none' - will not compare the candidate configuration with the running configuration
:param diff_ignore_lines: 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
@ -90,9 +89,16 @@ class Cliconf(CliconfBase):
"""
diff = {}
device_operations = self.get_device_operations()
option_values = self.get_option_values()
if candidate is None and not device_operations['supports_onbox_diff']:
raise ValueError('candidate configuration is required to generate diff')
raise ValueError("candidate configuration is required to generate diff")
if match not in option_values['diff_match']:
raise ValueError("'match' value %s in invalid, valid values are %s" % (match, option_values['diff_match']))
if replace not in option_values['diff_replace']:
raise ValueError("'replace' value %s in invalid, valid values are %s" % (replace, option_values['diff_replace']))
# prepare candidate configuration
candidate_obj = NetworkConfig(indent=1)
@ -118,19 +124,22 @@ class Cliconf(CliconfBase):
return json.dumps(diff)
@enable_mode
def edit_config(self, candidate, check_mode=False, replace=None):
def edit_config(self, candidate=None, commit=True, replace=False, diff=False, comment=None):
if not candidate:
raise ValueError('must provide a candidate config to load')
raise ValueError("must provide a candidate config to load")
if check_mode not in (True, False):
raise ValueError('`check_mode` must be a bool, got %s' % check_mode)
if commit not in (True, False):
raise ValueError("'commit' must be a bool, got %s" % commit)
options = self.get_option_values()
if replace and replace not in options['replace']:
raise ValueError('`replace` value %s in invalid, valid values are %s' % (replace, options['replace']))
if replace not in (True, False):
raise ValueError("'replace' must be a bool, got %s" % replace)
operations = self.get_device_operations()
if replace and not operations['supports_replace']:
raise ValueError("configuration replace is not supported on ios")
results = []
if not check_mode:
if commit:
for line in chain(['configure terminal'], to_list(candidate)):
if not isinstance(line, collections.Mapping):
line = {'command': line}
@ -140,16 +149,23 @@ class Cliconf(CliconfBase):
results.append(self.send_command(**line))
results.append(self.send_command('end'))
return results[1:-1]
def get(self, command, prompt=None, answer=None, sendonly=False):
return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly)
diff_config = None
if diff:
diff_config = candidate
return diff_config, results[1:-1]
def get(self, command=None, prompt=None, answer=None, sendonly=False):
if not command:
raise ValueError('must provide value of command to execute')
return self.send_command(command=command, prompt=prompt, answer=answer, sendonly=sendonly)
def get_device_info(self):
device_info = {}
device_info['network_os'] = 'ios'
reply = self.get('show version')
reply = self.get(command='show version')
data = to_text(reply, errors='surrogate_or_strict').strip()
match = re.search(r'Version (\S+)', data)
@ -168,47 +184,50 @@ class Cliconf(CliconfBase):
def get_device_operations(self):
return {
'supports_replace': True,
'supports_diff_replace': True,
'supports_commit': False,
'supports_rollback': False,
'supports_defaults': True,
'supports_onbox_diff': False,
'supports_commit_comment': False,
'supports_multiline_delimiter': False,
'support_match': True,
'support_diff_match': True,
'support_diff_ignore_lines': True,
'supports_generate_diff': True,
'supports_replace': False
}
def get_option_values(self):
return {
'format': ['text'],
'match': ['line', 'strict', 'exact', 'none'],
'replace': ['line', 'block']
'diff_match': ['line', 'strict', 'exact', 'none'],
'diff_replace': ['line', 'block']
}
def get_capabilities(self):
result = dict()
result['rpc'] = self.get_base_rpc() + ['edit_banner']
result['rpc'] = self.get_base_rpc() + ['edit_banner', 'get_diff']
result['network_api'] = 'cliconf'
result['device_info'] = self.get_device_info()
result['device_operations'] = self.get_device_operations()
result.update(self.get_option_values())
return json.dumps(result)
def edit_banner(self, banners, multiline_delimiter="@", check_mode=False):
def edit_banner(self, candidate=None, multiline_delimiter="@", commit=True, diff=False):
"""
Edit banner on remote device
:param banners: Banners to be loaded in json format
:param multiline_delimiter: Line delimiter for banner
:param check_mode: Boolean value that indicates if the device candidate
:param commit: Boolean value that indicates if the device candidate
configuration should be pushed in the running configuration or discarded.
:param diff: Boolean flag to indicate if configuration that is applied on remote host should
generated and returned in response or not
:return: Returns response of executing the configuration command received
from remote host
"""
banners_obj = json.loads(banners)
banners_obj = json.loads(candidate)
results = []
if not check_mode:
if commit:
for key, value in iteritems(banners_obj):
key += ' %s' % multiline_delimiter
for cmd in ['config terminal', key, value, multiline_delimiter, 'end']:
@ -218,7 +237,11 @@ class Cliconf(CliconfBase):
time.sleep(0.1)
results.append(self.send_command('\n'))
return results[1:-1]
diff_banner = None
if diff:
diff_banner = candidate
return diff_banner, results[1:-1]
def _extract_banners(self, config):
banners = {}

View file

@ -24,7 +24,9 @@ import json
from itertools import chain
from ansible.errors import AnsibleConnectionFailure
from ansible.module_utils._text import to_text
from ansible.module_utils.network.common.config import NetworkConfig, dumps
from ansible.module_utils.network.common.utils import to_list
from ansible.plugins.cliconf import CliconfBase
@ -51,14 +53,56 @@ class Cliconf(CliconfBase):
return device_info
def get_config(self, source='running', format='text'):
return self.send_command('show configuration commands')
def get_config(self, filter=None, format='set'):
if format == 'text':
out = self.send_command('show configuration')
else:
out = self.send_command('show configuration commands')
return out
def edit_config(self, command):
for cmd in chain(['configure'], to_list(command)):
self.send_command(cmd)
def edit_config(self, candidate=None, commit=True, replace=False, diff=False, comment=None):
if not candidate:
raise ValueError('must provide a candidate config to load')
def get(self, command, prompt=None, answer=None, sendonly=False):
if commit not in (True, False):
raise ValueError("'commit' must be a bool, got %s" % commit)
if replace not in (True, False):
raise ValueError("'replace' must be a bool, got %s" % replace)
operations = self.get_device_operations()
if replace and not operations['supports_replace']:
raise ValueError("configuration replace is not supported on vyos")
results = []
for cmd in chain(['configure'], to_list(candidate)):
results.append(self.send_command(cmd))
diff_config = None
if diff:
out = self.get('compare')
out = to_text(out, errors='surrogate_or_strict')
if not out.startswith('No changes'):
diff_config = out
if commit:
try:
self.commit(comment)
except AnsibleConnectionFailure as e:
msg = 'commit failed: %s' % e.message
self.discard_changes()
raise AnsibleConnectionFailure(msg)
else:
self.get('exit')
else:
self.discard_changes()
return diff_config, results[1:]
def get(self, command=None, prompt=None, answer=None, sendonly=False):
if not command:
raise ValueError('must provide value of command to execute')
return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly)
def commit(self, comment=None):
@ -68,12 +112,105 @@ class Cliconf(CliconfBase):
command = 'commit'
self.send_command(command)
def discard_changes(self, *args, **kwargs):
def discard_changes(self):
self.send_command('exit discard')
def get_diff(self, candidate=None, running=None, match='line', diff_ignore_lines=None, path=None, replace=None):
diff = {}
device_operations = self.get_device_operations()
option_values = self.get_option_values()
if candidate is None and not device_operations['supports_onbox_diff']:
raise ValueError("candidate configuration is required to generate diff")
if match not in option_values['diff_match']:
raise ValueError("'match' value %s in invalid, valid values are %s" % (match, option_values['diff_match']))
if replace:
raise ValueError("'replace' in diff is not supported on vyos")
if diff_ignore_lines:
raise ValueError("'diff_ignore_lines' in diff is not supported on vyos")
if path:
raise ValueError("'path' in diff is not supported on vyos")
set_format = candidate.startswith('set') or candidate.startswith('delete')
candidate_obj = NetworkConfig(indent=4, contents=candidate)
if not set_format:
config = [c.line for c in candidate_obj.items]
commands = list()
# this filters out less specific lines
for item in config:
for index, entry in enumerate(commands):
if item.startswith(entry):
del commands[index]
break
commands.append(item)
candidate_commands = ['set %s' % cmd.replace(' {', '') for cmd in commands]
else:
candidate_commands = str(candidate).strip().split('\n')
if match == 'none':
diff['config_diff'] = list(candidate_commands)
return json.dumps(diff)
running_commands = [str(c).replace("'", '') for c in running.splitlines()]
updates = list()
visited = set()
for line in candidate_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 running_commands:
updates.append(line)
elif item.startswith('delete'):
if not running_commands:
updates.append(line)
else:
item = re.sub(r'delete', 'set', item)
for entry in running_commands:
if entry.startswith(item) and line not in visited:
updates.append(line)
visited.add(line)
diff['config_diff'] = list(updates)
return json.dumps(diff)
def get_device_operations(self):
return {
'supports_diff_replace': False,
'supports_commit': True,
'supports_rollback': True,
'supports_defaults': False,
'supports_onbox_diff': False,
'supports_commit_comment': True,
'supports_multiline_delimiter': False,
'support_diff_match': True,
'support_diff_ignore_lines': False,
'supports_generate_diff': True,
'supports_replace': False
}
def get_option_values(self):
return {
'format': ['set', 'text'],
'diff_match': ['line', 'none'],
'diff_replace': [],
}
def get_capabilities(self):
result = {}
result['rpc'] = self.get_base_rpc() + ['commit', 'discard_changes']
result['rpc'] = self.get_base_rpc() + ['commit', 'discard_changes', 'get_diff']
result['network_api'] = 'cliconf'
result['device_info'] = self.get_device_info()
result['device_operations'] = self.get_device_operations()
result.update(self.get_option_values())
return json.dumps(result)

View file

@ -20,8 +20,9 @@
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
from ansible.compat.tests.mock import patch
from ansible.compat.tests.mock import patch, MagicMock
from ansible.modules.network.vyos import vyos_config
from ansible.plugins.cliconf.vyos import Cliconf
from units.modules.utils import set_module_args
from .vyos_module import TestVyosModule, load_fixture
@ -42,12 +43,23 @@ class TestVyosConfigModule(TestVyosModule):
self.mock_run_commands = patch('ansible.modules.network.vyos.vyos_config.run_commands')
self.run_commands = self.mock_run_commands.start()
self.mock_get_connection = patch('ansible.modules.network.vyos.vyos_config.get_connection')
self.get_connection = self.mock_get_connection.start()
self.cliconf_obj = Cliconf(MagicMock())
self.running_config = load_fixture('vyos_config_config.cfg')
self.conn = self.get_connection()
self.conn.edit_config = MagicMock()
self.running_config = load_fixture('vyos_config_config.cfg')
def tearDown(self):
super(TestVyosConfigModule, self).tearDown()
self.mock_get_config.stop()
self.mock_load_config.stop()
self.mock_run_commands.stop()
self.mock_get_connection.stop()
def load_fixtures(self, commands=None):
config_file = 'vyos_config_config.cfg'
@ -56,6 +68,7 @@ class TestVyosConfigModule(TestVyosModule):
def test_vyos_config_unchanged(self):
src = load_fixture('vyos_config_config.cfg')
self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(src, src))
set_module_args(dict(src=src))
self.execute_module()
@ -63,12 +76,14 @@ class TestVyosConfigModule(TestVyosModule):
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.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(src, self.running_config))
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.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(src, self.running_config))
self.execute_module(changed=True, commands=commands)
def test_vyos_config_backup(self):
@ -79,16 +94,22 @@ class TestVyosConfigModule(TestVyosModule):
def test_vyos_config_lines(self):
commands = ['set system host-name foo']
set_module_args(dict(lines=commands))
candidate = '\n'.join(commands)
self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(candidate, self.running_config))
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))
candidate = '\n'.join(new_config)
self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(candidate, 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'))
candidate = '\n'.join(lines)
self.conn.get_diff = MagicMock(return_value=self.cliconf_obj.get_diff(candidate, None, match='none'))
self.execute_module(changed=True, commands=lines, sort=False)