From f2cb44633aad25e8668997e088139ed5354c6f3d Mon Sep 17 00:00:00 2001 From: sushma-alethea <52454757+sushma-alethea@users.noreply.github.com> Date: Wed, 31 Jul 2019 20:01:41 +0530 Subject: [PATCH] Modules to manage ICX devices (#58969) * new module * new terminal * new terminal * new cliconf * cliconf * icx cliconf * icx_cliconf * icx test units module * icx units module * icx banner unit test * PR changes resolved * changes resolved * Changes Resolved * check_running_config changes resolved * added notes * removed icx rst * new commit * new changes * deleted icx rst * icx .rst * modified platform_index.rst * modified platform_index.rst * changes resolved * PR comments resolved * Update platform_index.rst PR comment resolved --- .github/BOTMETA.yml | 1 + .../rst/network/user_guide/platform_icx.rst | 69 ++++ .../rst/network/user_guide/platform_index.rst | 3 + .../module_utils/network/icx/__init__.py | 0 lib/ansible/module_utils/network/icx/icx.py | 69 ++++ lib/ansible/modules/network/icx/__init__.py | 0 lib/ansible/modules/network/icx/icx_banner.py | 216 ++++++++++++ lib/ansible/plugins/cliconf/icx.py | 315 ++++++++++++++++++ lib/ansible/plugins/terminal/icx.py | 81 +++++ test/units/modules/network/icx/__init__.py | 0 .../icx/fixtures/icx_banner_show_banner.txt | 16 + test/units/modules/network/icx/icx_module.py | 93 ++++++ .../modules/network/icx/test_icx_banner.py | 96 ++++++ 13 files changed, 959 insertions(+) create mode 100644 docs/docsite/rst/network/user_guide/platform_icx.rst create mode 100644 lib/ansible/module_utils/network/icx/__init__.py create mode 100644 lib/ansible/module_utils/network/icx/icx.py create mode 100644 lib/ansible/modules/network/icx/__init__.py create mode 100644 lib/ansible/modules/network/icx/icx_banner.py create mode 100644 lib/ansible/plugins/cliconf/icx.py create mode 100644 lib/ansible/plugins/terminal/icx.py create mode 100644 test/units/modules/network/icx/__init__.py create mode 100644 test/units/modules/network/icx/fixtures/icx_banner_show_banner.txt create mode 100644 test/units/modules/network/icx/icx_module.py create mode 100644 test/units/modules/network/icx/test_icx_banner.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index d82bd912d2..9daa9522dd 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -334,6 +334,7 @@ files: maintainers: $team_iosxr $modules/network/ironware/: paulquack $modules/network/junos/: Qalthos ganeshrn + $modules/network/icx/: sushma-alethea $modules/network/layer2/: $team_networking $modules/network/layer3/: $team_networking $modules/network/meraki/: &meraki diff --git a/docs/docsite/rst/network/user_guide/platform_icx.rst b/docs/docsite/rst/network/user_guide/platform_icx.rst new file mode 100644 index 0000000000..87a93bf7ca --- /dev/null +++ b/docs/docsite/rst/network/user_guide/platform_icx.rst @@ -0,0 +1,69 @@ +.. _icx_platform_options: + +*************************************** +ICX Platform Options +*************************************** + +ICX supports Enable Mode (Privilege Escalation). This page offers details on how to use Enable Mode on ICX in Ansible. + +.. 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** | | supported - use ``ansible_become: yes`` | +| | (Privilege Escalation) | | with ``ansible_become_method: enable`` | +| | | | and ``ansible_become_password:`` | ++---------------------------+-----------------------------------------------+ +| **Returned Data Format** | ``stdout[0].`` | ++---------------------------+-----------------------------------------------+ + + +Using CLI in Ansible +==================== + +Example CLI ``group_vars/icx.yml`` +---------------------------------- + +.. code-block:: yaml + + ansible_connection: network_cli + ansible_network_os: icx + ansible_user: myuser + ansible_password: !vault... + ansible_become: yes + ansible_become_method: enable + ansible_become_password: !vault... + ansible_ssh_common_args: '-o ProxyCommand="ssh -W %h:%p -q bastion01"' + + +- If you are using SSH keys (including an ssh-agent) you can remove the ``ansible_password`` configuration. +- If you are accessing your host directly (not through a bastion/jump host) you can remove the ``ansible_ssh_common_args`` configuration. +- If you are accessing your host through a bastion/jump host, you cannot include your SSH password in the ``ProxyCommand`` directive. To prevent secrets from leaking out (for example in ``ps`` output), SSH does not support providing passwords via environment variables. + +Example CLI Task +---------------- + +.. code-block:: yaml + + - name: Backup current switch config (icx) + icx_config: + backup: yes + register: backup_icx_location + when: ansible_network_os == 'icx' + +.. 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 300b8837c6..690c8a277e 100644 --- a/docs/docsite/rst/network/user_guide/platform_index.rst +++ b/docs/docsite/rst/network/user_guide/platform_index.rst @@ -17,6 +17,7 @@ Some Ansible Network platforms support multiple connection types, privilege esca platform_enos platform_eos platform_exos + platform_icx platform_ios platform_ironware platform_junos @@ -80,6 +81,8 @@ Settings by Platform +-------------------+-------------------------+-------------+---------+---------+----------+ | Pluribus Netvisor | ``netvisor`` | ✓ | | | | +-------------------+-------------------------+-------------+---------+---------+----------+ +| Ruckus ICX* | ``icx`` | ✓ | | | | ++-------------------+-------------------------+-------------+---------+---------+----------+ | VyOS* | ``vyos`` | ✓ | | | ✓ | +-------------------+-------------------------+-------------+---------+---------+----------+ | OS that supports | ```` | | ✓ | | ✓ | diff --git a/lib/ansible/module_utils/network/icx/__init__.py b/lib/ansible/module_utils/network/icx/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/ansible/module_utils/network/icx/icx.py b/lib/ansible/module_utils/network/icx/icx.py new file mode 100644 index 0000000000..9270f676da --- /dev/null +++ b/lib/ansible/module_utils/network/icx/icx.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Ansible Project +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +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, ConnectionError + +_DEVICE_CONFIGS = {} + + +def get_connection(module): + return Connection(module._socket_path) + + +def load_config(module, commands): + connection = get_connection(module) + + try: + resp = connection.edit_config(candidate=commands) + return resp.get('response') + except ConnectionError as exc: + module.fail_json(msg=to_text(exc)) + + +def run_commands(module, commands, check_rc=True): + connection = get_connection(module) + try: + return connection.run_commands(commands=commands, check_rc=check_rc) + except ConnectionError as exc: + module.fail_json(msg=to_text(exc)) + + +def exec_scp(module, command): + connection = Connection(module._socket_path) + return connection.scp(**command) + + +def get_config(module, flags=None, compare=None): + flag_str = ' '.join(to_list(flags)) + try: + return _DEVICE_CONFIGS[flag_str] + except KeyError: + connection = get_connection(module) + try: + out = connection.get_config(flags=flags, compare=compare) + except ConnectionError as exc: + module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + cfg = to_text(out, errors='surrogate_then_replace').strip() + _DEVICE_CONFIGS[flag_str] = cfg + return cfg + + +def check_args(module, warnings): + pass + + +def get_defaults_flag(module): + connection = get_connection(module) + try: + out = connection.get_defaults_flag() + except ConnectionError as exc: + module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + return to_text(out, errors='surrogate_then_replace').strip() diff --git a/lib/ansible/modules/network/icx/__init__.py b/lib/ansible/modules/network/icx/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/ansible/modules/network/icx/icx_banner.py b/lib/ansible/modules/network/icx/icx_banner.py new file mode 100644 index 0000000000..4fd52eddba --- /dev/null +++ b/lib/ansible/modules/network/icx/icx_banner.py @@ -0,0 +1,216 @@ +#!/usr/bin/python +# Copyright: 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': 'community'} + + +DOCUMENTATION = """ +--- +module: icx_banner +version_added: "2.9" +author: "Ruckus Wireless (@Commscope)" +short_description: Manage multiline banners on Ruckus ICX 7000 series switches +description: + - This will configure both login and motd banners on remote + ruckus ICX 7000 series switches. It allows playbooks to add or remove + banner text from the active running configuration. +notes: + - Tested against ICX 10.1 +options: + banner: + description: + - Specifies which banner should be configured on the remote device. + type: str + required: true + choices: ['motd', 'exec', 'incoming'] + text: + description: + - The banner text that should be + present in the remote device running configuration. + This argument accepts a multiline string, with no empty lines. + type: str + state: + description: + - Specifies whether or not the configuration is + present in the current devices active running configuration. + type: str + default: present + choices: ['present', 'absent'] + enterkey: + description: + - Specifies whether or not the motd configuration should accept + the require-enter-key + type: bool + default: no + check_running_config: + description: + - Check running configuration. This can be set as environment variable. + Module will use environment variable value(default:True), unless it is overriden, + by specifying it as module parameter. + type: bool + default: yes + +""" + +EXAMPLES = """ +- name: configure the motd banner + icx_banner: + banner: motd + text: | + this is my motd banner + that contains a multiline + string + state: present + +- name: remove the motd banner + icx_banner: + banner: motd + state: absent + +- name: configure require-enter-key for motd + icx_banner: + banner: motd + enterkey: True + +- name: remove require-enter-key for motd + icx_banner: + banner: motd + enterkey: False +""" + +RETURN = """ +commands: + description: The list of configuration mode commands to send to the device + returned: always + type: list + sample: + - banner motd + - this is my motd banner + - that contains a multiline + - string +""" + +import re +from ansible.module_utils._text import to_text +from ansible.module_utils.connection import exec_command +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible.module_utils.network.icx.icx import load_config, get_config +from ansible.module_utils.connection import Connection, ConnectionError + + +def map_obj_to_commands(updates, module): + commands = list() + state = module.params['state'] + want, have = updates + + if module.params['banner'] != 'motd' and module.params['enterkey']: + module.fail_json(msg=module.params['banner'] + " banner can have text only, got enterkey") + + if state == 'absent': + if 'text' in have.keys() and have['text']: + commands.append('no banner %s' % module.params['banner']) + if(module.params['enterkey'] is False): + commands.append('no banner %s require-enter-key' % module.params['banner']) + + elif state == 'present': + if module.params['text'] is None and module.params['enterkey'] is None: + module.fail_json(msg=module.params['banner'] + " one of the following is required: text, enterkey:only if motd") + + if module.params["banner"] == "motd" and want['enterkey'] != have['enterkey']: + if(module.params['enterkey']): + commands.append('banner %s require-enter-key' % module.params['banner']) + + if want['text'] and (want['text'] != have.get('text')): + module.params["enterkey"] = None + banner_cmd = 'banner %s' % module.params['banner'] + banner_cmd += ' $\n' + banner_cmd += module.params['text'].strip() + banner_cmd += '\n$' + commands.append(banner_cmd) + return commands + + +def map_config_to_obj(module): + compare = module.params.get('check_running_config') + obj = {'banner': module.params['banner'], 'state': 'absent', 'enterkey': False} + exec_command(module, 'skip') + output_text = '' + output_re = '' + out = get_config(module, flags=['| begin banner %s' + % module.params['banner']], compare=module.params['check_running_config']) + if out: + try: + output_re = re.search(r'banner %s( require-enter-key)' % module.params['banner'], out, re.S).group(0) + obj['enterkey'] = True + except BaseException: + pass + try: + output_text = re.search(r'banner %s (\$([^\$])+\$){1}' % module.params['banner'], out, re.S).group(1).strip('$\n') + except BaseException: + pass + + else: + output_text = None + if output_text: + obj['text'] = output_text + obj['state'] = 'present' + if module.params['check_running_config'] is False: + obj = {'banner': module.params['banner'], 'state': 'absent', 'enterkey': False, 'text': 'JUNK'} + return obj + + +def map_params_to_obj(module): + text = module.params['text'] + if text: + text = str(text).strip() + + return { + 'banner': module.params['banner'], + 'text': text, + 'state': module.params['state'], + 'enterkey': module.params['enterkey'] + } + + +def main(): + """entry point for module execution + """ + argument_spec = dict( + banner=dict(required=True, choices=['motd', 'exec', 'incoming']), + text=dict(), + enterkey=dict(type='bool'), + state=dict(default='present', choices=['present', 'absent']), + check_running_config=dict(default=True, type='bool', fallback=(env_fallback, ['ANSIBLE_CHECK_ICX_RUNNING_CONFIG'])) + ) + + required_one_of = [['text', 'enterkey', 'state']] + module = AnsibleModule(argument_spec=argument_spec, + required_one_of=required_one_of, + supports_check_mode=True) + + warnings = list() + results = {'changed': False} + + want = map_params_to_obj(module) + have = map_config_to_obj(module) + commands = map_obj_to_commands((want, have), module) + results['commands'] = commands + + if commands: + if not module.check_mode: + response = load_config(module, commands) + + results['changed'] = True + + module.exit_json(**results) + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/plugins/cliconf/icx.py b/lib/ansible/plugins/cliconf/icx.py new file mode 100644 index 0000000000..722388b1c1 --- /dev/null +++ b/lib/ansible/plugins/cliconf/icx.py @@ -0,0 +1,315 @@ +# Copyright: (c) 2019, 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 + +DOCUMENTATION = """ +--- +author: Ruckus Wireless (@Commscope) +cliconf: icx +short_description: Use icx cliconf to run command on Ruckus ICX platform +description: + - This icx plugin provides low level abstraction APIs for + sending and receiving CLI commands from Ruckus ICX network devices. +version_added: "2.9" +""" + + +import re +import time +import json +import os + +from itertools import chain +from ansible.errors import AnsibleConnectionFailure +from ansible.module_utils._text import to_text +from ansible.module_utils.six import iteritems +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, enable_mode +from ansible.module_utils.common._collections_compat import Mapping + + +class Cliconf(CliconfBase): + + @enable_mode + def get_config(self, source='running', flags=None, format=None, compare=None): + if source not in ('running', 'startup'): + raise ValueError("fetching configuration from %s is not supported" % source) + + if format: + raise ValueError("'format' value %s is not supported for get_config" % format) + + if not flags: + flags = [] + + if compare is False: + return '' + else: + if source == 'running': + cmd = 'show running-config ' + else: + cmd = 'show configuration ' + + cmd += ' '.join(to_list(flags)) + cmd = cmd.strip() + + return self.send_command(cmd) + + def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): + """ + 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 diff_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 diff_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 diff in json format. + { + 'config_diff': '', + 'banner_diff': {} + } + + """ + 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) + want_src, want_banners = self._extract_banners(candidate) + candidate_obj.load(want_src) + + if running and diff_match != 'none': + # running configuration + have_src, have_banners = self._extract_banners(running) + + running_obj = NetworkConfig(indent=1, contents=have_src, ignore_lines=diff_ignore_lines) + configdiffobjs = candidate_obj.difference(running_obj, path=path, match=diff_match, replace=diff_replace) + + else: + configdiffobjs = candidate_obj.items + have_banners = {} + + diff['config_diff'] = dumps(configdiffobjs, 'commands') if configdiffobjs else '' + + banners = self._diff_banners(want_banners, have_banners) + diff['banner_diff'] = banners if banners else {} + return diff + + @enable_mode + def edit_config(self, candidate=None, commit=True, replace=None, comment=None): + resp = {} + operations = self.get_device_operations() + self.check_edit_config_capability(operations, candidate, commit, replace, comment) + + results = [] + requests = [] + if commit: + prompt = self._connection.get_prompt() + if (b'(config-if' in prompt) or (b'(config' in prompt) or (b'(config-lag-if' in prompt): + self.send_command('end') + + self.send_command('configure terminal') + + for line in to_list(candidate): + if not isinstance(line, Mapping): + line = {'command': line} + + cmd = line['command'] + if cmd != 'end' and cmd[0] != '!': + results.append(self.send_command(**line)) + requests.append(cmd) + + self.send_command('end') + else: + raise ValueError('check mode is not supported') + + resp['request'] = requests + resp['response'] = results + return resp + + def get(self, command=None, prompt=None, answer=None, sendonly=False, output=None, check_all=False): + if not command: + raise ValueError('must provide value of command to execute') + if output: + raise ValueError("'output' value %s is not supported for get" % output) + + return self.send_command(command=command, prompt=prompt, answer=answer, sendonly=sendonly, check_all=check_all) + + def scp(self, command=None, scp_user=None, scp_pass=None): + if not command: + raise ValueError('must provide value of command to execute') + prompt = ["User name:", "Password:"] + if(scp_pass is None): + answer = [scp_user, self._connection._play_context.password] + else: + answer = [scp_user, scp_pass] + return self.send_command(command=command, prompt=prompt, answer=answer, sendonly=False, check_all=True) + + def get_device_info(self): + device_info = {} + + device_info['network_os'] = 'icx' + reply = self.get(command='show version') + data = to_text(reply, errors='surrogate_or_strict').strip() + + match = re.search(r'Version (\S+)', data) + if match: + device_info['network_os_version'] = match.group(1).strip(',') + + match = re.search(r'^Cisco (.+) \(revision', data, re.M) + if match: + device_info['network_os_model'] = match.group(1) + + match = re.search(r'^(.+) uptime', data, re.M) + if match: + device_info['network_os_hostname'] = match.group(1) + + return device_info + + 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': True, + 'supports_diff_match': True, + 'supports_diff_ignore_lines': True, + 'supports_generate_diff': True, + 'supports_replace': False + } + + def get_option_values(self): + return { + 'format': ['text'], + 'diff_match': ['line', 'strict', 'exact', 'none'], + 'diff_replace': ['line', 'block'], + 'output': [] + } + + def get_capabilities(self): + result = dict() + result['rpc'] = self.get_base_rpc() + ['edit_banner', 'get_diff', 'run_commands', 'get_defaults_flag'] + result['network_api'] = 'cliconf' + result['device_operations'] = self.get_device_operations() + result.update(self.get_option_values()) + return json.dumps(result) + + def edit_banner(self, candidate=None, multiline_delimiter="@", commit=True): + """ + Edit banner on remote device + :param banners: Banners to be loaded in json format + :param multiline_delimiter: Line delimiter for banner + :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 + """ + resp = {} + banners_obj = json.loads(candidate) + results = [] + requests = [] + if commit: + for key, value in iteritems(banners_obj): + key += ' %s' % multiline_delimiter + self.send_command('config terminal', sendonly=True) + for cmd in [key, value, multiline_delimiter]: + obj = {'command': cmd, 'sendonly': True} + results.append(self.send_command(**obj)) + requests.append(cmd) + + self.send_command('end', sendonly=True) + time.sleep(0.1) + results.append(self.send_command('\n')) + requests.append('\n') + + resp['request'] = requests + resp['response'] = results + + return resp + + 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: + raise ValueError("'output' value %s is not supported for run_commands" % output) + + try: + out = self.send_command(**cmd) + except AnsibleConnectionFailure as e: + if check_rc: + raise + out = getattr(e, 'err', to_text(e)) + + responses.append(out) + + return responses + + def _extract_banners(self, config): + banners = {} + banner_cmds = re.findall(r'^banner (\w+)', config, re.M) + for cmd in banner_cmds: + regex = r'banner %s \$(.+?)(?=\$)' % cmd + match = re.search(regex, config, re.S) + if match: + key = 'banner %s' % cmd + banners[key] = match.group(1).strip() + + for cmd in banner_cmds: + regex = r'banner %s \$(.+?)(?=\$)' % cmd + match = re.search(regex, config, re.S) + if match: + config = config.replace(str(match.group(1)), '') + + config = re.sub(r'banner \w+ \$\$', '!! banner removed', config) + return config, banners + + def _diff_banners(self, want, have): + candidate = {} + for key, value in iteritems(want): + if value != have.get(key): + candidate[key] = value + return candidate diff --git a/lib/ansible/plugins/terminal/icx.py b/lib/ansible/plugins/terminal/icx.py new file mode 100644 index 0000000000..e7d55549d8 --- /dev/null +++ b/lib/ansible/plugins/terminal/icx.py @@ -0,0 +1,81 @@ +# Copyright: (c) 2019, 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 + +import re + +from ansible.plugins.terminal import TerminalBase +from ansible.errors import AnsibleConnectionFailure +from ansible.module_utils._text import to_text, to_bytes +import json + + +class TerminalModule(TerminalBase): + + terminal_stdout_re = [ + re.compile(br"[\r\n]?[\w\+\-\.:\/\[\]]+(?:\([^\)]+\)){0,3}(?:[>#]) ?$") + ] + + terminal_stderr_re = [ + re.compile(br"% ?Error"), + re.compile(br"% ?Bad secret"), + re.compile(br"[\r\n%] Bad passwords"), + re.compile(br"invalid input", re.I), + re.compile(br"(?:incomplete|ambiguous) command", re.I), + re.compile(br"connection timed out", re.I), + re.compile(br"[^\r\n]+ not found"), + re.compile(br"'[^']' +returned error code: ?\d+"), + re.compile(br"Bad mask", re.I), + re.compile(br"% ?(\S+) ?overlaps with ?(\S+)", re.I), + re.compile(br"[%\S] ?Error: ?[\s]+", re.I), + re.compile(br"[%\S] ?Informational: ?[\s]+", re.I), + re.compile(br"Command authorization failed"), + re.compile(br"Error - *"), + re.compile(br"Error - Incorrect username or password."), + re.compile(br"Invalid input"), + re.compile(br"Already a http operation is in progress"), + re.compile(br"Flash access in progress. Please try later"), + re.compile(br"Error: .*"), + re.compile(br"^Error: .*", re.I), + re.compile(br"^Ambiguous input"), + re.compile(br"Errno") + ] + + def on_open_shell(self): + pass + + def __del__(self): + try: + self.close() + except AnsibleConnectionFailure: + raise AnsibleConnectionFailure('unable to set terminal parameters') + + def on_become(self, passwd=None): + if self._get_prompt().endswith(b'#'): + return + + cmd = {u'command': u'enable'} + cmd[u'prompt'] = to_text(r"[\r\n](?:Local_)?[Pp]assword: ?$", errors='surrogate_or_strict') + cmd[u'answer'] = passwd + cmd[u'prompt_retry_check'] = True + try: + self._exec_cli_command(to_bytes(json.dumps(cmd), errors='surrogate_or_strict')) + prompt = self._get_prompt() + if prompt is None or not prompt.endswith(b'#'): + raise AnsibleConnectionFailure('failed to elevate privilege to enable mode still at prompt [%s]' % prompt) + except AnsibleConnectionFailure as e: + prompt = self._get_prompt() + raise AnsibleConnectionFailure('unable to elevate privilege to enable mode, at prompt [%s] with error: %s' % (prompt, e.message)) + + def on_unbecome(self): + prompt = self._get_prompt() + if prompt is None: + return + + if b'(config' in prompt: + self._exec_cli_command(b'exit') + + elif prompt.endswith(b'#'): + self._exec_cli_command(b'exit') diff --git a/test/units/modules/network/icx/__init__.py b/test/units/modules/network/icx/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/units/modules/network/icx/fixtures/icx_banner_show_banner.txt b/test/units/modules/network/icx/fixtures/icx_banner_show_banner.txt new file mode 100644 index 0000000000..4847885148 --- /dev/null +++ b/test/units/modules/network/icx/fixtures/icx_banner_show_banner.txt @@ -0,0 +1,16 @@ +banner motd require-enter-key +banner motd $ +welcome +new user +$ +! +interface ethernet 1/1/1 + port-name port name + disable + speed-duplex 10-full + inline power power-limit 7000 +! +interface ethernet 1/1/2 + speed-duplex 10-full + inline power power-limit 3000 +! \ No newline at end of file diff --git a/test/units/modules/network/icx/icx_module.py b/test/units/modules/network/icx/icx_module.py new file mode 100644 index 0000000000..f3f9e92f2a --- /dev/null +++ b/test/units/modules/network/icx/icx_module.py @@ -0,0 +1,93 @@ +# Copyright: (c) 2019, 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 + +import os +import json + +from units.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase + + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +def load_fixture(name): + path = os.path.join(fixture_path, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as f: + data = f.read() + + try: + data = json.loads(data) + except Exception: + pass + + fixture_data[path] = data + return data + + +class TestICXModule(ModuleTestCase): + ENV_ICX_USE_DIFF = True + + def set_running_config(self): + self.ENV_ICX_USE_DIFF = self.get_running_config() + + def get_running_config(self, compare=None): + if compare is not None: + diff = compare + elif os.environ.get('ANSIBLE_CHECK_ICX_RUNNING_CONFIG') is not None: + if os.environ.get('ANSIBLE_CHECK_ICX_RUNNING_CONFIG') == 'False': + diff = False + else: + diff = True + else: + diff = True + return diff + + def execute_module(self, failed=False, changed=False, commands=None, sort=True, defaults=False, fields=None): + + self.load_fixtures(commands) + + if failed: + result = self.failed() + self.assertTrue(result['failed'], result) + else: + result = self.changed(changed) + self.assertEqual(result['changed'], changed, result) + + if commands is not None: + if sort: + self.assertEqual(sorted(commands), sorted(result['commands'])) + else: + self.assertEqual(commands, result['commands'], result['commands']) + + if fields is not None: + for key in fields: + if fields.get(key) is not None: + self.assertEqual(fields.get(key), result.get(key)) + + return result + + def failed(self): + with self.assertRaises(AnsibleFailJson) as exc: + self.module.main() + + result = exc.exception.args[0] + self.assertTrue(result['failed'], result) + return result + + def changed(self, changed=False): + with self.assertRaises(AnsibleExitJson) as exc: + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], changed, result) + return result + + def load_fixtures(self, commands=None): + pass diff --git a/test/units/modules/network/icx/test_icx_banner.py b/test/units/modules/network/icx/test_icx_banner.py new file mode 100644 index 0000000000..4217410725 --- /dev/null +++ b/test/units/modules/network/icx/test_icx_banner.py @@ -0,0 +1,96 @@ +# Copyright: (c) 2019, 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 +from units.compat.mock import patch +from ansible.modules.network.icx import icx_banner +from units.modules.utils import set_module_args +from .icx_module import TestICXModule, load_fixture + + +class TestICXBannerModule(TestICXModule): + + module = icx_banner + + def setUp(self): + super(TestICXBannerModule, self).setUp() + self.mock_exec_command = patch('ansible.modules.network.icx.icx_banner.exec_command') + self.exec_command = self.mock_exec_command.start() + + self.mock_load_config = patch('ansible.modules.network.icx.icx_banner.load_config') + self.load_config = self.mock_load_config.start() + + self.mock_get_config = patch('ansible.modules.network.icx.icx_banner.get_config') + self.get_config = self.mock_get_config.start() + + self.set_running_config() + + def tearDown(self): + super(TestICXBannerModule, self).tearDown() + self.mock_exec_command.stop() + self.mock_load_config.stop() + self.mock_get_config.stop() + + def load_fixtures(self, commands=None): + compares = None + + def load_file(*args, **kwargs): + module = args + for arg in args: + if arg.params['check_running_config'] is True: + return load_fixture('icx_banner_show_banner.txt').strip() + else: + return '' + + self.exec_command.return_value = (0, '', None) + self.get_config.side_effect = load_file + self.load_config.return_value = dict(diff=None, session='session') + + def test_icx_banner_create(self): + if not self.ENV_ICX_USE_DIFF: + set_module_args(dict(banner='motd', text='welcome\nnew user')) + commands = ['banner motd $\nwelcome\nnew user\n$'] + self.execute_module(changed=True, commands=commands) + else: + for banner_type in ('motd', 'exec', 'incoming'): + set_module_args(dict(banner=banner_type, text='test\nbanner\nstring')) + commands = ['banner {0} $\ntest\nbanner\nstring\n$'.format(banner_type)] + self.execute_module(changed=True, commands=commands) + + def test_icx_banner_remove(self): + set_module_args(dict(banner='motd', state='absent')) + if not self.ENV_ICX_USE_DIFF: + commands = ['no banner motd'] + self.execute_module(changed=True, commands=commands) + else: + commands = ['no banner motd'] + self.execute_module(changed=True, commands=commands) + + def test_icx_banner_motd_enter_set(self): + set_module_args(dict(banner='motd', enterkey=True)) + + if not self.ENV_ICX_USE_DIFF: + commands = ['banner motd require-enter-key'] + self.execute_module(changed=True, commands=commands) + else: + self.execute_module(changed=False) + + def test_icx_banner_motd_enter_remove(self): + set_module_args(dict(banner='motd', state='absent', enterkey=False)) + if not self.ENV_ICX_USE_DIFF: + commands = ['no banner motd', 'no banner motd require-enter-key'] + self.execute_module(changed=True, commands=commands) + + else: + commands = ['no banner motd', 'no banner motd require-enter-key'] + self.execute_module(changed=True, commands=commands) + + def test_icx_banner_remove_compare(self): + set_module_args(dict(banner='incoming', state='absent', check_running_config='True')) + if self.get_running_config(compare=True): + if not self.ENV_ICX_USE_DIFF: + commands = [] + self.execute_module(changed=False, commands=commands) + else: + commands = [] + self.execute_module()