diff --git a/docs/docsite/rst/network/user_guide/platform_exos.rst b/docs/docsite/rst/network/user_guide/platform_exos.rst index 842bbcb6b4..9cc6e2916e 100644 --- a/docs/docsite/rst/network/user_guide/platform_exos.rst +++ b/docs/docsite/rst/network/user_guide/platform_exos.rst @@ -4,37 +4,37 @@ EXOS Platform Options *************************************** -Extreme EXOS Ansible modules only support CLI connections today. This page offers details on how to -use ``network_cli`` on EXOS in Ansible. +Extreme EXOS Ansible modules support multiple connections. This page offers details on how each connection works in Ansible and how to use it. .. contents:: Topics Connections Available ================================================================================ -+---------------------------+-----------------------------------------------+ -|.. | CLI | -+===========================+===============================================+ -| **Protocol** | SSH | -+---------------------------+-----------------------------------------------+ -| | **Credentials** | | uses SSH keys / SSH-agent if present | -| | | | accepts ``-u myuser -k`` if using password | -+---------------------------+-----------------------------------------------+ -| **Indirect Access** | via a bastion (jump host) | -+---------------------------+-----------------------------------------------+ -| | **Connection Settings** | | ``ansible_connection: network_cli`` | -| | | | | -| | | | | -| | | | | -| | | | | -+---------------------------+-----------------------------------------------+ -| | **Enable Mode** | | not supported by EXOS | -| | (Privilege Escalation) | | | -+---------------------------+-----------------------------------------------+ -| **Returned Data Format** | ``stdout[0].`` | -+---------------------------+-----------------------------------------------+ ++---------------------------+-----------------------------------------------+-----------------------------------------+ +|.. | CLI | EXOS-API | ++===========================+===============================================+=========================================+ +| **Protocol** | SSH | HTTP(S) | ++---------------------------+-----------------------------------------------+-----------------------------------------+ +| | **Credentials** | | uses SSH keys / SSH-agent if present | | uses HTTPS certificates if present | +| | | | accepts ``-u myuser -k`` if using password | | | ++---------------------------+-----------------------------------------------+-----------------------------------------+ +| **Indirect Access** | via a bastion (jump host) | via a web proxy | ++---------------------------+-----------------------------------------------+-----------------------------------------+ +| | **Connection Settings** | | ``ansible_connection: network_cli`` | | ``ansible_connection: httpapi`` | +| | | | | | | +| | | | | | | +| | | | | | | +| | | | | | | ++---------------------------+-----------------------------------------------+-----------------------------------------+ +| | **Enable Mode** | | not supported by EXOS | | not supported by EXOS | +| | (Privilege Escalation) | | | | | +| | | | | | | ++---------------------------+-----------------------------------------------+-----------------------------------------+ +| **Returned Data Format** | ``stdout[0].`` | ``stdout[0].messages[0].`` | ++---------------------------+-----------------------------------------------+-----------------------------------------+ -EXOS does not support ``ansible_connection: local``. You must use ``ansible_connection: network_cli``. +EXOS does not support ``ansible_connection: local``. You must use ``ansible_connection: network_cli`` or ``ansible_connection: httpapi`` Using CLI in Ansible ==================== @@ -65,4 +65,37 @@ Example CLI Task commands: show version when: ansible_network_os == 'exos' + + +Using EXOS-API in Ansible +========================= + +Example EXOS-API ``group_vars/exos.yml`` +---------------------------------------- + +.. code-block:: yaml + + ansible_connection: httpapi + ansible_network_os: exos + ansible_user: myuser + ansible_password: !vault... + proxy_env: + http_proxy: http://proxy.example.com:8080 + +- If you are accessing your host directly (not through a web proxy) you can remove the ``proxy_env`` configuration. +- If you are accessing your host through a web proxy using ``https``, change ``http_proxy`` to ``https_proxy``. + + +Example EXOS-API Task +--------------------- + +.. code-block:: yaml + + - name: Retrieve EXOS OS version + exos_command: + commands: show version + when: ansible_network_os == 'exos' + +In this example the ``proxy_env`` variable defined in ``group_vars`` gets passed to the ``environment`` option of the module used in the task. + .. include:: shared_snippets/SSH_warning.txt diff --git a/docs/docsite/rst/network/user_guide/platform_index.rst b/docs/docsite/rst/network/user_guide/platform_index.rst index 16df681906..300b8837c6 100644 --- a/docs/docsite/rst/network/user_guide/platform_index.rst +++ b/docs/docsite/rst/network/user_guide/platform_index.rst @@ -54,7 +54,7 @@ Settings by Platform +-------------------+-------------------------+-------------+---------+---------+----------+ | Dell OS10 | ``dellos10`` | ✓ | | | ✓ | +-------------------+-------------------------+-------------+---------+---------+----------+ -| Extreme EXOS | ``exos`` | ✓ | | | | +| Extreme EXOS | ``exos`` | ✓ | | ✓ | | +-------------------+-------------------------+-------------+---------+---------+----------+ | Extreme IronWare | ``ironware`` | ✓ | | | ✓ | +-------------------+-------------------------+-------------+---------+---------+----------+ diff --git a/lib/ansible/module_utils/network/exos/exos.py b/lib/ansible/module_utils/network/exos/exos.py index 0b8648284e..d8f6f959cb 100644 --- a/lib/ansible/module_utils/network/exos/exos.py +++ b/lib/ansible/module_utils/network/exos/exos.py @@ -28,74 +28,192 @@ import json from ansible.module_utils._text import to_text from ansible.module_utils.basic import env_fallback -from ansible.module_utils.network.common.utils import to_list -from ansible.module_utils.connection import Connection +from ansible.module_utils.network.common.utils import to_list, ComplexList +from ansible.module_utils.common._collections_compat import Mapping +from ansible.module_utils.connection import Connection, ConnectionError -_DEVICE_CONFIGS = {} +_DEVICE_CONNECTION = None -def get_connection(module): - if hasattr(module, '_exos_connection'): - return module._exos_connection +class Cli: + def __init__(self, module): + self._module = module + self._device_configs = {} + self._connection = None - capabilities = get_capabilities(module) - network_api = capabilities.get('network_api') + def get_capabilities(self): + """Returns platform info of the remove device + """ + connection = self._get_connection() + return json.loads(connection.get_capabilities()) - if network_api == 'cliconf': - module._exos_connection = Connection(module._socket_path) - else: - module.fail_json(msg='Invalid connection type %s' % network_api) + def _get_connection(self): + if not self._connection: + self._connection = Connection(self._module._socket_path) + return self._connection - return module._exos_connection + def get_config(self, flags=None): + """Retrieves the current config from the device or cache + """ + flags = [] if flags is None else flags + if self._device_configs == {}: + connection = self._get_connection() + try: + out = connection.get_config(flags=flags) + except ConnectionError as exc: + self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + self._device_configs = to_text(out, errors='surrogate_then_replace').strip() + return self._device_configs + + def run_commands(self, commands, check_rc=True): + """Runs list of commands on remote device and returns results + """ + connection = self._get_connection() + try: + response = connection.run_commands(commands=commands, check_rc=check_rc) + except ConnectionError as exc: + self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + return response + + def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): + conn = self._get_connection() + try: + diff = conn.get_diff(candidate=candidate, running=running, diff_match=diff_match, + diff_ignore_lines=diff_ignore_lines, path=path, diff_replace=diff_replace) + except ConnectionError as exc: + self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + return diff + + +class HttpApi: + def __init__(self, module): + self._module = module + self._device_configs = {} + self._connection_obj = None + + def get_capabilities(self): + """Returns platform info of the remove device + """ + try: + capabilities = self._connection.get_capabilities() + except ConnectionError as exc: + self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + + return json.loads(capabilities) + + @property + def _connection(self): + if not self._connection_obj: + self._connection_obj = Connection(self._module._socket_path) + return self._connection_obj + + def get_config(self, flags=None): + """Retrieves the current config from the device or cache + """ + flags = [] if flags is None else flags + if self._device_configs == {}: + try: + out = self._connection.get_config(flags=flags) + except ConnectionError as exc: + self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + self._device_configs = to_text(out, errors='surrogate_then_replace').strip() + return self._device_configs + + def run_commands(self, commands, check_rc=True): + """Runs list of commands on remote device and returns results + """ + try: + response = self._connection.run_commands(commands=commands, check_rc=check_rc) + except ConnectionError as exc: + self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + return response + + def send_requests(self, requests): + """Send a list of http requests to remote device and return results + """ + if requests is None: + raise ValueError("'requests' value is required") + + responses = list() + for req in to_list(requests): + try: + response = self._connection.send_request(**req) + except ConnectionError as exc: + self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + responses.append(response) + return responses + + def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): + try: + diff = self._connection.get_diff(candidate=candidate, running=running, diff_match=diff_match, + diff_ignore_lines=diff_ignore_lines, path=path, diff_replace=diff_replace) + except ConnectionError as exc: + self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + return diff def get_capabilities(module): - if hasattr(module, '_exos_capabilities'): - return module._exos_capabilities + conn = get_connection(module) + return conn.get_capabilities() - capabilities = Connection(module._socket_path).get_capabilities() - module._exos_capabilities = json.loads(capabilities) - return module._exos_capabilities +def get_connection(module): + global _DEVICE_CONNECTION + if not _DEVICE_CONNECTION: + connection_proxy = Connection(module._socket_path) + cap = json.loads(connection_proxy.get_capabilities()) + if cap['network_api'] == 'cliconf': + conn = Cli(module) + elif cap['network_api'] == 'exosapi': + conn = HttpApi(module) + else: + module.fail_json(msg='Invalid connection type %s' % cap['network_api']) + _DEVICE_CONNECTION = conn + return _DEVICE_CONNECTION def get_config(module, flags=None): - global _DEVICE_CONFIGS - - if _DEVICE_CONFIGS != {}: - return _DEVICE_CONFIGS - else: - connection = get_connection(module) - out = connection.get_config() - cfg = to_text(out, errors='surrogate_then_replace').strip() - _DEVICE_CONFIGS = cfg - return cfg - - -def run_commands(module, commands, check_rc=True): - responses = list() - connection = get_connection(module) - - for cmd in to_list(commands): - if isinstance(cmd, dict): - command = cmd['command'] - prompt = cmd['prompt'] - answer = cmd['answer'] - else: - command = cmd - prompt = None - answer = None - out = connection.get(command, prompt, answer) - - try: - out = to_text(out, errors='surrogate_or_strict') - except UnicodeError: - module.fail_json(msg=u'Failed to decode output from %s: %s' % (cmd, to_text(out))) - responses.append(out) - - return responses + flags = None if flags is None else flags + conn = get_connection(module) + return conn.get_config(flags) def load_config(module, commands): - connection = get_connection(module) - out = connection.edit_config(commands) + conn = get_connection(module) + return conn.run_commands(to_command(module, commands)) + + +def run_commands(module, commands, check_rc=True): + conn = get_connection(module) + return conn.run_commands(to_command(module, commands), check_rc=check_rc) + + +def to_command(module, commands): + transform = ComplexList(dict( + command=dict(key=True), + output=dict(default='text'), + prompt=dict(type='list'), + answer=dict(type='list'), + sendonly=dict(type='bool', default=False), + check_all=dict(type='bool', default=False), + ), module) + return transform(to_list(commands)) + + +def send_requests(module, requests): + conn = get_connection(module) + return conn.send_requests(to_request(module, requests)) + + +def to_request(module, requests): + transform = ComplexList(dict( + path=dict(key=True), + method=dict(), + data=dict(), + ), module) + return transform(to_list(requests)) + + +def get_diff(module, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): + conn = get_connection(module) + return conn.get_diff(candidate=candidate, running=running, diff_match=diff_match, diff_ignore_lines=diff_ignore_lines, path=path, diff_replace=diff_replace) diff --git a/lib/ansible/modules/network/exos/exos_config.py b/lib/ansible/modules/network/exos/exos_config.py index f0c31e4ab4..c0bb8dfd3f 100644 --- a/lib/ansible/modules/network/exos/exos_config.py +++ b/lib/ansible/modules/network/exos/exos_config.py @@ -126,6 +126,7 @@ options: - When this option is configured as I(running), the module will return the before and after diff of the running-config with respect to any changes made to the device configuration. + default: running choices: ['running', 'startup', 'intended'] diff_ignore_lines: description: @@ -222,50 +223,48 @@ backup_path: """ import re -from ansible.module_utils.network.exos.exos import run_commands, get_config, load_config +from ansible.module_utils.network.exos.exos import run_commands, get_config, load_config, get_diff from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.network.common.config import NetworkConfig, dumps from ansible.module_utils._text import to_text +from ansible.module_utils.network.common.utils import to_list __metaclass__ = type -def get_running_config(module, current_config=None): +def get_running_config(module, current_config=None, flags=None): contents = module.params['running_config'] if not contents: if current_config: contents = current_config.config_text else: - contents = get_config(module) - return NetworkConfig(indent=1, contents=contents) + contents = get_config(module, flags=flags) + return contents -def get_startup_config_text(module): - reply = run_commands(module, ['show switch | include "Config Selected"']) - match = re.search(r': +(\S+)\.cfg', to_text(reply, errors='surrogate_or_strict').strip()) +def get_startup_config(module, flags=None): + reply = run_commands(module, {'command': 'show switch', 'output': 'text'}) + match = re.search(r'Config Selected: +(\S+)\.cfg', to_text(reply, errors='surrogate_or_strict').strip(), re.MULTILINE) if match: cfgname = match.group(1).strip() - reply = run_commands(module, ['debug cfgmgr show configuration file ' + cfgname]) + command = ' '.join(['debug cfgmgr show configuration file', cfgname]) + if flags: + command += ' '.join(to_list(flags)).strip() + reply = run_commands(module, {'command': command, 'output': 'text'}) data = reply[0] else: data = '' return data -def get_startup_config(module): - data = get_startup_config_text(module) - return NetworkConfig(indent=1, contents=data) - - def get_candidate(module): candidate = NetworkConfig(indent=1) if module.params['src']: candidate.load(module.params['src']) - elif module.params['lines']: candidate.add(module.params['lines']) - + candidate = dumps(candidate, 'raw') return candidate @@ -308,7 +307,7 @@ def main(): save_when=dict(choices=['always', 'never', 'modified', 'changed'], default='never'), - diff_against=dict(choices=['startup', 'intended', 'running']), + diff_against=dict(choices=['startup', 'intended', 'running'], default='running'), diff_ignore_lines=dict(type='list'), ) @@ -327,12 +326,15 @@ def main(): result = {'changed': False} warnings = list() - result['warnings'] = warnings + if warnings: + result['warnings'] = warnings config = None + flags = ['detail'] if module.params['defaults'] else [] + diff_ignore_lines = module.params['diff_ignore_lines'] if module.params['backup'] or (module._diff and module.params['diff_against'] == 'running'): - contents = get_config(module) + contents = get_config(module, flags=flags) config = NetworkConfig(indent=1, contents=contents) if module.params['backup']: result['__backup__'] = contents @@ -342,15 +344,17 @@ def main(): replace = module.params['replace'] candidate = get_candidate(module) + running = get_running_config(module, config) - if match != 'none': - config = get_running_config(module, config) - configobjs = candidate.difference(config, match=match, replace=replace) - else: - configobjs = candidate.items + try: + response = get_diff(module, candidate=candidate, running=running, diff_match=match, diff_ignore_lines=diff_ignore_lines, diff_replace=replace) + except ConnectionError as exc: + module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) - if configobjs: - commands = dumps(configobjs, 'commands').split('\n') + config_diff = response.get('config_diff') + + if config_diff: + commands = config_diff.split('\n') if module.params['before']: commands[:0] = module.params['before'] @@ -372,13 +376,11 @@ def main(): running_config = None startup_config = None - diff_ignore_lines = module.params['diff_ignore_lines'] - if module.params['save_when'] == 'always': save_config(module, result) elif module.params['save_when'] == 'modified': - running = get_running_config(module).config_text - startup = get_startup_config(module).config_text + running = get_running_config(module) + startup = get_startup_config(module) running_config = NetworkConfig(indent=1, contents=running, ignore_lines=diff_ignore_lines) startup_config = NetworkConfig(indent=1, contents=startup, ignore_lines=diff_ignore_lines) @@ -390,7 +392,7 @@ def main(): if module._diff: if not running_config: - contents = get_running_config(module).config_text + contents = get_running_config(module) else: contents = running_config.config_text @@ -406,7 +408,7 @@ def main(): elif module.params['diff_against'] == 'startup': if not startup_config: - contents = get_startup_config(module).config_text + contents = get_startup_config(module) else: contents = startup_config.config_text diff --git a/lib/ansible/modules/network/exos/exos_facts.py b/lib/ansible/modules/network/exos/exos_facts.py index d9a03669e8..7bd99eeea7 100644 --- a/lib/ansible/modules/network/exos/exos_facts.py +++ b/lib/ansible/modules/network/exos/exos_facts.py @@ -240,10 +240,10 @@ class Interfaces(FactsBase): COMMANDS = [ 'show switch', - 'run script cli2json.py show port config', - 'run script cli2json.py show port description', - 'run script cli2json.py show vlan detail', - 'run script cli2json.py show lldp neighbors' + {'command': 'show port config', 'output': 'json'}, + {'command': 'show port description', 'output': 'json'}, + {'command': 'show vlan detail', 'output': 'json'}, + {'command': 'show lldp neighbors', 'output': 'json'} ] def populate(self): @@ -256,19 +256,19 @@ class Interfaces(FactsBase): if data: sysmac = self.parse_sysmac(data) - data = json.loads(self.responses[1]) + data = self.responses[1] if data: self.facts['interfaces'] = self.populate_interfaces(data, sysmac) - data = json.loads(self.responses[2]) + data = self.responses[2] if data: self.populate_interface_descriptions(data) - data = json.loads(self.responses[3]) + data = self.responses[3] if data: self.populate_vlan_interfaces(data, sysmac) - data = json.loads(self.responses[4]) + data = self.responses[4] if data: self.facts['neighbors'] = self.parse_neighbors(data) diff --git a/lib/ansible/plugins/cliconf/exos.py b/lib/ansible/plugins/cliconf/exos.py index 72bc527d7d..0b486e25fd 100644 --- a/lib/ansible/plugins/cliconf/exos.py +++ b/lib/ansible/plugins/cliconf/exos.py @@ -32,20 +32,51 @@ version_added: "2.6" import re import json -from itertools import chain - +from ansible.errors import AnsibleConnectionFailure from ansible.module_utils._text import to_bytes, to_text from ansible.module_utils.network.common.utils import to_list +from ansible.module_utils.connection import ConnectionError +from ansible.module_utils.common._collections_compat import Mapping +from ansible.module_utils.network.common.config import NetworkConfig, dumps from ansible.plugins.cliconf import CliconfBase class Cliconf(CliconfBase): + def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): + diff = {} + device_operations = self.get_device_operations() + option_values = self.get_option_values() + + if candidate is None and device_operations['supports_generate_diff']: + raise ValueError("candidate configuration is required to generate diff") + + if diff_match not in option_values['diff_match']: + raise ValueError("'match' value %s in invalid, valid values are %s" % (diff_match, ', '.join(option_values['diff_match']))) + + if diff_replace not in option_values['diff_replace']: + raise ValueError("'replace' value %s in invalid, valid values are %s" % (diff_replace, ', '.join(option_values['diff_replace']))) + + # prepare candidate configuration + candidate_obj = NetworkConfig(indent=1) + candidate_obj.load(candidate) + + if running and diff_match != 'none' and diff_replace != 'config': + # running configuration + running_obj = NetworkConfig(indent=1, contents=running, ignore_lines=diff_ignore_lines) + configdiffobjs = candidate_obj.difference(running_obj, path=path, match=diff_match, replace=diff_replace) + + else: + configdiffobjs = candidate_obj.items + + diff['config_diff'] = dumps(configdiffobjs, 'commands') if configdiffobjs else '' + return diff + def get_device_info(self): device_info = {} device_info['network_os'] = 'exos' - reply = self.get('show switch detail') + reply = self.run_commands({'command': 'show switch detail', 'output': 'text'}) data = to_text(reply, errors='surrogate_or_strict').strip() match = re.search(r'ExtremeXOS version (\S+)', data) @@ -62,69 +93,138 @@ class Cliconf(CliconfBase): return device_info - def get_config(self, source='running', flags=None): - if source not in ('running', 'startup'): + def get_default_flag(self): + # The flag to modify the command to collect configuration with defaults + return 'detail' + + def get_config(self, source='running', format='text', flags=None): + options_values = self.get_option_values() + if format not in options_values['format']: + raise ValueError("'format' value %s is invalid. Valid values are %s" % (format, ','.join(options_values['format']))) + + lookup = {'running': 'show configuration', 'startup': 'debug cfgmgr show configuration file'} + if source not in lookup: raise ValueError("fetching configuration from %s is not supported" % source) - if source == 'running': - cmd = 'show configuration' - else: - cmd = 'debug cfgmgr show configuration file' - reply = self.get('show switch | include "Config Selected"') + + cmd = {'command': lookup[source], 'output': 'text'} + + if source == 'startup': + reply = self.run_commands({'command': 'show switch', 'format': 'text'}) data = to_text(reply, errors='surrogate_or_strict').strip() - match = re.search(r': +(\S+)\.cfg', data) + match = re.search(r'Config Selected: +(\S+)\.cfg', data, re.MULTILINE) if match: - cmd += ' '.join(match.group(1)) - cmd = cmd.strip() - - flags = [] if flags is None else flags - cmd += ' '.join(flags) - cmd = cmd.strip() - - return self.send_command(cmd) - - def edit_config(self, command): - for cmd in chain(to_list(command)): - if isinstance(cmd, dict): - command = cmd['command'] - prompt = cmd['prompt'] - answer = cmd['answer'] - newline = cmd.get('newline', True) + cmd['command'] += match.group(1) else: - command = cmd - prompt = None - answer = None - newline = True - self.send_command(to_bytes(command), to_bytes(prompt), to_bytes(answer), - False, newline) + # No Startup(/Selected) Config + return {} - def get(self, command, prompt=None, answer=None, sendonly=False, check_all=False): - return self.send_command(command=command, prompt=prompt, answer=answer, sendonly=sendonly, check_all=check_all) + cmd['command'] += ' '.join(to_list(flags)) + cmd['command'] = cmd['command'].strip() + + return self.run_commands(cmd)[0] + + def edit_config(self, candidate=None, commit=True, replace=None, diff=False, comment=None): + resp = {} + operations = self.get_device_operations() + self.check_edit_config_capability(operations, candidate, commit, replace, comment) + results = [] + requests = [] + + if commit: + for line in to_list(candidate): + if not isinstance(line, Mapping): + line = {'command': line} + results.append(self.send_command(**line)) + requests.append(line['command']) + else: + raise ValueError('check mode is not supported') + + resp['request'] = requests + resp['response'] = results + return resp + + def get(self, command, prompt=None, answer=None, sendonly=False, output=None, check_all=False): + if output: + command = self._get_command_with_output(command, output) + return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly, check_all=check_all) + + def run_commands(self, commands=None, check_rc=True): + if commands is None: + raise ValueError("'commands' value is required") + + responses = list() + for cmd in to_list(commands): + if not isinstance(cmd, Mapping): + cmd = {'command': cmd} + + output = cmd.pop('output', None) + if output: + cmd['command'] = self._get_command_with_output(cmd['command'], output) + + try: + out = self.send_command(**cmd) + except AnsibleConnectionFailure as e: + if check_rc is True: + raise + out = getattr(e, 'err', e) + + if out is not None: + try: + out = to_text(out, errors='surrogate_or_strict').strip() + except UnicodeError: + raise ConnectionError(message=u'Failed to decode output from %s: %s' % (cmd, to_text(out))) + + if output and output == 'json': + try: + out = json.loads(out) + except ValueError: + raise ConnectionError('Response was not valid JSON, got {0}'.format( + to_text(out) + )) + responses.append(out) + + return responses def get_device_operations(self): return { - '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, - 'supports_diff_match': True, - 'supports_diff_ignore_lines': True, - 'supports_generate_diff': True, - 'supports_replace': True + 'supports_diff_replace': False, # identify if config should be merged or replaced is supported + 'supports_commit': False, # identify if commit is supported by device or not + 'supports_rollback': False, # identify if rollback is supported or not + 'supports_defaults': True, # identify if fetching running config with default is supported + 'supports_commit_comment': False, # identify if adding comment to commit is supported of not + 'supports_onbox_diff': False, # identify if on box diff capability is supported or not + 'supports_generate_diff': True, # identify if diff capability is supported within plugin + 'supports_multiline_delimiter': False, # identify if multiline delimiter is supported within config + 'supports_diff_match': True, # identify if match is supported + 'supports_diff_ignore_lines': True, # identify if ignore line in diff is supported + 'supports_config_replace': False, # identify if running config replace with candidate config is supported + 'supports_admin': False, # identify if admin configure mode is supported or not + 'supports_commit_label': False, # identify if commit label is supported or not + 'supports_replace': False } def get_option_values(self): return { - 'format': ['text'], + 'format': ['text', 'json'], 'diff_match': ['line', 'strict', 'exact', 'none'], 'diff_replace': ['line', 'block'], - 'output': ['text'] + 'output': ['text', 'json'] } def get_capabilities(self): result = super(Cliconf, self).get_capabilities() + result['rpc'] += ['run_commmands', 'get_default_flag', 'get_diff'] result['device_operations'] = self.get_device_operations() + result['device_info'] = self.get_device_info() result.update(self.get_option_values()) return json.dumps(result) + + def _get_command_with_output(self, command, output): + if output not in self.get_option_values().get('output'): + raise ValueError("'output' value is %s is invalid. Valid values are %s" % (output, ','.join(self.get_option_values().get('output')))) + + if output == 'json' and not command.startswith('run script cli2json.py'): + cmd = 'run script cli2json.py %s' % command + else: + cmd = command + return cmd diff --git a/lib/ansible/plugins/httpapi/exos.py b/lib/ansible/plugins/httpapi/exos.py new file mode 100644 index 0000000000..70989b4639 --- /dev/null +++ b/lib/ansible/plugins/httpapi/exos.py @@ -0,0 +1,253 @@ +# Copyright (c) 2019 Extreme Networks. +# +# 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 . +# + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = """ +--- +author: + - "Ujwal Komarla (@ujwalkomarla)" +httpapi: exos +short_description: Use EXOS REST APIs to communicate with EXOS platform +description: + - This plugin provides low level abstraction api's to send REST API + requests to EXOS network devices and receive JSON responses. +version_added: "2.8" +""" + +import json +import re +from ansible.module_utils._text import to_text +from ansible.module_utils.connection import ConnectionError +from ansible.module_utils.network.common.utils import to_list +from ansible.plugins.httpapi import HttpApiBase +import ansible.module_utils.six.moves.http_cookiejar as cookiejar +from ansible.module_utils.common._collections_compat import Mapping +from ansible.module_utils.network.common.config import NetworkConfig, dumps + + +class HttpApi(HttpApiBase): + + def __init__(self, *args, **kwargs): + super(HttpApi, self).__init__(*args, **kwargs) + self._device_info = None + self._auth_token = cookiejar.CookieJar() + + def login(self, username, password): + auth_path = '/auth/token' + credentials = {'username': username, 'password': password} + self.send_request(path=auth_path, data=json.dumps(credentials), method='POST') + + def logout(self): + pass + + def handle_httperror(self, exc): + return False + + def send_request(self, path, data=None, method='GET', **message_kwargs): + headers = {'Content-Type': 'application/json'} + response, response_data = self.connection.send(path, data, method=method, cookies=self._auth_token, headers=headers, **message_kwargs) + try: + if response.status == 204: + response_data = {} + else: + response_data = json.loads(to_text(response_data.getvalue())) + except ValueError: + raise ConnectionError('Response was not valid JSON, got {0}'.format( + to_text(response_data.getvalue()) + )) + return response_data + + def run_commands(self, commands, check_rc=True): + if commands is None: + raise ValueError("'commands' value is required") + + headers = {'Content-Type': 'application/json'} + responses = list() + for cmd in to_list(commands): + if not isinstance(cmd, Mapping): + cmd = {'command': cmd} + + cmd['command'] = strip_run_script_cli2json(cmd['command']) + + output = cmd.pop('output', None) + if output and output not in self.get_option_values().get('output'): + raise ValueError("'output' value is %s is invalid. Valid values are %s" % (output, ','.join(self.get_option_values().get('output')))) + + data = request_builder(cmd['command']) + + response, response_data = self.connection.send('/jsonrpc', data, cookies=self._auth_token, headers=headers, method='POST') + try: + response_data = json.loads(to_text(response_data.getvalue())) + except ValueError: + raise ConnectionError('Response was not valid JSON, got {0}'.format( + to_text(response_data.getvalue()) + )) + + if response_data.get('error', None): + raise ConnectionError("Request Error, got {0}".format(response_data['error'])) + if not response_data.get('result', None): + raise ConnectionError("Request Error, got {0}".format(response_data)) + + response_data = response_data['result'] + + if output and output == 'text': + statusOut = getKeyInResponse(response_data, 'status') + cliOut = getKeyInResponse(response_data, 'CLIoutput') + if statusOut == "ERROR": + raise ConnectionError("Command error({1}) for request {0}".format(cmd['command'], cliOut)) + if cliOut is None: + raise ValueError("Response for request {0} doesn't have the CLIoutput field, got {1}".format(cmd['command'], response_data)) + response_data = cliOut + + responses.append(response_data) + return responses + + def get_device_info(self): + device_info = {} + device_info['network_os'] = 'exos' + + reply = self.run_commands({'command': 'show switch detail', 'output': 'text'}) + data = to_text(reply, errors='surrogate_or_strict').strip() + + match = re.search(r'ExtremeXOS version (\S+)', data) + if match: + device_info['network_os_version'] = match.group(1) + + match = re.search(r'System Type: +(\S+)', data) + if match: + device_info['network_os_model'] = match.group(1) + + match = re.search(r'SysName: +(\S+)', data) + if match: + device_info['network_os_hostname'] = match.group(1) + + return device_info + + def get_device_operations(self): + return { + 'supports_diff_replace': False, # identify if config should be merged or replaced is supported + 'supports_commit': False, # identify if commit is supported by device or not + 'supports_rollback': False, # identify if rollback is supported or not + 'supports_defaults': True, # identify if fetching running config with default is supported + 'supports_commit_comment': False, # identify if adding comment to commit is supported of not + 'supports_onbox_diff': False, # identify if on box diff capability is supported or not + 'supports_generate_diff': True, # identify if diff capability is supported within plugin + 'supports_multiline_delimiter': False, # identify if multiline demiliter is supported within config + 'supports_diff_match': True, # identify if match is supported + 'supports_diff_ignore_lines': True, # identify if ignore line in diff is supported + 'supports_config_replace': False, # identify if running config replace with candidate config is supported + 'supports_admin': False, # identify if admin configure mode is supported or not + 'supports_commit_label': False # identify if commit label is supported or not + } + + def get_option_values(self): + return { + 'format': ['text', 'json'], + 'diff_match': ['line', 'strict', 'exact', 'none'], + 'diff_replace': ['line', 'block'], + 'output': ['text', 'json'] + } + + def get_capabilities(self): + result = {} + result['rpc'] = ['get_default_flag', 'run_commands', 'get_config', 'send_request', 'get_capabilities', 'get_diff'] + result['device_info'] = self.get_device_info() + result['device_operations'] = self.get_device_operations() + result.update(self.get_option_values()) + result['network_api'] = 'exosapi' + return json.dumps(result) + + def get_default_flag(self): + # The flag to modify the command to collect configuration with defaults + return 'detail' + + def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): + diff = {} + device_operations = self.get_device_operations() + option_values = self.get_option_values() + + if candidate is None and device_operations['supports_generate_diff']: + raise ValueError("candidate configuration is required to generate diff") + + if diff_match not in option_values['diff_match']: + raise ValueError("'match' value %s in invalid, valid values are %s" % (diff_match, ', '.join(option_values['diff_match']))) + + if diff_replace not in option_values['diff_replace']: + raise ValueError("'replace' value %s in invalid, valid values are %s" % (diff_replace, ', '.join(option_values['diff_replace']))) + + # prepare candidate configuration + candidate_obj = NetworkConfig(indent=1) + candidate_obj.load(candidate) + + if running and diff_match != 'none' and diff_replace != 'config': + # running configuration + running_obj = NetworkConfig(indent=1, contents=running, ignore_lines=diff_ignore_lines) + configdiffobjs = candidate_obj.difference(running_obj, path=path, match=diff_match, replace=diff_replace) + + else: + configdiffobjs = candidate_obj.items + + diff['config_diff'] = dumps(configdiffobjs, 'commands') if configdiffobjs else '' + return diff + + def get_config(self, source='running', format='text', flags=None): + options_values = self.get_option_values() + if format not in options_values['format']: + raise ValueError("'format' value %s is invalid. Valid values are %s" % (format, ','.join(options_values['format']))) + + lookup = {'running': 'show configuration', 'startup': 'debug cfgmgr show configuration file'} + if source not in lookup: + raise ValueError("fetching configuration from %s is not supported" % source) + + cmd = {'command': lookup[source], 'output': 'text'} + + if source == 'startup': + reply = self.run_commands({'command': 'show switch', 'format': 'text'}) + data = to_text(reply, errors='surrogate_or_strict').strip() + match = re.search(r'Config Selected: +(\S+)\.cfg', data, re.MULTILINE) + if match: + cmd['command'] += match.group(1) + else: + # No Startup(/Selected) Config + return {} + + cmd['command'] += ' '.join(to_list(flags)) + cmd['command'] = cmd['command'].strip() + + return self.run_commands(cmd)[0] + + +def request_builder(command, reqid=""): + return json.dumps(dict(jsonrpc='2.0', id=reqid, method='cli', params=to_list(command))) + + +def strip_run_script_cli2json(command): + if to_text(command, errors="surrogate_then_replace").startswith('run script cli2json.py'): + command = str(command).replace('run script cli2json.py', '') + return command + + +def getKeyInResponse(response, key): + keyOut = None + for item in response: + if key in item: + keyOut = item[key] + break + return keyOut diff --git a/test/units/modules/network/exos/fixtures/run_script_cli2json.py_show_lldp_neighbors b/test/units/modules/network/exos/fixtures/show_lldp_neighbors similarity index 100% rename from test/units/modules/network/exos/fixtures/run_script_cli2json.py_show_lldp_neighbors rename to test/units/modules/network/exos/fixtures/show_lldp_neighbors diff --git a/test/units/modules/network/exos/fixtures/run_script_cli2json.py_show_port_config b/test/units/modules/network/exos/fixtures/show_port_config similarity index 100% rename from test/units/modules/network/exos/fixtures/run_script_cli2json.py_show_port_config rename to test/units/modules/network/exos/fixtures/show_port_config diff --git a/test/units/modules/network/exos/fixtures/run_script_cli2json.py_show_port_description b/test/units/modules/network/exos/fixtures/show_port_description similarity index 100% rename from test/units/modules/network/exos/fixtures/run_script_cli2json.py_show_port_description rename to test/units/modules/network/exos/fixtures/show_port_description diff --git a/test/units/modules/network/exos/fixtures/run_script_cli2json.py_show_vlan b/test/units/modules/network/exos/fixtures/show_vlan similarity index 100% rename from test/units/modules/network/exos/fixtures/run_script_cli2json.py_show_vlan rename to test/units/modules/network/exos/fixtures/show_vlan diff --git a/test/units/modules/network/exos/fixtures/run_script_cli2json.py_show_vlan_detail b/test/units/modules/network/exos/fixtures/show_vlan_detail similarity index 100% rename from test/units/modules/network/exos/fixtures/run_script_cli2json.py_show_vlan_detail rename to test/units/modules/network/exos/fixtures/show_vlan_detail diff --git a/test/units/modules/network/exos/test_exos_config.py b/test/units/modules/network/exos/test_exos_config.py index aa90eed958..78b77a64c7 100644 --- a/test/units/modules/network/exos/test_exos_config.py +++ b/test/units/modules/network/exos/test_exos_config.py @@ -19,8 +19,9 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type -from units.compat.mock import patch +from units.compat.mock import patch, MagicMock from units.modules.utils import set_module_args +from ansible.plugins.cliconf.exos import Cliconf from ansible.modules.network.exos import exos_config from .exos_module import TestExosModule, load_fixture @@ -41,11 +42,22 @@ class TestExosConfigModule(TestExosModule): self.mock_run_commands = patch('ansible.modules.network.exos.exos_config.run_commands') self.run_commands = self.mock_run_commands.start() + self.mock_get_startup_config = patch('ansible.modules.network.exos.exos_config.get_startup_config') + self.get_startup_config = self.mock_get_startup_config.start() + + self.cliconf_obj = Cliconf(MagicMock()) + + self.mock_get_diff = patch('ansible.modules.network.exos.exos_config.get_diff') + self.get_diff = self.mock_get_diff.start() + + self.running_config = load_fixture('exos_config_config.cfg') + def tearDown(self): super(TestExosConfigModule, self).tearDown() self.mock_get_config.stop() self.mock_load_config.stop() self.mock_run_commands.stop() + self.mock_get_startup_config.stop() def load_fixtures(self, commands=None): config_file = 'exos_config_config.cfg' @@ -55,6 +67,7 @@ class TestExosConfigModule(TestExosModule): def test_exos_config_unchanged(self): src = load_fixture('exos_config_config.cfg') set_module_args(dict(src=src)) + self.get_diff.return_value = self.cliconf_obj.get_diff(src, src) self.execute_module() def test_exos_config_src(self): @@ -62,6 +75,7 @@ class TestExosConfigModule(TestExosModule): set_module_args(dict(src=src)) commands = ['configure ports 1 description-string "IDS"', 'configure snmp sysName "marble"'] + self.get_diff.return_value = self.cliconf_obj.get_diff(src, self.running_config) self.execute_module(changed=True, commands=commands) def test_exos_config_backup(self): @@ -84,6 +98,7 @@ class TestExosConfigModule(TestExosModule): set_module_args(dict(src=src, save_when='changed')) commands = ['configure ports 1 description-string "IDS"', 'configure snmp sysName "marble"'] + self.get_diff.return_value = self.cliconf_obj.get_diff(src, self.running_config) self.execute_module(changed=True, commands=commands) self.assertEqual(self.run_commands.call_count, 1) self.assertEqual(self.get_config.call_count, 1) @@ -96,6 +111,7 @@ class TestExosConfigModule(TestExosModule): set_module_args(dict(src=src, save_when='changed', _ansible_check_mode=True)) commands = ['configure ports 1 description-string "IDS"', 'configure snmp sysName "marble"'] + self.get_diff.return_value = self.cliconf_obj.get_diff(src, self.running_config) self.execute_module(changed=True, commands=commands) self.assertEqual(self.run_commands.call_count, 0) self.assertEqual(self.get_config.call_count, 1) @@ -109,65 +125,64 @@ class TestExosConfigModule(TestExosModule): self.assertEqual(self.load_config.call_count, 0) def test_exos_config_save_modified_false(self): - mock_get_startup_config_text = patch('ansible.modules.network.exos.exos_config.get_startup_config_text') - get_startup_config_text = mock_get_startup_config_text.start() - get_startup_config_text.return_value = load_fixture('exos_config_config.cfg') - + self.get_startup_config.return_value = load_fixture('exos_config_config.cfg') set_module_args(dict(save_when='modified')) self.execute_module(changed=False) self.assertEqual(self.run_commands.call_count, 0) self.assertEqual(self.get_config.call_count, 1) - self.assertEqual(get_startup_config_text.call_count, 1) + self.assertEqual(self.get_startup_config.call_count, 1) self.assertEqual(self.load_config.call_count, 0) - mock_get_startup_config_text.stop() - def test_exos_config_save_modified_true(self): - mock_get_startup_config_text = patch('ansible.modules.network.exos.exos_config.get_startup_config_text') - get_startup_config_text = mock_get_startup_config_text.start() - get_startup_config_text.return_value = load_fixture('exos_config_modified.cfg') - + self.get_startup_config.return_value = load_fixture('exos_config_modified.cfg') set_module_args(dict(save_when='modified')) self.execute_module(changed=True) self.assertEqual(self.run_commands.call_count, 1) self.assertTrue(self.get_config.call_count > 0) - self.assertEqual(get_startup_config_text.call_count, 1) + self.assertEqual(self.get_startup_config.call_count, 1) self.assertEqual(self.load_config.call_count, 0) - mock_get_startup_config_text.stop() - def test_exos_config_lines(self): - set_module_args(dict(lines=['configure snmp sysName "marble"'])) + lines = ['configure snmp sysName "marble"'] + set_module_args(dict(lines=lines)) commands = ['configure snmp sysName "marble"'] + self.get_diff.return_value = self.cliconf_obj.get_diff('\n'.join(lines), self.running_config) self.execute_module(changed=True, commands=commands) def test_exos_config_before(self): - set_module_args(dict(lines=['configure snmp sysName "marble"'], before=['test1', 'test2'])) + lines = ['configure snmp sysName "marble"'] + set_module_args(dict(lines=lines, before=['test1', 'test2'])) commands = ['test1', 'test2', 'configure snmp sysName "marble"'] + self.get_diff.return_value = self.cliconf_obj.get_diff('\n'.join(lines), self.running_config) self.execute_module(changed=True, commands=commands, sort=False) def test_exos_config_after(self): - set_module_args(dict(lines=['hostname foo'], after=['test1', 'test2'])) - commands = ['hostname foo', 'test1', 'test2'] - set_module_args(dict(lines=['configure snmp sysName "marble"'], after=['test1', 'test2'])) + lines = ['configure snmp sysName "marble"'] + set_module_args(dict(lines=lines, after=['test1', 'test2'])) commands = ['configure snmp sysName "marble"', 'test1', 'test2'] + self.get_diff.return_value = self.cliconf_obj.get_diff('\n'.join(lines), self.running_config) self.execute_module(changed=True, commands=commands, sort=False) def test_exos_config_before_after_no_change(self): - set_module_args(dict(lines=['configure snmp sysName "x870"'], + lines = ['configure snmp sysName "x870"'] + set_module_args(dict(lines=lines, before=['test1', 'test2'], after=['test3', 'test4'])) + self.get_diff.return_value = self.cliconf_obj.get_diff('\n'.join(lines), self.running_config) self.execute_module() def test_exos_config_config(self): config = 'hostname localhost' - set_module_args(dict(lines=['configure snmp sysName "x870"'], config=config)) + lines = ['configure snmp sysName "x870"'] + set_module_args(dict(lines=lines, config=config)) commands = ['configure snmp sysName "x870"'] + self.get_diff.return_value = self.cliconf_obj.get_diff('\n'.join(lines), config) self.execute_module(changed=True, commands=commands) def test_exos_config_match_none(self): lines = ['configure snmp sysName "x870"'] set_module_args(dict(lines=lines, match='none')) + self.get_diff.return_value = self.cliconf_obj.get_diff('\n'.join(lines), self.running_config, diff_match='none') self.execute_module(changed=True, commands=lines) def test_exos_config_src_and_lines_fails(self): @@ -208,39 +223,43 @@ class TestExosConfigModule(TestExosModule): self.execute_module(changed=False) def test_exos_diff_startup_unchanged(self): - mock_get_startup_config_text = patch('ansible.modules.network.exos.exos_config.get_startup_config_text') - get_startup_config_text = mock_get_startup_config_text.start() - get_startup_config_text.return_value = load_fixture('exos_config_config.cfg') + mock_get_startup_config = patch('ansible.modules.network.exos.exos_config.get_startup_config') + get_startup_config = mock_get_startup_config.start() + get_startup_config.return_value = load_fixture('exos_config_config.cfg') args = dict(diff_against='startup', _ansible_diff=True) set_module_args(args) self.execute_module(changed=False) - self.assertEqual(get_startup_config_text.call_count, 1) + self.assertEqual(get_startup_config.call_count, 1) - mock_get_startup_config_text.stop() + mock_get_startup_config.stop() def test_exos_diff_startup_changed(self): - mock_get_startup_config_text = patch('ansible.modules.network.exos.exos_config.get_startup_config_text') - get_startup_config_text = mock_get_startup_config_text.start() - get_startup_config_text.return_value = load_fixture('exos_config_modified.cfg') + mock_get_startup_config = patch('ansible.modules.network.exos.exos_config.get_startup_config') + get_startup_config = mock_get_startup_config.start() + get_startup_config.return_value = load_fixture('exos_config_modified.cfg') args = dict(diff_against='startup', _ansible_diff=True) set_module_args(args) self.execute_module(changed=True) - self.assertEqual(get_startup_config_text.call_count, 1) + self.assertEqual(get_startup_config.call_count, 1) - mock_get_startup_config_text.stop() + mock_get_startup_config.stop() def test_exos_diff_intended_unchanged(self): + intended_config = load_fixture('exos_config_config.cfg') args = dict(diff_against='intended', - intended_config=load_fixture('exos_config_config.cfg'), + intended_config=intended_config, _ansible_diff=True) set_module_args(args) + self.get_diff = self.cliconf_obj.get_diff(intended_config, self.running_config) self.execute_module(changed=False) def test_exos_diff_intended_modified(self): + intended_config = load_fixture('exos_config_modified.cfg') args = dict(diff_against='intended', - intended_config=load_fixture('exos_config_modified.cfg'), + intended_config=intended_config, _ansible_diff=True) set_module_args(args) + self.get_diff = self.cliconf_obj.get_diff(intended_config, self.running_config) self.execute_module(changed=True) diff --git a/test/units/modules/network/exos/test_exos_facts.py b/test/units/modules/network/exos/test_exos_facts.py index 74a219f730..59e101f466 100644 --- a/test/units/modules/network/exos/test_exos_facts.py +++ b/test/units/modules/network/exos/test_exos_facts.py @@ -20,9 +20,11 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type import os +import json from units.compat.mock import patch from units.modules.utils import set_module_args +from ansible.module_utils.common._collections_compat import Mapping from ansible.modules.network.exos import exos_facts from .exos_module import TestExosModule @@ -49,10 +51,18 @@ class TestExosFactsModule(TestExosModule): fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') for command in commands: + if isinstance(command, Mapping): + command = command['command'] filename = str(command).replace(' ', '_') filename = os.path.join(fixture_path, filename) with open(filename) as f: data = f.read() + + try: + data = json.loads(data) + except Exception: + pass + output.append(data) return output