From 406c9e01a23895b7fc41b4a09e19978ac43078da Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Wed, 1 Feb 2017 08:19:55 -0500 Subject: [PATCH] moves the module utils network cli transport into a separate module (#20921) This move will allow for the cli transport to go through a deprecation process as we transition to network_cli plugin --- lib/ansible/module_utils/eos_cli.py | 235 ++++++++++++++++++++++++++ lib/ansible/module_utils/ios_cli.py | 157 +++++++++++++++++ lib/ansible/module_utils/iosxr_cli.py | 168 ++++++++++++++++++ lib/ansible/module_utils/nxos_cli.py | 157 +++++++++++++++++ lib/ansible/module_utils/shell.py | 92 +++++----- lib/ansible/module_utils/vyos_cli.py | 161 ++++++++++++++++++ 6 files changed, 929 insertions(+), 41 deletions(-) create mode 100644 lib/ansible/module_utils/eos_cli.py create mode 100644 lib/ansible/module_utils/ios_cli.py create mode 100644 lib/ansible/module_utils/iosxr_cli.py create mode 100644 lib/ansible/module_utils/nxos_cli.py create mode 100644 lib/ansible/module_utils/vyos_cli.py diff --git a/lib/ansible/module_utils/eos_cli.py b/lib/ansible/module_utils/eos_cli.py new file mode 100644 index 00000000000..a4ec0092a4d --- /dev/null +++ b/lib/ansible/module_utils/eos_cli.py @@ -0,0 +1,235 @@ +# +# 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) 2017 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 re + +from ansible.module_utils.shell import CliBase +from ansible.module_utils.basic import env_fallback, get_exception +from ansible.module_utils.network_common import to_list +from ansible.module_utils.netcli import Command +from ansible.module_utils.six import iteritems +from ansible.module_utils.network import NetworkError + +_DEVICE_CONFIGS = {} +_DEVICE_CONNECTION = None + +eos_cli_argument_spec = { + 'host': dict(), + 'port': dict(type='int'), + + 'username': dict(fallback=(env_fallback, ['ANSIBLE_NET_USERNAME'])), + 'password': dict(fallback=(env_fallback, ['ANSIBLE_NET_PASSWORD']), no_log=True), + + 'authorize': dict(default=False, fallback=(env_fallback, ['ANSIBLE_NET_AUTHORIZE']), type='bool'), + 'auth_pass': dict(no_log=True, fallback=(env_fallback, ['ANSIBLE_NET_AUTH_PASS'])), + + 'timeout': dict(type='int', default=10), + + 'provider': dict(type='dict'), + + # deprecated in Ansible 2.3 + 'transport': dict(), +} + +def check_args(module, warnings): + provider = module.params['provider'] or {} + for key in ('host', 'username', 'password'): + if not module.params[key] and not provider.get(key): + module.fail_json(msg='missing required argument %s' % key) + + +class Cli(CliBase): + + CLI_PROMPTS_RE = [ + re.compile(r"[\r\n]?[\w+\-\.:\/\[\]]+(?:\([^\)]+\)){,3}(?:>|#) ?$"), + re.compile(r"\[\w+\@[\w\-\.]+(?: [^\]])\] ?[>#\$] ?$") + ] + + CLI_ERRORS_RE = [ + re.compile(r"% ?Error"), + re.compile(r"^% \w+", re.M), + re.compile(r"% ?Bad secret"), + re.compile(r"invalid input", re.I), + re.compile(r"(?:incomplete|ambiguous) command", re.I), + re.compile(r"connection timed out", re.I), + re.compile(r"[^\r\n]+ not found", re.I), + re.compile(r"'[^']' +returned error code: ?\d+"), + re.compile(r"[^\r\n]\/bin\/(?:ba)?sh") + ] + + NET_PASSWD_RE = re.compile(r"[\r\n]?password: $", re.I) + + def __init__(self, module): + self._module = module + super(Cli, self).__init__() + + provider = module.params.get('provider') or dict() + for key, value in iteritems(provider): + if key in ios_cli_argument_spec: + if module.params.get(key) is None and value is not None: + module.params[key] = value + + try: + self.connect() + except NetworkError: + exc = get_exception() + self._module.fail_json(msg=str(exc)) + + if module.params['authorize']: + self.authorize() + + def connect(self, params, **kwargs): + super(Cli, self).connect(params, kickstart=False, **kwargs) + self.shell.send('terminal length 0') + + def authorize(self, params, **kwargs): + passwd = params['auth_pass'] + if passwd: + self.execute(Command('enable', prompt=self.NET_PASSWD_RE, response=passwd)) + else: + self.execute('enable') + + +def connection(module): + global _DEVICE_CONNECTION + if not _DEVICE_CONNECTION: + cli = Cli(module) + _DEVICE_CONNECTION = cli + return _DEVICE_CONNECTION + +def check_authorization(module): + conn = connection(module) + for cmd in ['show clock', 'prompt()']: + rc, out, err = conn.exec_command(cmd) + return out.endswith('#') + +def supports_sessions(module): + conn = connection(module) + rc, out, err = conn.exec_command('show configuration sessions') + return rc == 0 + +def run_commands(module, commands): + """Run list of commands on remote device and return results + """ + conn = connection(module) + responses = list() + + for cmd in to_list(commands): + rc, out, err = conn.exec_command(cmd) + + if rc != 0: + module.fail_json(msg=err) + + try: + out = module.from_json(out) + except ValueError: + out = str(out).strip() + + responses.append(out) + return responses + +def send_config(module, commands): + conn = connection(module) + multiline = False + for command in to_list(commands): + if command == 'end': + pass + + if command.startswith('banner') or multiline: + multiline = True + command = module.jsonify({'command': command, 'sendonly': True}) + elif command == 'EOF' and multiline: + multiline = False + + rc, out, err = conn.exec_command(command) + if rc != 0: + return (rc, out, err) + return (rc, 'ok','') + + +def configure(module, commands): + """Sends configuration commands to the remote device + """ + if not check_authorization(module): + module.fail_json(msg='configuration operations require privilege escalation') + + conn = connection(module) + + rc, out, err = conn.exec_command('configure') + if rc != 0: + module.fail_json(msg='unable to enter configuration mode', output=err) + + rc, out, err = send_config(module, commands) + if rc != 0: + module.fail_json(msg=err) + + conn.exec_command('end') + return {} + +def load_config(module, commands, commit=False, replace=False): + """Loads the config commands onto the remote device + """ + if not check_authorization(module): + module.fail_json(msg='configuration operations require privilege escalation') + + use_session = os.getenv('ANSIBLE_EOS_USE_SESSIONS', True) + try: + use_session = int(use_session) + except ValueError: + pass + + if not all((bool(use_session), supports_sessions(module))): + return configure(module, commands) + + conn = connection(module) + session = 'ansible_%s' % int(time.time()) + result = {'session': session} + + rc, out, err = conn.exec_command('configure session %s' % session) + if rc != 0: + module.fail_json(msg='unable to enter configuration mode', output=err) + + if replace: + conn.exec_command('rollback clean-config', check_rc=True) + + rc, out, err = send_config(module, commands) + if rc != 0: + conn.exec_command('abort') + conn.fail_json(msg=err, commands=commands) + + rc, out, err = module.exec_command('show session-config diffs') + if rc == 0: + result['diff'] = out.strip() + + if commit: + conn.exec_command('commit') + else: + conn.exec_command('abort') + + return result diff --git a/lib/ansible/module_utils/ios_cli.py b/lib/ansible/module_utils/ios_cli.py new file mode 100644 index 00000000000..27e92b5219f --- /dev/null +++ b/lib/ansible/module_utils/ios_cli.py @@ -0,0 +1,157 @@ +# +# 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) 2017 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 re + +from ansible.module_utils.shell import CliBase +from ansible.module_utils.basic import env_fallback, get_exception +from ansible.module_utils.network_common import to_list +from ansible.module_utils.netcli import Command +from ansible.module_utils.six import iteritems +from ansible.module_utils.network import NetworkError + +_DEVICE_CONFIGS = {} +_DEVICE_CONNECTION = None + +ios_cli_argument_spec = { + 'host': dict(), + 'port': dict(type='int'), + + 'username': dict(fallback=(env_fallback, ['ANSIBLE_NET_USERNAME'])), + 'password': dict(fallback=(env_fallback, ['ANSIBLE_NET_PASSWORD']), no_log=True), + + 'authorize': dict(default=False, fallback=(env_fallback, ['ANSIBLE_NET_AUTHORIZE']), type='bool'), + 'auth_pass': dict(no_log=True, fallback=(env_fallback, ['ANSIBLE_NET_AUTH_PASS'])), + + 'timeout': dict(type='int', default=10), + + 'provider': dict(type='dict'), + + # deprecated in Ansible 2.3 + 'transport': dict(), +} + +def check_args(module): + provider = module.params['provider'] or {} + for key in ('host', 'username', 'password'): + if not module.params[key] and not provider.get(key): + module.fail_json(msg='missing required argument %s' % key) + +class Cli(CliBase): + + CLI_PROMPTS_RE = [ + re.compile(r"[\r\n]?[\w+\-\.:\/\[\]]+(?:\([^\)]+\)){,3}(?:>|#) ?$"), + re.compile(r"\[\w+\@[\w\-\.]+(?: [^\]])\] ?[>#\$] ?$") + ] + + CLI_ERRORS_RE = [ + re.compile(r"% ?Error"), + re.compile(r"% ?Bad secret"), + re.compile(r"invalid input", re.I), + re.compile(r"(?:incomplete|ambiguous) command", re.I), + re.compile(r"connection timed out", re.I), + re.compile(r"[^\r\n]+ not found", re.I), + ] + + NET_PASSWD_RE = re.compile(r"[\r\n]?password: $", re.I) + + def __init__(self, module): + self._module = module + super(Cli, self).__init__() + + provider = module.params.get('provider') or dict() + for key, value in iteritems(provider): + if key in ios_cli_argument_spec: + if module.params.get(key) is None and value is not None: + module.params[key] = value + + try: + self.connect() + except NetworkError: + exc = get_exception() + self._module.fail_json(msg=str(exc)) + + if module.params['authorize']: + self.authorize() + + def connect(self): + super(Cli, self).connect(self._module.params, kickstart=False) + self.shell.send('terminal length 0') + + def authorize(self): + passwd = self._module.params['auth_pass'] + self.execute(Command('enable', prompt=self.NET_PASSWD_RE, response=passwd)) + + +def connection(module): + global _DEVICE_CONNECTION + if not _DEVICE_CONNECTION: + cli = Cli(module) + _DEVICE_CONNECTION = cli + return _DEVICE_CONNECTION + + +def get_config(module, flags=[]): + cmd = 'show running-config ' + cmd += ' '.join(flags) + cmd = cmd.strip() + + try: + return _DEVICE_CONFIGS[cmd] + except KeyError: + conn = connection(module) + out = conn.exec_command(cmd) + cfg = str(out).strip() + _DEVICE_CONFIGS[cmd] = cfg + return cfg + +def run_commands(module, commands, check_rc=True): + responses = list() + conn = connection(module) + for cmd in to_list(commands): + rc, out, err = conn.exec_command(cmd) + if check_rc and rc != 0: + module.fail_json(msg=err, rc=rc) + responses.append(out) + return responses + +def load_config(module, commands): + conn = connection(module) + rc, out, err = conn.exec_command('configure terminal') + if rc != 0: + module.fail_json(msg='unable to enter configuration mode', err=err) + + for command in to_list(commands): + if command == 'end': + continue + rc, out, err = module.exec_command(command) + if rc != 0: + module.fail_json(msg=err, command=command, rc=rc) + + conn.exec_command('end') diff --git a/lib/ansible/module_utils/iosxr_cli.py b/lib/ansible/module_utils/iosxr_cli.py new file mode 100644 index 00000000000..00f8b429831 --- /dev/null +++ b/lib/ansible/module_utils/iosxr_cli.py @@ -0,0 +1,168 @@ +# 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) 2017 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 re + +from ansible.module_utils.shell import CliBase +from ansible.module_utils.basic import env_fallback, get_exception +from ansible.module_utils.network_common import to_list +from ansible.module_utils.netcli import Command +from ansible.module_utils.six import iteritems +from ansible.module_utils.network import NetworkError + +_DEVICE_CONFIGS = {} +_DEVICE_CONNECTION = None + +iosxr_cli_argument_spec = { + 'host': dict(), + 'port': dict(type='int'), + + 'username': dict(fallback=(env_fallback, ['ANSIBLE_NET_USERNAME'])), + 'password': dict(fallback=(env_fallback, ['ANSIBLE_NET_PASSWORD']), no_log=True), + + 'authorize': dict(default=False, fallback=(env_fallback, ['ANSIBLE_NET_AUTHORIZE']), type='bool'), + 'auth_pass': dict(no_log=True, fallback=(env_fallback, ['ANSIBLE_NET_AUTH_PASS'])), + + 'timeout': dict(type='int', default=10), + + 'provider': dict(type='dict'), + + # deprecated in Ansible 2.3 + 'transport': dict(), +} + +def check_args(module): + provider = module.params['provider'] or {} + for key in ('host', 'username', 'password'): + if not module.params[key] and not provider.get(key): + module.fail_json(msg='missing required argument %s' % key) + +class Cli(CliBase): + + CLI_PROMPTS_RE = [ + re.compile(r"[\r\n]?[\w+\-\.:\/\[\]]+(?:\([^\)]+\)){,3}(?:>|#) ?$"), + re.compile(r"\[\w+\@[\w\-\.]+(?: [^\]])\] ?[>#\$] ?$") + ] + + CLI_ERRORS_RE = [ + re.compile(r"% ?Error"), + re.compile(r"% ?Bad secret"), + re.compile(r"invalid input", re.I), + re.compile(r"(?:incomplete|ambiguous) command", re.I), + re.compile(r"connection timed out", re.I), + re.compile(r"[^\r\n]+ not found", re.I), + re.compile(r"'[^']' +returned error code: ?\d+"), + ] + + NET_PASSWD_RE = re.compile(r"[\r\n]?password: $", re.I) + + def __init__(self, module): + self._module = module + super(Cli, self).__init__() + + provider = self._module.params.get('provider') or dict() + for key, value in iteritems(provider): + if key in nxos_cli_argument_spec: + if self._module.params.get(key) is None and value is not None: + self._module.params[key] = value + + try: + self.connect() + except NetworkError: + exc = get_exception() + self._module.fail_json(msg=str(exc)) + + def connect(self, params, **kwargs): + super(Cli, self).connect(params, kickstart=False, **kwargs) + self.shell.send(['terminal length 0', 'terminal exec prompt no-timestamp']) + + +def connection(module): + global _DEVICE_CONNECTION + if not _DEVICE_CONNECTION: + cli = Cli(module) + _DEVICE_CONNECTION = cli + return _DEVICE_CONNECTION + + +def get_config(module, flags=[]): + cmd = 'show running-config ' + cmd += ' '.join(flags) + cmd = cmd.strip() + + try: + return _DEVICE_CONFIGS[cmd] + except KeyError: + conn = connection(module) + rc, out, err = conn.exec_command(cmd) + if rc != 0: + module.fail_json(msg='unable to retrieve current config', stderr=err) + cfg = str(out).strip() + _DEVICE_CONFIGS[cmd] = cfg + return cfg + +def run_commands(module, commands, check_rc=True): + responses = list() + for cmd in to_list(commands): + conn = connection(module) + rc, out, err = conn.exec_command(cmd) + if check_rc and rc != 0: + module.fail_json(msg=err, rc=rc) + responses.append(out) + return responses + +def load_config(module, commands, commit=False, replace=False, comment=None): + rc, out, err = conn.exec_command('configure terminal') + if rc != 0: + module.fail_json(msg='unable to enter configuration mode', err=err) + + failed = False + for command in to_list(commands): + if command == 'end': + pass + + conn = connection(module) + rc, out, err = conn.exec_command(command) + if rc != 0: + failed = True + break + + if failed: + conn.exec_command('abort') + module.fail_json(msg=err, commands=commands, rc=rc) + + rc, diff, err = conn.exec_command('show commit changes diff') + if commit: + cmd = 'commit' + if comment: + cmd += ' comment {0}'.format(comment) + else: + cmd = 'abort' + diff = None + conn.exec_command(cmd) + + return diff diff --git a/lib/ansible/module_utils/nxos_cli.py b/lib/ansible/module_utils/nxos_cli.py new file mode 100644 index 00000000000..65623b6abe5 --- /dev/null +++ b/lib/ansible/module_utils/nxos_cli.py @@ -0,0 +1,157 @@ +# +# 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) 2017 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 re + +from ansible.module_utils.shell import CliBase +from ansible.module_utils.basic import env_fallback, get_exception +from ansible.module_utils.network_common import to_list +from ansible.module_utils.netcli import Command +from ansible.module_utils.six import iteritems +from ansible.module_utils.network import NetworkError + +_DEVICE_CONFIGS = {} +_DEVICE_CONNECTION = None + +nxos_cli_argument_spec = { + 'host': dict(), + 'port': dict(type='int'), + + 'username': dict(fallback=(env_fallback, ['ANSIBLE_NET_USERNAME'])), + 'password': dict(fallback=(env_fallback, ['ANSIBLE_NET_PASSWORD']), no_log=True), + + 'authorize': dict(default=False, fallback=(env_fallback, ['ANSIBLE_NET_AUTHORIZE']), type='bool'), + 'auth_pass': dict(no_log=True, fallback=(env_fallback, ['ANSIBLE_NET_AUTH_PASS'])), + + 'timeout': dict(type='int', default=10), + + 'provider': dict(type='dict'), + + # deprecated in Ansible 2.3 + 'transport': dict(), +} + +def check_args(module, warnings): + provider = module.params['provider'] or {} + for key in ('host', 'username', 'password'): + if not module.params[key] and not provider.get(key): + module.fail_json(msg='missing required argument %s' % key) + +class Cli(CliBase): + + CLI_PROMPTS_RE = [ + re.compile(r'[\r\n]?[a-zA-Z]{1}[a-zA-Z0-9-]*[>|#|%](?:\s*)$'), + re.compile(r'[\r\n]?[a-zA-Z]{1}[a-zA-Z0-9-]*\(.+\)#(?:\s*)$') + ] + + CLI_ERRORS_RE = [ + re.compile(r"% ?Error"), + re.compile(r"^% \w+", re.M), + re.compile(r"% ?Bad secret"), + re.compile(r"invalid input", re.I), + re.compile(r"(?:incomplete|ambiguous) command", re.I), + re.compile(r"connection timed out", re.I), + re.compile(r"[^\r\n]+ not found", re.I), + re.compile(r"'[^']' +returned error code: ?\d+"), + re.compile(r"syntax error"), + re.compile(r"unknown command") + ] + + NET_PASSWD_RE = re.compile(r"[\r\n]?password: $", re.I) + + def __init__(self, module): + self._module = module + super(Cli, self).__init__() + + provider = self._module.params.get('provider') or dict() + for key, value in iteritems(provider): + if key in nxos_cli_argument_spec: + if self._module.params.get(key) is None and value is not None: + self._module.params[key] = value + + try: + self.connect() + except NetworkError: + exc = get_exception() + self._module.fail_json(msg=str(exc)) + + if module.params['authorize']: + self.authorize() + + def connect(self): + super(Cli, self).connect(self._module.params, kickstart=False) + self.shell.send('terminal length 0') + + +def connection(module): + global _DEVICE_CONNECTION + if not _DEVICE_CONNECTION: + cli = Cli(module) + _DEVICE_CONNECTION = cli + return _DEVICE_CONNECTION + + +def get_config(module, flags=[]): + cmd = 'show running-config ' + cmd += ' '.join(flags) + cmd = cmd.strip() + + try: + return _DEVICE_CONFIGS[cmd] + except KeyError: + conn = connection(module) + out = conn.exec_command(cmd) + cfg = str(out).strip() + _DEVICE_CONFIGS[cmd] = cfg + return cfg + +def run_commands(module, commands, check_rc=True): + responses = list() + conn = connection(module) + for cmd in to_list(commands): + rc, out, err = conn.exec_command(cmd) + if check_rc and rc != 0: + module.fail_json(msg=err, rc=rc) + responses.append(out) + return responses + +def load_config(module, commands): + conn = connection(module) + rc, out, err = conn.exec_command('configure') + if rc != 0: + module.fail_json(msg='unable to enter configuration mode', err=err) + + for command in to_list(commands): + if command == 'end': + continue + rc, out, err = module.exec_command(command) + if rc != 0: + module.fail_json(msg=err, command=command, rc=rc) + + conn.exec_command('end') diff --git a/lib/ansible/module_utils/shell.py b/lib/ansible/module_utils/shell.py index 0404fd13489..6bcccde3732 100644 --- a/lib/ansible/module_utils/shell.py +++ b/lib/ansible/module_utils/shell.py @@ -21,6 +21,7 @@ import re import socket import time import signal +import json try: import paramiko @@ -33,6 +34,7 @@ from ansible.module_utils.basic import get_exception from ansible.module_utils.network import NetworkError from ansible.module_utils.six import BytesIO from ansible.module_utils._text import to_native +from ansible.module_utils.network_common import to_list, ComplexDict ANSI_RE = [ re.compile(r'(\x1b\[\?1h\x1b=)'), @@ -40,14 +42,6 @@ ANSI_RE = [ re.compile(r'\x1b[^m]*m') ] -def to_list(val): - if isinstance(val, (list, tuple)): - return list(val) - elif val is not None: - return [val] - else: - return list() - class ShellError(Exception): @@ -127,6 +121,15 @@ class Shell(object): data = regex.sub('', data) return data + def to_command(self, obj): + cast = ComplexDict({ + 'command': dict(key=True), + 'output': dict(), + 'prompt': dict(), + 'response': dict() + }) + return cast(obj) + def alarm_handler(self, signum, frame): self.shell.close() raise ShellError('timeout trying to send command: %s' % self._history[-1]) @@ -143,45 +146,57 @@ class Shell(object): window = self.strip(recv.read().decode('utf8')) - if hasattr(cmd, 'prompt') and not handled: - handled = self.handle_prompt(window, cmd) + if cmd: + if 'prompt' in cmd and not handled: + handled = self.handle_prompt(window, cmd) try: if self.find_prompt(window): resp = self.strip(recv.getvalue().decode('utf8')) - return self.sanitize(cmd, resp) + if cmd: + resp = self.sanitize(cmd, resp) + return resp except ShellError: exc = get_exception() - exc.command = cmd + exc.command = cmd['command'] raise - def send(self, commands): - responses = list() + def send_command(self, command): try: - for command in to_list(commands): - signal.alarm(self._timeout) - self._history.append(str(command)) - cmd = '%s\r' % str(command) - self.shell.sendall(cmd) - if self._timeout == 0: - return - responses.append(self.receive(command)) + obj = self.to_command(command) - except socket.timeout: - raise ShellError("timeout trying to send command: %s" % cmd) + self._history.append(str(obj['command'])) + cmd = '%s\r' % str(obj['command']) - except socket.error: + self.shell.sendall(cmd) + + if self._timeout == 0: + return + + signal.alarm(self._timeout) + out = self.receive(obj) + signal.alarm(0) + + return (0, out, '') + except ShellError: exc = get_exception() - raise ShellError("problem sending command to host: %s" % to_native(exc)) + return (1, '', to_native(exc)) + def send(self, commands): + responses = list() + for command in to_list(commands): + rc, out, err = self.send_command(command) + if rc != 0: + raise ShellError(err) + responses.append(out) return responses def close(self): self.shell.close() def handle_prompt(self, resp, cmd): - prompt = to_list(cmd.prompt) - response = to_list(cmd.response) + prompt = to_list(cmd['prompt']) + response = to_list(cmd['response']) for pr, ans in zip(prompt, response): match = pr.search(resp) @@ -193,7 +208,7 @@ class Shell(object): def sanitize(self, cmd, resp): cleaned = [] for line in resp.splitlines(): - if line.lstrip().startswith(str(cmd)) or self.find_prompt(line): + if line.lstrip().startswith(cmd['command']) or self.find_prompt(line): continue cleaned.append(line) return "\n".join(cleaned) @@ -266,17 +281,12 @@ class CliBase(object): commands = [str(c) for c in commands] raise NetworkError(to_native(exc), commands=commands) + def exec_command(self, command): + try: + cmdobj = json.loads(command) + return self.shell.send_command(cmdobj) + except ValueError: + return (1, '', 'unable to parse request') + def run_commands(self, commands): return self.execute(to_list(commands)) - - def configure(self, commands): - raise NotImplementedError - - def get_config(self, **kwargs): - raise NotImplementedError - - def load_config(self, commands, **kwargs): - raise NotImplementedError - - def save_config(self): - raise NotImplementedError diff --git a/lib/ansible/module_utils/vyos_cli.py b/lib/ansible/module_utils/vyos_cli.py new file mode 100644 index 00000000000..81b2fd29f3a --- /dev/null +++ b/lib/ansible/module_utils/vyos_cli.py @@ -0,0 +1,161 @@ +# 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) 2017 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 re + +from ansible.module_utils.shell import CliBase +from ansible.module_utils.basic import env_fallback, get_exception +from ansible.module_utils.network_common import to_list +from ansible.module_utils.netcli import Command +from ansible.module_utils.six import iteritems +from ansible.module_utils.network import NetworkError + +_DEVICE_CONFIGS = {} +_DEVICE_CONNECTION = None + +vyos_cli_argument_spec = { + 'host': dict(), + 'port': dict(type='int'), + + 'username': dict(fallback=(env_fallback, ['ANSIBLE_NET_USERNAME'])), + 'password': dict(fallback=(env_fallback, ['ANSIBLE_NET_PASSWORD']), no_log=True), + + 'authorize': dict(default=False, fallback=(env_fallback, ['ANSIBLE_NET_AUTHORIZE']), type='bool'), + 'auth_pass': dict(no_log=True, fallback=(env_fallback, ['ANSIBLE_NET_AUTH_PASS'])), + + 'timeout': dict(type='int', default=10), + + 'provider': dict(type='dict'), + + # deprecated in Ansible 2.3 + 'transport': dict(), +} + +def check_args(module): + provider = module.params['provider'] or {} + for key in ('host', 'username', 'password'): + if not module.params[key] and not provider.get(key): + module.fail_json(msg='missing required argument %s' % key) + +class Cli(CliBase): + + CLI_PROMPTS_RE = [ + re.compile(r"[\r\n]?[\w+\-\.:\/\[\]]+(?:\([^\)]+\)){,3}(?:>|#) ?$"), + re.compile(r"\@[\w\-\.]+:\S+?[>#\$] ?$") + ] + + CLI_ERRORS_RE = [ + re.compile(r"\n\s*Invalid command:"), + re.compile(r"\nCommit failed"), + re.compile(r"\n\s+Set failed"), + ] + + def __init__(self, module): + self._module = module + super(Cli, self).__init__() + + provider = self._module.params.get('provider') or dict() + for key, value in iteritems(provider): + if key in nxos_cli_argument_spec: + if self._module.params.get(key) is None and value is not None: + self._module.params[key] = value + + try: + self.connect() + except NetworkError: + exc = get_exception() + self._module.fail_json(msg=str(exc)) + + def connect(self, params, **kwargs): + super(Cli, self).connect(params, kickstart=False, **kwargs) + self.shell.send('set terminal length 0') + +def connection(module): + global _DEVICE_CONNECTION + if not _DEVICE_CONNECTION: + cli = Cli(module) + _DEVICE_CONNECTION = cli + return _DEVICE_CONNECTION + +def get_config(module, target='commands'): + cmd = ' '.join(['show configuration', target]) + + try: + return _DEVICE_CONFIGS[cmd] + except KeyError: + conn = connection(module) + rc, out, err = conn.exec_command(cmd) + if rc != 0: + module.fail_json(msg='unable to retrieve current config', stderr=err) + cfg = str(out).strip() + _DEVICE_CONFIGS[cmd] = cfg + return cfg + +def run_commands(module, commands, check_rc=True): + responses = list() + for cmd in to_list(commands): + conn = connection(module) + rc, out, err = conn.exec_command(cmd) + if check_rc and rc != 0: + module.fail_json(msg=err, rc=rc) + responses.append(out) + return responses + +def load_config(module, commands, commit=False, comment=None, save=False): + commands.insert(0, 'configure') + + for cmd in to_list(commands): + conn = connection(module) + rc, out, err = conn.exec_command(cmd, check_rc=False) + if rc != 0: + # discard any changes in case of failure + conn.exec_command('exit discard') + module.fail_json(msg='configuration failed') + + diff = None + if module._diff: + rc, out, err = conn.exec_command('compare') + if not out.startswith('No changes'): + rc, out, err = conn.exec_command('show') + diff = str(out).strip() + + if commit: + cmd = 'commit' + if comment: + cmd += ' comment "%s"' % comment + conn.exec_command(cmd) + + if save: + conn.exec_command(cmd) + + if not commit: + conn.exec_command('exit discard') + else: + conn.exec_command('exit') + + if diff: + return diff