From 02572cb9ec94462261c035b33de506e4e505b3ca Mon Sep 17 00:00:00 2001 From: Mike Wiebe Date: Thu, 29 Aug 2019 15:57:39 -0400 Subject: Rewrite nxos_file_copy as an action plugin (#60643) * Initial nxos_file_copy action plugin work * Remove code from nxos_file_copy module * Add file_push and file_pull support * Additional refactoring and shipable updates * Simplify outcomes and update doc header * Add more error data information for easier debugging * Reorder outcomes and add additional tests * Capture more data for permission denied outcome --- lib/ansible/modules/network/nxos/nxos_file_copy.py | 346 ++------------- lib/ansible/plugins/action/nxos_file_copy.py | 475 +++++++++++++++++++++ .../targets/nxos_file_copy/meta/main.yml | 5 +- .../targets/nxos_file_copy/tasks/cli.yaml | 6 - .../nxos_file_copy/tests/cli/input_validation.yaml | 65 +++ .../targets/nxos_file_copy/tests/cli/negative.yaml | 133 ++++++ .../targets/nxos_file_copy/tests/cli/sanity.yaml | 110 +++-- .../nxos_file_copy/tests/nxapi/badtransport.yaml | 2 +- test/sanity/ignore.txt | 6 - 9 files changed, 793 insertions(+), 355 deletions(-) create mode 100644 lib/ansible/plugins/action/nxos_file_copy.py create mode 100644 test/integration/targets/nxos_file_copy/tests/cli/input_validation.yaml create mode 100644 test/integration/targets/nxos_file_copy/tests/cli/negative.yaml diff --git a/lib/ansible/modules/network/nxos/nxos_file_copy.py b/lib/ansible/modules/network/nxos/nxos_file_copy.py index 0a915a285a..5315a90c5a 100644 --- a/lib/ansible/modules/network/nxos/nxos_file_copy.py +++ b/lib/ansible/modules/network/nxos/nxos_file_copy.py @@ -36,10 +36,11 @@ description: author: - Jason Edelman (@jedelman8) - Gabriele Gerbino (@GGabriele) + - Rewritten as a plugin by (@mikewiebe) notes: - Tested against NXOS 7.0(3)I2(5), 7.0(3)I4(6), 7.0(3)I5(3), 7.0(3)I6(1), 7.0(3)I7(3), 6.0(2)A8(8), 7.0(3)F3(4), 7.3(0)D1(1), - 8.3(0) + 8.3(0), 9.2, 9.3 - When pushing files (file_pull is False) to the NXOS device, feature scp-server must be enabled. - When pulling files (file_pull is True) to the NXOS device, @@ -56,7 +57,7 @@ options: description: - When (file_pull is False) this is the path to the local file on the Ansible controller. The local directory must exist. - - When (file_pull is True) this is the file name used on the NXOS device. + - When (file_pull is True) this is the target file name on the NXOS device. remote_file: description: - When (file_pull is False) this is the remote file path on the NXOS device. @@ -66,13 +67,13 @@ options: server to be copied to the NXOS device. file_system: description: - - The remote file system of the device. If omitted, + - The remote file system on the nxos device. If omitted, devices that support a I(file_system) parameter will use their default values. default: "bootflash:" connect_ssh_port: description: - - SSH port to connect to server during transfer of file + - SSH server port used for file transfer. default: 22 version_added: "2.5" file_pull: @@ -85,33 +86,53 @@ options: type: bool default: False version_added: "2.7" + file_pull_compact: + description: + - When file_pull is True, this is used to compact nxos image files. + This option can only be used with nxos image files. + - When (file_pull is False), this is not used. + type: bool + default: False + version_added: "2.9" + file_pull_kstack: + description: + - When file_pull is True, this can be used to speed up file copies when + the nxos running image supports the use-kstack option. + - When (file_pull is False), this is not used. + type: bool + default: False + version_added: "2.9" local_file_directory: description: - When (file_pull is True) file is copied from a remote SCP server to the NXOS device, and written to this directory on the NXOS device. If the directory does not exist, it will be created under the file_system. This is an optional parameter. - - When (file_pull is False), this not used. + - When (file_pull is False), this is not used. version_added: "2.7" file_pull_timeout: description: - Use this parameter to set timeout in seconds, when transferring large files or when the network is slow. + - When (file_pull is False), this is not used. default: 300 version_added: "2.7" remote_scp_server: description: - - The remote scp server address which is used to pull the file. + - The remote scp server address when file_pull is True. This is required if file_pull is True. + - When (file_pull is False), this is not used. version_added: "2.7" remote_scp_server_user: description: - - The remote scp server username which is used to pull the file. + - The remote scp server username when file_pull is True. This is required if file_pull is True. + - When (file_pull is False), this is not used. version_added: "2.7" remote_scp_server_password: description: - - The remote scp server password which is used to pull the file. + - The remote scp server password when file_pull is True. This is required if file_pull is True. + - When (file_pull is False), this is not used. version_added: "2.7" vrf: description: @@ -143,8 +164,7 @@ EXAMPLES = ''' RETURN = ''' transfer_status: - description: Whether a file was transferred. "No Transfer" or "Sent". - If file_pull is successful, it is set to "Received". + description: Whether a file was transferred to the nxos device. returned: success type: str sample: 'Sent' @@ -158,300 +178,14 @@ remote_file: returned: success type: str sample: '/path/to/remote/file' +remote_scp_server: + description: The name of the scp server when file_pull is True. + returned: success + type: str + sample: 'fileserver.example.com' +changed: + description: Indicates wheather or not the file was copied. + returned: success + type: bool + sample: true ''' - -import hashlib -import os -import re -import time -import traceback - -from ansible.module_utils.compat.paramiko import paramiko -from ansible.module_utils.network.nxos.nxos import run_commands -from ansible.module_utils.network.nxos.nxos import nxos_argument_spec, check_args -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils._text import to_native, to_text, to_bytes - -try: - from scp import SCPClient - HAS_SCP = True -except ImportError: - HAS_SCP = False - -try: - import pexpect - HAS_PEXPECT = True -except ImportError: - HAS_PEXPECT = False - - -def md5sum_check(module, dst, file_system): - command = 'show file {0}{1} md5sum'.format(file_system, dst) - remote_filehash = run_commands(module, {'command': command, 'output': 'text'})[0] - remote_filehash = to_bytes(remote_filehash, errors='surrogate_or_strict') - - local_file = module.params['local_file'] - try: - with open(local_file, 'rb') as f: - filecontent = f.read() - except (OSError, IOError) as exc: - module.fail_json(msg="Error reading the file: %s" % to_text(exc)) - - filecontent = to_bytes(filecontent, errors='surrogate_or_strict') - local_filehash = hashlib.md5(filecontent).hexdigest() - - if local_filehash == remote_filehash: - return True - else: - return False - - -def remote_file_exists(module, dst, file_system='bootflash:'): - command = 'dir {0}/{1}'.format(file_system, dst) - body = run_commands(module, {'command': command, 'output': 'text'})[0] - if 'No such file' in body: - return False - else: - return md5sum_check(module, dst, file_system) - - -def verify_remote_file_exists(module, dst, file_system='bootflash:'): - command = 'dir {0}/{1}'.format(file_system, dst) - body = run_commands(module, {'command': command, 'output': 'text'})[0] - if 'No such file' in body: - return 0 - return body.split()[0].strip() - - -def local_file_exists(module): - return os.path.isfile(module.params['local_file']) - - -def get_flash_size(module): - command = 'dir {0}'.format(module.params['file_system']) - body = run_commands(module, {'command': command, 'output': 'text'})[0] - - match = re.search(r'(\d+) bytes free', body) - bytes_free = match.group(1) - - return int(bytes_free) - - -def enough_space(module): - flash_size = get_flash_size(module) - file_size = os.path.getsize(module.params['local_file']) - if file_size > flash_size: - return False - - return True - - -def transfer_file_to_device(module, dest): - file_size = os.path.getsize(module.params['local_file']) - - if not enough_space(module): - module.fail_json(msg='Could not transfer file. Not enough space on device.') - - hostname = module.params['host'] - username = module.params['username'] - password = module.params['password'] - port = module.params['connect_ssh_port'] - - ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh.connect( - hostname=hostname, - username=username, - password=password, - port=port) - - full_remote_path = '{0}{1}'.format(module.params['file_system'], dest) - scp = SCPClient(ssh.get_transport()) - try: - scp.put(module.params['local_file'], full_remote_path) - except Exception: - time.sleep(10) - temp_size = verify_remote_file_exists( - module, dest, file_system=module.params['file_system']) - if int(temp_size) == int(file_size): - pass - else: - module.fail_json(msg='Could not transfer file. There was an error ' - 'during transfer. Please make sure remote ' - 'permissions are set.', temp_size=temp_size, - file_size=file_size) - scp.close() - ssh.close() - return True - - -def copy_file_from_remote(module, local, local_file_directory, file_system='bootflash:'): - hostname = module.params['host'] - username = module.params['username'] - password = module.params['password'] - port = module.params['connect_ssh_port'] - - try: - child = pexpect.spawn('ssh ' + username + '@' + hostname + ' -p' + str(port)) - # response could be unknown host addition or Password - index = child.expect(['yes', '(?i)Password', '#']) - if index == 0: - child.sendline('yes') - child.expect('(?i)Password') - if index == 1: - child.sendline(password) - child.expect('#') - ldir = '/' - if local_file_directory: - dir_array = local_file_directory.split('/') - for each in dir_array: - if each: - child.sendline('mkdir ' + ldir + each) - child.expect('#') - ldir += each + '/' - - cmdroot = 'copy scp://' - ruser = module.params['remote_scp_server_user'] + '@' - rserver = module.params['remote_scp_server'] - rfile = module.params['remote_file'] + ' ' - vrf = ' vrf ' + module.params['vrf'] - command = (cmdroot + ruser + rserver + rfile + file_system + ldir + local + vrf) - - child.sendline(command) - # response could be remote host connection time out, - # there is already an existing file with the same name, - # unknown host addition or password - index = child.expect(['timed out', 'existing', 'yes', '(?i)password'], timeout=180) - if index == 0: - module.fail_json(msg='Timeout occured due to remote scp server not responding') - elif index == 1: - child.sendline('y') - # response could be unknown host addition or Password - sub_index = child.expect(['yes', '(?i)password']) - if sub_index == 0: - child.sendline('yes') - child.expect('(?i)password') - elif index == 2: - child.sendline('yes') - child.expect('(?i)password') - child.sendline(module.params['remote_scp_server_password']) - fpt = module.params['file_pull_timeout'] - # response could be that there is no space left on device, - # permission denied due to wrong user/password, - # remote file non-existent or success, - # timeout due to large file transfer or network too slow, - # success - index = child.expect(['No space', 'Permission denied', 'No such file', pexpect.TIMEOUT, '#'], timeout=fpt) - if index == 0: - module.fail_json(msg='File copy failed due to no space left on the device') - elif index == 1: - module.fail_json(msg='Username/Password for remote scp server is wrong') - elif index == 2: - module.fail_json(msg='File copy failed due to remote file not present') - elif index == 3: - module.fail_json(msg='Timeout occured, please increase "file_pull_timeout" and try again!') - except pexpect.ExceptionPexpect as e: - module.fail_json(msg='%s' % to_native(e), exception=traceback.format_exc()) - - child.close() - - -def main(): - argument_spec = dict( - local_file=dict(type='str'), - remote_file=dict(type='str'), - file_system=dict(required=False, default='bootflash:'), - connect_ssh_port=dict(required=False, type='int', default=22), - file_pull=dict(type='bool', default=False), - file_pull_timeout=dict(type='int', default=300), - local_file_directory=dict(required=False, type='str'), - remote_scp_server=dict(type='str'), - remote_scp_server_user=dict(type='str'), - remote_scp_server_password=dict(no_log=True), - vrf=dict(required=False, type='str', default='management'), - ) - - argument_spec.update(nxos_argument_spec) - - required_if = [("file_pull", True, ["remote_file", "remote_scp_server"]), - ("file_pull", False, ["local_file"])] - - required_together = [['remote_scp_server', - 'remote_scp_server_user', - 'remote_scp_server_password']] - - module = AnsibleModule(argument_spec=argument_spec, - required_if=required_if, - required_together=required_together, - supports_check_mode=True) - - file_pull = module.params['file_pull'] - - if file_pull: - if not HAS_PEXPECT: - module.fail_json( - msg='library pexpect is required when file_pull is True but does not appear to be ' - 'installed. It can be installed using `pip install pexpect`' - ) - else: - if paramiko is None: - module.fail_json( - msg='library paramiko is required when file_pull is False but does not appear to be ' - 'installed. It can be installed using `pip install paramiko`' - ) - - if not HAS_SCP: - module.fail_json( - msg='library scp is required when file_pull is False but does not appear to be ' - 'installed. It can be installed using `pip install scp`' - ) - warnings = list() - check_args(module, warnings) - results = dict(changed=False, warnings=warnings) - - local_file = module.params['local_file'] - remote_file = module.params['remote_file'] - file_system = module.params['file_system'] - local_file_directory = module.params['local_file_directory'] - - results['transfer_status'] = 'No Transfer' - results['file_system'] = file_system - - if file_pull: - src = remote_file.split('/')[-1] - local = local_file or src - - if not module.check_mode: - copy_file_from_remote(module, local, local_file_directory, file_system=file_system) - results['transfer_status'] = 'Received' - - results['changed'] = True - results['remote_file'] = src - results['local_file'] = local - else: - if not local_file_exists(module): - module.fail_json(msg="Local file {0} not found".format(local_file)) - - dest = remote_file or os.path.basename(local_file) - remote_exists = remote_file_exists(module, dest, file_system=file_system) - - if not remote_exists: - results['changed'] = True - file_exists = False - else: - file_exists = True - - if not module.check_mode and not file_exists: - transfer_file_to_device(module, dest) - results['transfer_status'] = 'Sent' - - results['local_file'] = local_file - if remote_file is None: - remote_file = os.path.basename(local_file) - results['remote_file'] = remote_file - - module.exit_json(**results) - - -if __name__ == '__main__': - main() diff --git a/lib/ansible/plugins/action/nxos_file_copy.py b/lib/ansible/plugins/action/nxos_file_copy.py new file mode 100644 index 0000000000..444d3196d3 --- /dev/null +++ b/lib/ansible/plugins/action/nxos_file_copy.py @@ -0,0 +1,475 @@ +# +# 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 copy +import hashlib +import os +import re +import sys +import time +import traceback +import uuid + +from ansible.errors import AnsibleError +from ansible.module_utils._text import to_text, to_bytes +from ansible.module_utils.connection import Connection +from ansible.plugins.action import ActionBase +from ansible.module_utils.six.moves.urllib.parse import urlsplit +from ansible.utils.display import Display +from ansible.module_utils.compat.paramiko import paramiko +from ansible.module_utils.network.nxos.nxos import run_commands +from ansible.module_utils._text import to_native, to_text, to_bytes +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils import six + +try: + from scp import SCPClient + HAS_SCP = True +except ImportError: + HAS_SCP = False + +try: + import pexpect + HAS_PEXPECT = True +except ImportError: + HAS_PEXPECT = False + +display = Display() + + +class ActionModule(ActionBase): + + def process_playbook_values(self): + ''' Get playbook values and perform input validation ''' + argument_spec = dict( + vrf=dict(type='str', default='management'), + connect_ssh_port=dict(type='int', default=22), + file_system=dict(type='str', default='bootflash:'), + file_pull=dict(type='bool', default=False), + file_pull_timeout=dict(type='int', default=300), + file_pull_compact=dict(type='bool', default=False), + file_pull_kstack=dict(type='bool', default=False), + local_file=dict(type='str'), + local_file_directory=dict(type='str'), + remote_file=dict(type='str'), + remote_scp_server=dict(type='str'), + remote_scp_server_user=dict(type='str'), + remote_scp_server_password=dict(no_log=True), + ) + + playvals = {} + # Process key value pairs from playbook task + for key in argument_spec.keys(): + playvals[key] = self._task.args.get(key, argument_spec[key].get('default')) + if playvals[key] is None: + continue + if argument_spec[key].get('type') is None: + argument_spec[key]['type'] = 'str' + type_ok = False + type = argument_spec[key]['type'] + if type == 'str': + if isinstance(playvals[key], six.string_types): + type_ok = True + elif type == 'int': + if isinstance(playvals[key], int): + type_ok = True + elif type == 'bool': + if isinstance(playvals[key], bool): + type_ok = True + else: + raise AnsibleError('Unrecognized type <{0}> for playbook parameter <{1}>'.format(type, key)) + + if not type_ok: + raise AnsibleError('Playbook parameter <{0}> value should be of type <{1}>'.format(key, type)) + + # Validate playbook dependencies + if playvals['file_pull']: + if playvals.get('remote_file') is None: + raise AnsibleError('Playbook parameter required when is True') + if playvals.get('remote_scp_server') is None: + raise AnsibleError('Playbook parameter required when is True') + + if playvals['remote_scp_server'] or \ + playvals['remote_scp_server_user'] or \ + playvals['remote_scp_server_password']: + + if None in (playvals['remote_scp_server'], + playvals['remote_scp_server_user'], + playvals['remote_scp_server_password']): + params = ', , ,remote_scp_server_password>' + raise AnsibleError('Playbook parameters {0} must all be set together'.format(params)) + + return playvals + + def check_library_dependencies(self, file_pull): + if file_pull: + if not HAS_PEXPECT: + msg = 'library pexpect is required when file_pull is True but does not appear to be ' + msg += 'installed. It can be installed using `pip install pexpect`' + raise AnsibleError(msg) + else: + if paramiko is None: + msg = 'library paramiko is required when file_pull is False but does not appear to be ' + msg += 'installed. It can be installed using `pip install paramiko`' + raise AnsibleError(msg) + + if not HAS_SCP: + msg = 'library scp is required when file_pull is False but does not appear to be ' + msg += 'installed. It can be installed using `pip install scp`' + raise AnsibleError(msg) + + def md5sum_check(self, dst, file_system): + command = 'show file {0}{1} md5sum'.format(file_system, dst) + remote_filehash = self.conn.exec_command(command) + remote_filehash = to_bytes(remote_filehash, errors='surrogate_or_strict') + + local_file = self.playvals['local_file'] + try: + with open(local_file, 'rb') as f: + filecontent = f.read() + except (OSError, IOError) as exc: + raise AnsibleError('Error reading the file: {0}'.format(to_text(exc))) + + filecontent = to_bytes(filecontent, errors='surrogate_or_strict') + local_filehash = hashlib.md5(filecontent).hexdigest() + + if local_filehash == remote_filehash: + return True + else: + return False + + def remote_file_exists(self, remote_file, file_system): + command = 'dir {0}/{1}'.format(file_system, remote_file) + body = self.conn.exec_command(command) + if 'No such file' in body: + return False + else: + return self.md5sum_check(remote_file, file_system) + + def verify_remote_file_exists(self, dst, file_system): + command = 'dir {0}/{1}'.format(file_system, dst) + body = self.conn.exec_command(command) + if 'No such file' in body: + return 0 + return body.split()[0].strip() + + def local_file_exists(self, file): + return os.path.isfile(file) + + def get_flash_size(self, file_system): + command = 'dir {0}'.format(file_system) + body = self.conn.exec_command(command) + + match = re.search(r'(\d+) bytes free', body) + if match: + bytes_free = match.group(1) + return int(bytes_free) + + match = re.search(r'No such file or directory', body) + if match: + raise AnsibleError('Invalid nxos filesystem {0}'.format(file_system)) + else: + raise AnsibleError('Unable to determine size of filesystem {0}'.format(file_system)) + + def enough_space(self, file, file_system): + flash_size = self.get_flash_size(file_system) + file_size = os.path.getsize(file) + if file_size > flash_size: + return False + + return True + + def transfer_file_to_device(self, remote_file): + timeout = self.socket_timeout + local_file = self.playvals['local_file'] + file_system = self.playvals['file_system'] + file_size = os.path.getsize(local_file) + + if not self.enough_space(local_file, file_system): + raise AnsibleError('Could not transfer file. Not enough space on device.') + + # frp = full_remote_path, flp = full_local_path + frp = '{0}{1}'.format(file_system, remote_file) + flp = os.path.join(os.path.abspath(local_file)) + try: + self.conn.copy_file(source=flp, destination=frp, proto='scp', timeout=timeout) + except Exception as exc: + self.results['failed'] = True + self.results['msg'] = ('Exception received : %s' % exc) + + def file_push(self): + local_file = self.playvals['local_file'] + remote_file = self.playvals['remote_file'] or os.path.basename(local_file) + file_system = self.playvals['file_system'] + + if not self.local_file_exists(local_file): + raise AnsibleError('Local file {0} not found'.format(local_file)) + + remote_file = remote_file or os.path.basename(local_file) + remote_exists = self.remote_file_exists(remote_file, file_system) + + if not remote_exists: + self.results['changed'] = True + file_exists = False + else: + self.results['transfer_status'] = 'No Transfer: File already copied to remote device.' + file_exists = True + + if not self.play_context.check_mode and not file_exists: + self.transfer_file_to_device(remote_file) + self.results['transfer_status'] = 'Sent: File copied to remote device.' + + self.results['local_file'] = local_file + if remote_file is None: + remote_file = os.path.basename(local_file) + self.results['remote_file'] = remote_file + + def copy_file_from_remote(self, local, local_file_directory, file_system): + self.results['failed'] = False + nxos_hostname = self.play_context.remote_addr + nxos_username = self.play_context.remote_user + nxos_password = self.play_context.password + port = self.playvals['connect_ssh_port'] + + # Build copy command components that will be used to initiate copy from the nxos device. + cmdroot = 'copy scp://' + ruser = self.playvals['remote_scp_server_user'] + '@' + rserver = self.playvals['remote_scp_server'] + rfile = self.playvals['remote_file'] + ' ' + vrf = ' vrf ' + self.playvals['vrf'] + local_dir_root = '/' + if self.playvals['file_pull_compact']: + compact = ' compact ' + else: + compact = '' + if self.playvals['file_pull_kstack']: + kstack = ' use-kstack ' + else: + kstack = '' + + def process_outcomes(session, timeout=None): + if timeout is None: + timeout = 10 + outcome = {} + outcome['user_response_required'] = False + outcome['password_prompt_detected'] = False + outcome['existing_file_with_same_name'] = False + outcome['final_prompt_detected'] = False + outcome['copy_complete'] = False + outcome['expect_timeout'] = False + outcome['error'] = False + outcome['error_data'] = None + + # Possible outcomes key: + # 0) - Are you sure you want to continue connecting (yes/no) + # 1) - Password: or @servers's password: + # 2) - Warning: There is already a file existing with this name. Do you want to overwrite (y/n)?[n] + # 3) - Timeout conditions + # 4) - No space on nxos device file_system + # 5) - Username/Password or file permission issues + # 6) - File does not exist on remote scp server + # 7) - invalid nxos command + # 8) - compact option not supported + # 9) - compaction attempt failed + # 10) - other failures like attempting to compact non image file + # 11) - failure to resolve hostname + # 12) - Too many authentication failures + # 13) - Copy to / from this server not permitted + # 14) - Copy completed without issues + # 15) - nxos_router_prompt# + # 16) - pexpect timeout + possible_outcomes = ['yes', + '(?i)Password', + 'file existing with this name', + 'timed out', + '(?i)No space.*#', + '(?i)Permission denied.*#', + '(?i)No such file.*#', + '.*Invalid command.*#', + 'Compaction is not supported on this platform.*#', + 'Compact of.*failed.*#', + '(?i)Failed.*#', + '(?i)Could not resolve hostname', + '(?i)Too many authentication failures', + r'(?i)Copying to\/from this server name is not permitted', + '(?i)Copy complete', + r'#\s', + pexpect.TIMEOUT] + index = session.expect(possible_outcomes, timeout=timeout) + # Each index maps to items in possible_outcomes + if index == 0: + outcome['user_response_required'] = True + return outcome + elif index == 1: + outcome['password_prompt_detected'] = True + return outcome + elif index == 2: + outcome['existing_file_with_same_name'] = True + return outcome + elif index in [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]: + before = session.before.strip().replace(' \x08', '') + after = session.after.strip().replace(' \x08', '') + outcome['error'] = True + outcome['error_data'] = 'COMMAND {0} ERROR {1}'.format(before, after) + return outcome + elif index == 14: + outcome['copy_complete'] = True + return outcome + elif index == 15: + outcome['final_prompt_detected'] = True + return outcome + elif index == 16: + # The before property will contain all text up to the expected string pattern. + # The after string will contain the text that was matched by the expected pattern. + outcome['expect_timeout'] = True + outcome['error_data'] = 'Expect Timeout error occured: BEFORE {0} AFTER {1}'.format(session.before, session.after) + return outcome + else: + outcome['error'] = True + outcome['error_data'] = 'Unrecognized error occured: BEFORE {0} AFTER {1}'.format(session.before, session.after) + return outcome + + return outcome + + # Spawn pexpect connection to NX-OS device. + nxos_session = pexpect.spawn('ssh ' + nxos_username + '@' + nxos_hostname + ' -p' + str(port)) + # There might be multiple user_response_required prompts or intermittent timeouts + # spawning the expect session so loop up to 5 times during the spwan process. + for connect_attempt in range(6): + outcome = process_outcomes(nxos_session) + if outcome['user_response_required']: + nxos_session.sendline('yes') + continue + if outcome['password_prompt_detected']: + nxos_session.sendline(nxos_password) + continue + if outcome['final_prompt_detected']: + break + if outcome['error'] or outcome['expect_timeout']: + self.results['failed'] = True + self.results['error_data'] = 'Failed to spawn expect session! ' + outcome['error_data'] + return + else: + # The before property will contain all text up to the expected string pattern. + # The after string will contain the text that was matched by the expected pattern. + msg = 'After {0} attempts, failed to spawn pexpect session to {1}' + msg += 'BEFORE: {2}, AFTER: {3}' + raise AnsibleError(msg.format(connect_attempt, nxos_hostname, nxos_session.before, nxos_session.before)) + + # Create local file directory under NX-OS filesystem if + # local_file_directory playbook parameter is set. + if local_file_directory: + dir_array = local_file_directory.split('/') + for each in dir_array: + if each: + mkdir_cmd = 'mkdir ' + local_dir_root + each + nxos_session.sendline(mkdir_cmd) + outcome = process_outcomes(nxos_session) + if outcome['error'] or outcome['expect_timeout']: + self.results['mkdir_cmd'] = mkdir_cmd + self.results['failed'] = True + self.results['error_data'] = outcome['error_data'] + return + local_dir_root += each + '/' + + # Initiate file copy + copy_cmd = (cmdroot + ruser + rserver + rfile + file_system + local_dir_root + local + compact + vrf + kstack) + self.results['copy_cmd'] = copy_cmd + nxos_session.sendline(copy_cmd) + for copy_attempt in range(6): + outcome = process_outcomes(nxos_session, self.playvals['file_pull_timeout']) + if outcome['user_response_required']: + nxos_session.sendline('yes') + continue + if outcome['password_prompt_detected']: + nxos_session.sendline(self.playvals['remote_scp_server_password']) + continue + if outcome['existing_file_with_same_name']: + nxos_session.sendline('y') + continue + if outcome['copy_complete']: + self.results['transfer_status'] = 'Received: File copied/pulled to nxos device from remote scp server.' + break + if outcome['error'] or outcome['expect_timeout']: + self.results['failed'] = True + self.results['error_data'] = outcome['error_data'] + return + else: + # The before property will contain all text up to the expected string pattern. + # The after string will contain the text that was matched by the expected pattern. + msg = 'After {0} attempts, failed to copy file to {1}' + msg += 'BEFORE: {2}, AFTER: {3}, CMD: {4}' + raise AnsibleError(msg.format(copy_attempt, nxos_hostname, nxos_session.before, nxos_session.before, copy_cmd)) + + def file_pull(self): + local_file = self.playvals['local_file'] + remote_file = self.playvals['remote_file'] + file_system = self.playvals['file_system'] + # Note: This is the local file directory on the remote nxos device. + local_file_dir = self.playvals['local_file_directory'] + + local_file = local_file or self.playvals['remote_file'].split('/')[-1] + + if not self.play_context.check_mode: + self.copy_file_from_remote(local_file, local_file_dir, file_system) + + if not self.results['failed']: + self.results['changed'] = True + self.results['remote_file'] = remote_file + if local_file_dir: + dir = local_file_dir + else: + dir = '' + self.results['local_file'] = file_system + dir + '/' + local_file + self.results['remote_scp_server'] = self.playvals['remote_scp_server'] + + # This is the main run method for the action plugin to copy files + def run(self, tmp=None, task_vars=None): + socket_path = None + self.play_context = copy.deepcopy(self._play_context) + self.results = super(ActionModule, self).run(task_vars=task_vars) + + if self.play_context.connection != 'network_cli': + # Plugin is supported only with network_cli + self.results['failed'] = True + self.results['msg'] = ('Connection type must be ') + return self.results + + # Get playbook values + self.playvals = self.process_playbook_values() + + file_pull = self.playvals['file_pull'] + self.check_library_dependencies(file_pull) + + if socket_path is None: + socket_path = self._connection.socket_path + self.conn = Connection(socket_path) + self.socket_timeout = self.conn.get_option('persistent_command_timeout') + + # This action plugin support two modes of operation. + # - file_pull is False - Push files from the ansible controller to nxos switch. + # - file_pull is True - Initiate copy from the device to pull files to the nxos switch. + self.results['transfer_status'] = 'No Transfer' + self.results['file_system'] = self.playvals['file_system'] + if file_pull: + self.file_pull() + else: + self.file_push() + + return self.results diff --git a/test/integration/targets/nxos_file_copy/meta/main.yml b/test/integration/targets/nxos_file_copy/meta/main.yml index ae741cbdc7..1530e79eed 100644 --- a/test/integration/targets/nxos_file_copy/meta/main.yml +++ b/test/integration/targets/nxos_file_copy/meta/main.yml @@ -1,2 +1,5 @@ dependencies: - - prepare_nxos_tests + # prepare_nxos_tests is not needed for this test and simply adds overhead. + # This can be uncommented in the future if needed. + # + # - prepare_nxos_tests diff --git a/test/integration/targets/nxos_file_copy/tasks/cli.yaml b/test/integration/targets/nxos_file_copy/tasks/cli.yaml index ac06cd7ea7..9243812668 100644 --- a/test/integration/targets/nxos_file_copy/tasks/cli.yaml +++ b/test/integration/targets/nxos_file_copy/tasks/cli.yaml @@ -25,9 +25,3 @@ with_items: "{{ test_items }}" loop_control: loop_var: test_case_to_run - -- name: run test cases (connection=local) - include: "{{ test_case_to_run }} ansible_connection=local" - with_items: "{{ test_items }}" - loop_control: - loop_var: test_case_to_run diff --git a/test/integration/targets/nxos_file_copy/tests/cli/input_validation.yaml b/test/integration/targets/nxos_file_copy/tests/cli/input_validation.yaml new file mode 100644 index 0000000000..606633f0b1 --- /dev/null +++ b/test/integration/targets/nxos_file_copy/tests/cli/input_validation.yaml @@ -0,0 +1,65 @@ +--- +- debug: msg="START nxos_file_copy input_validation test" + +- name: "Input Validation - param should be type " + nxos_file_copy: + remote_file: 500 + register: result + ignore_errors: true + +- assert: + that: + - result is search('Playbook parameter value should be of type ') + +- name: "Input Validation - param should be type " + nxos_file_copy: + file_pull_timeout: 'foobar' + register: result + ignore_errors: true + +- assert: + that: + - result is search('Playbook parameter value should be of type ') + +- name: "Input Validation - param should be type " + nxos_file_copy: + file_pull: 'foobar' + register: result + ignore_errors: true + +- assert: + that: + - result is search('Playbook parameter value should be of type ') + +- name: "Input Validation - param dependency" + nxos_file_copy: + file_pull: True + register: result + ignore_errors: true + +- assert: + that: + - result is search('Playbook parameter required when is True') + +- name: "Input Validation - param dependency" + nxos_file_copy: + file_pull: True + remote_file: "/network-integration.cfg" + register: result + ignore_errors: true + +- assert: + that: + - result is search('Playbook parameter required when is True') + +- name: "Input Validation - remote_scp_server params together" + nxos_file_copy: + remote_scp_server: "{{ inventory_hostname_short }}" + register: result + ignore_errors: true + +- assert: + that: + - result is search('Playbook parameters , , ,remote_scp_server_password> must all be set together') + +- debug: msg="END nxos_file_copy input_validation test" diff --git a/test/integration/targets/nxos_file_copy/tests/cli/negative.yaml b/test/integration/targets/nxos_file_copy/tests/cli/negative.yaml new file mode 100644 index 0000000000..58d091663c --- /dev/null +++ b/test/integration/targets/nxos_file_copy/tests/cli/negative.yaml @@ -0,0 +1,133 @@ +--- +- debug: msg="START nxos_file_copy negative test" + +# This test uses a file that is committed to the Ansible core repository. +# The file name and relative path is test/integration/targets/network-integration.cfg +- set_fact: test_source_file="network-integration.cfg" +- set_fact: test_destination_file="test_destination_file" + +# ------------------------- +# Tests for file_pull False +# ------------------------- +- name: "Attempt to copy file to invalid file_system" + nxos_file_copy: + file_pull: False + local_file: "./{{ test_source_file }}" + file_system: "invalid_media_type:" + connect_ssh_port: "{{ ansible_ssh_port }}" + register: result + ignore_errors: true + +- assert: + that: + - result is search('Invalid nxos filesystem invalid_media_type:') + +- name: "Attempt to copy source file that does not exist on Ansible controller" + nxos_file_copy: + file_pull: False + local_file: "./{{ test_source_file }}_does_not_exist" + file_system: "bootflash:" + connect_ssh_port: "{{ ansible_ssh_port }}" + register: result + ignore_errors: true + +- assert: + that: + - result is search('Local file ./network-integration.cfg_does_not_exist not found') + +# ------------------------- +# Tests for file_pull True +# ------------------------- +- name: "Try and copy file using an invalid remote scp server name" + nxos_file_copy: + file_pull: True + file_pull_timeout: 10 + remote_file: "/{{ test_destination_file }}" + local_file: "{{ test_destination_file }}_copy" + local_file_directory: "dir1/dir2/dir3" + remote_scp_server: "scp_server_gone.example.com" + remote_scp_server_user: "{{ ansible_ssh_user }}" + remote_scp_server_password: "{{ ansible_ssh_pass }}" + register: result + ignore_errors: true + +- assert: + that: + - "result.changed == false" + - "'copy scp:' in result.copy_cmd" + - "'bootflash:' in result.file_system" + - "'No Transfer' in result.transfer_status" + +- assert: + that: + - result.error_data is search("ERROR Could not resolve hostname|Copying to.*from this server name is not permitted") + +- name: "Try and copy file using an invalid remote scp server ip address" + nxos_file_copy: + file_pull: True + file_pull_timeout: 300 + remote_file: "/{{ test_destination_file }}" + local_file: "{{ test_destination_file }}_copy" + local_file_directory: "dir1/dir2/dir3" + remote_scp_server: "192.168.55.55" + remote_scp_server_user: "{{ ansible_ssh_user }}" + remote_scp_server_password: "{{ ansible_ssh_pass }}" + register: result + ignore_errors: true + +- assert: + that: + - "result.changed == false" + - "'copy scp:' in result.copy_cmd" + - "'timed out' in result.error_data" + - "'bootflash:' in result.file_system" + - "'No Transfer' in result.transfer_status" + +# Sometimes the previous negative test needs a few seconds after the timeout +# failure before the next negative test is executed. +- pause: + seconds: 10 + +- name: "Try and copy file using an invalid username" + nxos_file_copy: + file_pull: True + file_pull_timeout: 10 + remote_file: "/{{ test_destination_file }}" + local_file: "{{ test_destination_file }}_copy" + local_file_directory: "dir1/dir2/dir3" + remote_scp_server: "{{ inventory_hostname_short }}" + remote_scp_server_user: "invalid_user_name" + remote_scp_server_password: "{{ ansible_ssh_pass }}" + register: result + ignore_errors: true + +- assert: + that: + - "result.changed == false" + - "'copy scp:' in result.copy_cmd" + - "'Too many authentication failures' in result.error_data" + - "'bootflash:' in result.file_system" + - "'No Transfer' in result.transfer_status" + +- name: "Try and copy file using an invalid password" + nxos_file_copy: + file_pull: True + file_pull_timeout: 10 + remote_file: "/{{ test_destination_file }}" + local_file: "{{ test_destination_file }}_copy" + local_file_directory: "dir1/dir2/dir3" + remote_scp_server: "{{ inventory_hostname_short }}" + remote_scp_server_user: "{{ ansible_ssh_user }}" + remote_scp_server_password: "invalid_password" + register: result + ignore_errors: true + +- assert: + that: + - "result.changed == false" + - "'copy scp:' in result.copy_cmd" + - "'Too many authentication failures' in result.error_data" + - "'bootflash:' in result.file_system" + - "'No Transfer' in result.transfer_status" + +- debug: msg="END nxos_file_copy negative test" diff --git a/test/integration/targets/nxos_file_copy/tests/cli/sanity.yaml b/test/integration/targets/nxos_file_copy/tests/cli/sanity.yaml index e8494e59a3..3fa3ea84f3 100644 --- a/test/integration/targets/nxos_file_copy/tests/cli/sanity.yaml +++ b/test/integration/targets/nxos_file_copy/tests/cli/sanity.yaml @@ -1,35 +1,45 @@ --- - debug: msg="START connection={{ ansible_connection }} nxos_file_copy sanity test" +# This test uses a file that is committed to the Ansible core repository. +# The file name and relative path is test/integration/targets/network-integration.cfg +- set_fact: test_source_file="network-integration.cfg" +- set_fact: test_destination_file="test_destination_file" + - name: "Setup - Remove existing file" nxos_command: &remove_file commands: - terminal dont-ask - - delete network-integration.cfg - - delete bootflash:/dir1/dir2/dir3/network-integration_copy.cfg + - "delete {{ test_source_file }}" + - "delete {{ test_destination_file }}" + - "delete bootflash:/dir1/dir2/dir3/*" - rmdir dir1/dir2/dir3 - rmdir dir1/dir2 - rmdir dir1 ignore_errors: yes - name: "Setup - Turn on feature scp-server" - nxos_feature: + nxos_feature: feature: scp-server state: enabled - block: - - name: "Copy network-integration.cfg to bootflash" + - name: "Copy {{ test_source_file }} file from Ansible controller to bootflash" nxos_file_copy: ©_file_same_name - local_file: "./network-integration.cfg" + local_file: "./{{ test_source_file }}" file_system: "bootflash:" connect_ssh_port: "{{ ansible_ssh_port }}" register: result - - assert: &true + - assert: that: - "result.changed == true" + - "'bootflash:' in result.file_system" + - "'./{{ test_source_file }}' in result.local_file" + - "'network-integration.cfg' in result.remote_file" + - "'Sent: File copied to remote device.' in result.transfer_status" - - name: "Check Idempotence - Copy network-integration.cfg to bootflash" + - name: "Idempotence - Copy {{ test_source_file }} file from Ansible controller to bootflash" nxos_file_copy: *copy_file_same_name register: result @@ -41,47 +51,77 @@ nxos_command: *remove_file register: result - - name: "Copy inventory.networking.template to bootflash as another name" + - name: "Copy {{ test_source_file }} file from Ansible controller to bootflash renamed as {{ test_destination_file }}" nxos_file_copy: ©_file_different_name - local_file: "./inventory.networking.template" - remote_file: "network-integration.cfg" + local_file: "./{{ test_source_file }}" + remote_file: "{{ test_destination_file }}" file_system: "bootflash:" connect_ssh_port: "{{ ansible_ssh_port }}" register: result - - assert: *true + - assert: + that: + - "result.changed == true" + - "'bootflash:' in result.file_system" + - "'./{{ test_source_file }}' in result.local_file" + - "'{{ test_destination_file }}' in result.remote_file" + - "'Sent: File copied to remote device.' in result.transfer_status" - - name: "Check Idempotence - Copy inventory.networking.template to bootflash as another name" + - name: "Idempotence - Copy {{ test_source_file }} file from Ansible controller to bootflash renamed as {{ test_destination_file }}" nxos_file_copy: *copy_file_different_name register: result + - name: "Verify file_pull true options have no impact when file_true is false" + nxos_file_copy: + file_pull: False + file_pull_timeout: 1200 + file_pull_compact: True + file_pull_kstack: True + local_file_directory: "dir1/dir2/dir3" + remote_scp_server: "{{ inventory_hostname_short }}" + remote_scp_server_user: "{{ ansible_ssh_user }}" + remote_scp_server_password: "{{ ansible_ssh_pass }}" + # Parameters above are only used when file_pull is True + local_file: "./{{ test_source_file }}" + remote_file: "{{ test_destination_file }}" + file_system: "bootflash:" + connect_ssh_port: "{{ ansible_ssh_port }}" + register: result + - assert: *false - - block: - - name: "Copy file using file_pull" - nxos_file_copy: ©_pull - file_pull: True - file_pull_timeout: 1200 - remote_file: "/network-integration.cfg" - local_file: "network-integration_copy.cfg" - local_file_directory: "dir1/dir2/dir3" - remote_scp_server: "{{ inventory_hostname_short }}" - remote_scp_server_user: "{{ ansible_ssh_user }}" - remote_scp_server_password: "{{ ansible_ssh_pass }}" - register: result - - - assert: *true - - - name: "Overwrite the file" - nxos_file_copy: *copy_pull - register: result - - - assert: *true - ignore_errors: yes + # This step validates the ability to initiate the copy from the nxos device + # to pull a file from a remote file server to the nxos bootflash device. + # + # In this case we are using the nxos device as the remote file server so we + # copy a file from bootflash: to bootflash:dir1/dir2/dir3 + - name: "Initiate copy from nxos device to copy {{ test_destination_file }} to bootflash:dir1/dir2/dir3/{{ test_destination_file }}_copy" + nxos_file_copy: ©_pull + file_pull: True + file_pull_timeout: 30 + remote_file: "/{{ test_destination_file }}" + local_file: "{{ test_destination_file }}_copy" + local_file_directory: "dir1/dir2/dir3" + remote_scp_server: "{{ inventory_hostname_short }}" + remote_scp_server_user: "{{ ansible_ssh_user }}" + remote_scp_server_password: "{{ ansible_ssh_pass }}" + register: result - rescue: + - assert: &overwrite + that: + - "result.changed == true" + - "'copy scp:' in result.copy_cmd" + - "'bootflash:' in result.file_system" + - "'bootflash:dir1/dir2/dir3/{{ test_destination_file }}_copy' in result.local_file" + - "'/{{ test_destination_file }}' in result.remote_file" + - "'Received: File copied/pulled to nxos device from remote scp server.' in result.transfer_status" + - "'{{ inventory_hostname_short }}' in result.remote_scp_server" + + - name: "Overwrite the file" + nxos_file_copy: *copy_pull + register: result - - debug: msg="TRANSPORT:CLI nxos_file_copy failure detected" + - assert: *overwrite always: diff --git a/test/integration/targets/nxos_file_copy/tests/nxapi/badtransport.yaml b/test/integration/targets/nxos_file_copy/tests/nxapi/badtransport.yaml index de0693d51a..7cd44709ea 100644 --- a/test/integration/targets/nxos_file_copy/tests/nxapi/badtransport.yaml +++ b/test/integration/targets/nxos_file_copy/tests/nxapi/badtransport.yaml @@ -12,6 +12,6 @@ - assert: that: - - result.failed and result.msg is search('Transport') + - result.failed and result.msg is search('Connection type must be ') - debug: msg="END nxapi/badtransport.yaml" diff --git a/test/sanity/ignore.txt b/test/sanity/ignore.txt index 3d32bb24b9..22ed554e7c 100644 --- a/test/sanity/ignore.txt +++ b/test/sanity/ignore.txt @@ -4221,12 +4221,6 @@ lib/ansible/modules/network/nxos/nxos_feature.py validate-modules:doc-default-do lib/ansible/modules/network/nxos/nxos_feature.py validate-modules:doc-default-incompatible-type lib/ansible/modules/network/nxos/nxos_feature.py validate-modules:parameter-type-not-in-doc lib/ansible/modules/network/nxos/nxos_feature.py validate-modules:doc-missing-type -lib/ansible/modules/network/nxos/nxos_file_copy.py future-import-boilerplate -lib/ansible/modules/network/nxos/nxos_file_copy.py metaclass-boilerplate -lib/ansible/modules/network/nxos/nxos_file_copy.py validate-modules:doc-default-does-not-match-spec -lib/ansible/modules/network/nxos/nxos_file_copy.py validate-modules:doc-default-incompatible-type -lib/ansible/modules/network/nxos/nxos_file_copy.py validate-modules:parameter-type-not-in-doc -lib/ansible/modules/network/nxos/nxos_file_copy.py validate-modules:doc-missing-type lib/ansible/modules/network/nxos/nxos_gir.py future-import-boilerplate lib/ansible/modules/network/nxos/nxos_gir.py metaclass-boilerplate lib/ansible/modules/network/nxos/nxos_gir.py validate-modules:doc-default-does-not-match-spec -- cgit v1.2.3