From 0d1e9bfe90a2077ee0a07c649a9b4aa833f834a6 Mon Sep 17 00:00:00 2001 From: Rafael Vencioneck Date: Mon, 23 Apr 2018 14:53:09 -0300 Subject: [PATCH] Adding exos_command cli_conf module (#37775) * Adding exos_command cli_conf module * fixing documentation, indentation and metadata_version * removing doc fragmentation and adding required import * removing unnecessary code and including company name on short_description * updating BOTMETA.yml with exos module information --- .github/BOTMETA.yml | 10 + .../module_utils/network/exos/__init__.py | 0 lib/ansible/module_utils/network/exos/exos.py | 101 ++++++++ lib/ansible/modules/network/exos/__init__.py | 0 .../modules/network/exos/exos_command.py | 236 ++++++++++++++++++ lib/ansible/plugins/cliconf/exos.py | 98 ++++++++ lib/ansible/plugins/terminal/exos.py | 54 ++++ 7 files changed, 499 insertions(+) create mode 100644 lib/ansible/module_utils/network/exos/__init__.py create mode 100644 lib/ansible/module_utils/network/exos/exos.py create mode 100644 lib/ansible/modules/network/exos/__init__.py create mode 100644 lib/ansible/modules/network/exos/exos_command.py create mode 100644 lib/ansible/plugins/cliconf/exos.py create mode 100644 lib/ansible/plugins/terminal/exos.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 1c07a39fde..b80dcc2c75 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -456,6 +456,7 @@ files: $modules/network/edgeos/: samdoran $modules/network/enos/: amuraleedhar $modules/network/eos/: privateip trishnaguha + $modules/network/exos/: rdvencioneck $modules/network/f5/: ignored: Etienne-Carriere mhite mryanlam perzizzle srvg wojtek0806 JoeReifel maintainers: caphrim007 @@ -829,6 +830,9 @@ files: $module_utils/network/enos: maintainers: amuraleedhar labels: networking + $module_utils/network/exos: + maintainers: rdvencioneck + labels: networking $module_utils/f5_utils.py: maintainers: caphrim007 labels: @@ -957,6 +961,9 @@ files: lib/ansible/plugins/cliconf/: maintainers: $team_networking labels: networking + lib/ansible/plugins/cliconf/exos.py: + maintainers: rdvencioneck + labels: networking lib/ansible/plugins/cliconf/ironware.py: maintainers: paulquack labels: networking @@ -1041,6 +1048,9 @@ files: lib/ansible/plugins/terminal/eos.py: maintainers: $team_networking labels: networking + lib/ansible/plugins/terminal/exos.py: + maintainers: rdvencioneck + labels: networking lib/ansible/plugins/terminal/ios.py: maintainers: $team_networking labels: networking diff --git a/lib/ansible/module_utils/network/exos/__init__.py b/lib/ansible/module_utils/network/exos/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/ansible/module_utils/network/exos/exos.py b/lib/ansible/module_utils/network/exos/exos.py new file mode 100644 index 0000000000..9ed1ff64bf --- /dev/null +++ b/lib/ansible/module_utils/network/exos/exos.py @@ -0,0 +1,101 @@ +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is BSD licensed. +# Modules you write using this snippet, which is embedded dynamically by Ansible +# still belong to the author of the module, and may assign their own license +# to the complete work. +# +# (c) 2016 Red Hat Inc. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +import json +from ansible.module_utils._text import to_text +from ansible.module_utils.basic import env_fallback, return_values +from ansible.module_utils.network.common.utils import to_list +from ansible.module_utils.connection import Connection + +_DEVICE_CONFIGS = {} + + +def get_connection(module): + if hasattr(module, '_exos_connection'): + return module._exos_connection + + capabilities = get_capabilities(module) + network_api = capabilities.get('network_api') + + if network_api == 'cliconf': + module._exos_connection = Connection(module._socket_path) + else: + module.fail_json(msg='Invalid connection type %s' % network_api) + + return module._exos_connection + + +def get_capabilities(module): + if hasattr(module, '_exos_capabilities'): + return module._exos_capabilities + + capabilities = Connection(module._socket_path).get_capabilities() + module._exos_capabilities = json.loads(capabilities) + + return module._exos_capabilities + + +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 + + +def load_config(module, commands): + connection = get_connection(module) + out = connection.edit_config(commands) diff --git a/lib/ansible/modules/network/exos/__init__.py b/lib/ansible/modules/network/exos/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/ansible/modules/network/exos/exos_command.py b/lib/ansible/modules/network/exos/exos_command.py new file mode 100644 index 0000000000..0b002230a3 --- /dev/null +++ b/lib/ansible/modules/network/exos/exos_command.py @@ -0,0 +1,236 @@ +#!/usr/bin/python +# +# 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 + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = """ +--- +module: exos_command +version_added: "2.6" +author: "Rafael D. Vencioneck (@rdvencioneck)" +short_description: Run commands on remote devices running Extreme EXOS +description: + - Sends arbitrary commands to an Extreme EXOS device and returns the results + read from the device. This module includes an argument that will cause the + module to wait for a specific condition before returning or timing out if + the condition is not met. + - This module does not support running configuration commands. + Please use M(exos_config) to configure EXOS devices. +notes: + - If a command sent to the device requires answering a prompt, it is possible + to pass a dict containing I(command), I(answer) and I(prompt). See examples. +options: + commands: + description: + - List of commands to send to the remote EXOS device over the + configured provider. The resulting output from the command + is returned. If the I(wait_for) argument is provided, the + module is not returned until the condition is satisfied or + the number of retries has expired. + required: true + wait_for: + description: + - List of conditions to evaluate against the output of the + command. The task will wait for each condition to be true + before moving forward. If the conditional is not true + within the configured number of retries, the task fails. + See examples. + default: null + match: + description: + - The I(match) argument is used in conjunction with the + I(wait_for) argument to specify the match policy. Valid + values are C(all) or C(any). If the value is set to C(all) + then all conditionals in the wait_for must be satisfied. If + the value is set to C(any) then only one of the values must be + satisfied. + default: all + choices: ['any', 'all'] + retries: + description: + - Specifies the number of retries a command should by tried + before it is considered failed. The command is run on the + target device every retry and evaluated against the + I(wait_for) conditions. + default: 10 + interval: + description: + - Configures the interval in seconds to wait between retries + of the command. If the command does not pass the specified + conditions, the interval indicates how long to wait before + trying the command again. + default: 1 +""" + +EXAMPLES = """ +tasks: + - name: run show version on remote devices + exos_command: + commands: show version + - name: run show version and check to see if output contains ExtremeXOS + exos_command: + commands: show version + wait_for: result[0] contains ExtremeXOS + - name: run multiple commands on remote nodes + exos_command: + commands: + - show version + - show ports no-refresh + - name: run multiple commands and evaluate the output + exos_command: + commands: + - show version + - show ports no-refresh + wait_for: + - result[0] contains ExtremeXOS + - result[1] contains 20 + - name: run command that requires answering a prompt + exos_command: + commands: + - command: 'clear license-info' + prompt: 'Are you sure.*' + answer: 'Yes' +""" + +RETURN = """ +stdout: + description: The set of responses from the commands + returned: always apart from low level errors (such as action plugin) + type: list + sample: ['...', '...'] +stdout_lines: + description: The value of stdout split into a list + returned: always apart from low level errors (such as action plugin) + type: list + sample: [['...', '...'], ['...'], ['...']] +failed_conditions: + description: The list of conditionals that have failed + returned: failed + type: list + sample: ['...', '...'] +""" +import re +import time + +from ansible.module_utils.network.exos.exos import run_commands +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.network.common.utils import ComplexList +from ansible.module_utils.network.common.parsing import Conditional +from ansible.module_utils.six import string_types + + +def to_lines(stdout): + for item in stdout: + if isinstance(item, string_types): + item = str(item).split('\n') + yield item + + +def parse_commands(module, warnings): + command = ComplexList(dict( + command=dict(key=True), + prompt=dict(), + answer=dict() + ), module) + commands = command(module.params['commands']) + for item in list(commands): + command_split = re.match(r'^(\w*)(.*)$', item['command']) + if module.check_mode and not item['command'].startswith('show'): + warnings.append( + 'only show commands are supported when using check mode, not ' + 'executing `%s`' % item['command'] + ) + commands.remove(item) + elif command_split and command_split.group(1) not in ('check', 'clear', 'debug', 'history', + 'ls', 'mrinfo', 'mtrace', 'nslookup', + 'ping', 'rtlookup', 'show', 'traceroute'): + module.fail_json( + msg='some commands were not recognized. exos_command can only run read-only' + 'commands. For configuration commands, please use exos_config instead' + ) + return commands + + +def main(): + """main entry point for module execution + """ + argument_spec = dict( + commands=dict(type='list', required=True), + + wait_for=dict(type='list'), + match=dict(default='all', choices=['all', 'any']), + + retries=dict(default=10, type='int'), + interval=dict(default=1, type='int') + ) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True) + + result = {'changed': False} + + warnings = list() + commands = parse_commands(module, warnings) + result['warnings'] = warnings + + wait_for = module.params['wait_for'] or list() + conditionals = [Conditional(c) for c in wait_for] + + retries = module.params['retries'] + interval = module.params['interval'] + match = module.params['match'] + + while retries > 0: + responses = run_commands(module, commands) + + for item in list(conditionals): + if item(responses): + if match == 'any': + conditionals = list() + break + conditionals.remove(item) + + if not conditionals: + break + + time.sleep(interval) + retries -= 1 + + if conditionals: + failed_conditions = [item.raw for item in conditionals] + msg = 'One or more conditional statements have not be satisfied' + module.fail_json(msg=msg, failed_conditions=failed_conditions) + + result.update({ + 'changed': False, + 'stdout': responses, + 'stdout_lines': list(to_lines(responses)) + }) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/plugins/cliconf/exos.py b/lib/ansible/plugins/cliconf/exos.py new file mode 100644 index 0000000000..677b31a297 --- /dev/null +++ b/lib/ansible/plugins/cliconf/exos.py @@ -0,0 +1,98 @@ +# +# (c) 2017 Red Hat Inc. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import re +import json + +from itertools import chain + +from ansible.module_utils._text import to_bytes, to_text +from ansible.module_utils.network.common.utils import to_list +from ansible.plugins.cliconf import CliconfBase + + +class Cliconf(CliconfBase): + + def get_device_info(self): + device_info = {} + device_info['network_os'] = 'exos' + + reply = self.get(b'show switch detail') + 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_config(self, source='running', flags=None): + if source not in ('running', 'startup'): + return self.invalid_params("fetching configuration from %s is not supported" % source) + if source == 'running': + cmd = 'show configuration' + else: + cmd = 'debug cfgmgr show configuration file' + reply = self.get(b'show switch | include "Config Selected"') + data = to_text(reply, errors='surrogate_or_strict').strip() + match = re.search(r': +(\S+)\.cfg', data) + 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) + else: + command = cmd + prompt = None + answer = None + newline = True + self.send_command(to_bytes(command), to_bytes(prompt), to_bytes(answer), + False, newline) + + def get(self, command, prompt=None, answer=None, sendonly=False): + return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly) + + def get_capabilities(self): + result = {} + result['rpc'] = self.get_base_rpc() + result['network_api'] = 'cliconf' + result['device_info'] = self.get_device_info() + return json.dumps(result) diff --git a/lib/ansible/plugins/terminal/exos.py b/lib/ansible/plugins/terminal/exos.py new file mode 100644 index 0000000000..fd3c99e60d --- /dev/null +++ b/lib/ansible/plugins/terminal/exos.py @@ -0,0 +1,54 @@ +# +# (c) 2016 Red Hat Inc. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import re + +from ansible.errors import AnsibleConnectionFailure +from ansible.plugins.terminal import TerminalBase + + +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) + ] + + def on_open_shell(self): + try: + for cmd in (b'disable clipaging', b'configure cli columns 256'): + self._exec_cli_command(cmd) + except AnsibleConnectionFailure: + raise AnsibleConnectionFailure('unable to set terminal parameters')