#
# 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 time
from ansible.errors import AnsibleError
from ansible.module_utils._text import to_text, to_bytes, to_native
from ansible.module_utils.common import validation
from ansible.module_utils.connection import Connection
from ansible.plugins.action import ActionBase
from ansible.utils.display import Display
from ansible.module_utils.compat.paramiko import paramiko
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="path"),
local_file_directory=dict(type="path"),
remote_file=dict(type="path"),
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
option_type = argument_spec[key].get("type", "str")
try:
if option_type == "str":
playvals[key] = validation.check_type_str(playvals[key])
elif option_type == "int":
playvals[key] = validation.check_type_int(playvals[key])
elif option_type == "bool":
playvals[key] = validation.check_type_bool(playvals[key])
elif option_type == "path":
playvals[key] = validation.check_type_path(playvals[key])
else:
raise AnsibleError(
"Unrecognized type <{0}> for playbook parameter <{1}>".format(
option_type, key
)
)
except (TypeError, ValueError) as e:
raise AnsibleError(
"argument %s is of type %s and we were unable to convert to %s: %s"
% (key, type(playvals[key]), option_type, to_native(e))
)
# 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"]:
if None in (
playvals["remote_scp_server"],
playvals["remote_scp_server_user"],
):
params = ", "
raise AnsibleError(
"Playbook parameters {0} must 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()
decoded_rhash = remote_filehash.decode("UTF-8")
if local_filehash == decoded_rhash:
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"]
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 or ""
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 = [
r"sure you want to continue connecting \(yes/no\)\? ",
"(?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]:
decoded_before = session.before.decode("UTF-8")
decoded_after = session.after.decode("UTF-8")
before = decoded_before.strip().replace(" \x08", "")
after = decoded_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 occurred: BEFORE {0} AFTER {1}".format(
session.before, session.after
)
return outcome
else:
outcome["error"] = True
outcome[
"error_data"
] = "Unrecognized error occurred: 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 24 times during the spawn process.
max_attempts = 24
for connect_attempt in range(max_attempts):
outcome = process_outcomes(nxos_session)
if outcome["user_response_required"]:
nxos_session.sendline("yes")
continue
if outcome["password_prompt_detected"]:
time.sleep(3)
nxos_session.sendline(nxos_password)
continue
if outcome["final_prompt_detected"]:
break
if outcome["error"] or outcome["expect_timeout"]:
# Error encountered, try to spawn expect session n more times up to max_attempts - 1
if connect_attempt < max_attempts:
outcome["error"] = False
outcome["expect_timeout"] = False
nxos_session.close()
nxos_session = pexpect.spawn(
"ssh "
+ nxos_username
+ "@"
+ nxos_hostname
+ " -p"
+ str(port)
)
continue
self.results["failed"] = True
outcome["error_data"] = re.sub(
nxos_password, "", outcome["error_data"]
)
self.results["error_data"] = (
"Failed to spawn expect session! " + outcome["error_data"]
)
nxos_session.close()
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}"
error_msg = msg.format(
connect_attempt,
nxos_hostname,
nxos_session.before,
nxos_session.after,
)
re.sub(nxos_password, "", error_msg)
nxos_session.close()
raise AnsibleError(error_msg)
# 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
outcome["error_data"] = re.sub(
nxos_password, "", outcome["error_data"]
)
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"]:
if self.playvals.get("remote_scp_server_password"):
nxos_session.sendline(
self.playvals["remote_scp_server_password"]
)
else:
err_msg = "Remote scp server {0} requires a password.".format(
rserver
)
err_msg += " Set the playbook parameter or configure nxos device for passwordless scp"
raise AnsibleError(err_msg)
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
outcome["error_data"] = re.sub(
nxos_password, "", outcome["error_data"]
)
if self.playvals.get("remote_scp_server_password"):
outcome["error_data"] = re.sub(
self.playvals["remote_scp_server_password"],
"",
outcome["error_data"],
)
self.results["error_data"] = outcome["error_data"]
nxos_session.close()
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}"
error_msg = msg.format(
copy_attempt,
nxos_hostname,
nxos_session.before,
nxos_session.before,
copy_cmd,
)
re.sub(nxos_password, "", error_msg)
if self.playvals.get("remote_scp_server_password"):
re.sub(
self.playvals["remote_scp_server_password"], "", error_msg
)
nxos_session.close()
raise AnsibleError(error_msg)
nxos_session.close()
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.split(".")[-1] != "network_cli":
# Plugin is supported only with network_cli
self.results["failed"] = True
self.results["msg"] = (
"Connection type must be fully qualified name for network_cli connection type, got %s"
% self.play_context.connection
)
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)
# Call get_capabilities() to start the connection to the device.
self.conn.get_capabilities()
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