ccmlib/remote.py (225 lines of code) (raw):
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Remote execution helper functionality for executing CCM commands on a remote
# machine with CCM installed
#
from __future__ import absolute_import
import argparse
import logging
import os
import re
import select
import stat
import sys
import tempfile
# Paramiko is an optional library as SSH is optional for CCM
PARAMIKO_IS_AVAILABLE = False
try:
import paramiko
PARAMIKO_IS_AVAILABLE = True
except ImportError:
pass
def get_remote_usage():
"""
Get the usage for the remote exectuion options
:return Usage for the remote execution options
"""
return RemoteOptionsParser().usage()
def get_remote_options():
"""
Parse the command line arguments and split out the CCM arguments and the remote options
:return: A tuple defining the arguments parsed and actions to take
* remote_options - Remote options only
* ccm_args - CCM arguments only
:raises Exception if private key is not a file
"""
return RemoteOptionsParser().parse_known_options()
def execute_ccm_remotely(remote_options, ccm_args):
"""
Execute CCM operation(s) remotely
:return A tuple defining the execution of the command
* output - The output of the execution if the output was not displayed
* exit_status - The exit status of remotely executed script
:raises Exception if invalid options are passed for `--dse-credentials`, `--ssl`, or
`--node-ssl` when initiating a remote execution; also if
error occured during ssh connection
"""
if not PARAMIKO_IS_AVAILABLE:
logging.warn("Paramiko is not Availble: Skipping remote execution of CCM command")
return None, None
# Create the SSH client
ssh_client = SSHClient(remote_options.ssh_host, remote_options.ssh_port,
remote_options.ssh_username, remote_options.ssh_password,
remote_options.ssh_private_key)
# Handle CCM arguments that require SFTP
for index, argument in enumerate(ccm_args):
# Determine if DSE credentials argument is being used
if "--dse-credentials" in argument:
# Get the filename being used for the DSE credentials
tokens = argument.split("=")
credentials_path = os.path.join(os.path.expanduser("~"), ".ccm", ".dse.ini")
if len(tokens) == 2:
credentials_path = tokens[1]
# Ensure the credential file exists locally and copy to remote host
if not os.path.isfile(credentials_path):
raise Exception("DSE Credentials File Does not Exist: %s"
% credentials_path)
ssh_client.put(credentials_path, ssh_client.ccm_config_dir)
# Update the DSE credentials argument
ccm_args[index] = "--dse-credentials"
# Determine if SSL or node SSL path argument is being used
if "--ssl" in argument or "--node-ssl" in argument:
# Get the directory being used for the path
tokens = argument.split("=")
if len(tokens) != 2:
raise Exception("Path is not Specified: %s" % argument)
ssl_path = tokens[1]
# Ensure the path exists locally and copy to remote host
if not os.path.isdir(ssl_path):
raise Exception("Path Does not Exist: %s" % ssl_path)
remote_ssl_path = ssh_client.temp + os.path.basename(ssl_path)
ssh_client.put(ssl_path, remote_ssl_path)
# Update the argument
ccm_args[index] = tokens[0] + "=" + remote_ssl_path
# Execute the CCM request, return output and exit status
return ssh_client.execute_ccm_command(ccm_args)
class SSHClient:
"""
SSH client class used to handle SSH operations to the remote host
"""
def __init__(self, host, port, username, password, private_key=None):
"""
Create the SSH client
:param host: Hostname or IP address to connect to
:param port: Port number to use for SSH
:param username: Username credentials for SSH access
:param password: Password credentials for SSH access (or private key passphrase)
:param private_key: Private key to bypass clear text password (default: None - Username and
password credentials)
"""
# Reduce the noise from the logger for paramiko
logging.getLogger("paramiko").setLevel(logging.WARNING)
# Establish the SSH connection
self.ssh = self.__connect(host, port, username, password, private_key)
# Gather information about the remote OS
information = self.__server_information()
self.separator = information[1]
self.home = information[0] + self.separator
self.temp = information[2] + self.separator
self.platform = information[3]
self.profile = information[4]
self.distribution = information[5]
# Create the CCM configuration directory variable
self.ccm_config_dir = self.home + self.separator + ".ccm" + self.separator
@staticmethod
def __connect(host, port, username, password, private_key):
"""
Establish remote connection
:param host: Hostname or IP address to connect to
:param port: Port number to use for SSH
:param username: Username credentials for SSH access
:param password: Password credentials for SSH access (or private key passphrase)
:param private_key: Private key to bypass clear text password
:return: Paramiko SSH client instance if connection was established
:raises Exception if connection was unsuccessful
"""
# Initialize the SSH connection
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if private_key is not None and password is not None:
private_key = paramiko.RSAKey.from_private_key_file(private_key, password)
elif private_key is not None:
private_key = paramiko.RSAKey.from_private_key_file(private_key, password)
# Establish the SSH connection
try:
ssh.connect(host, port, username, password, private_key)
except Exception as e:
raise e
# Return the established SSH connection
return ssh
def execute(self, command, is_displayed=True, profile=None):
"""
Execute a command on the remote server
:param command: Command to execute remotely
:param is_displayed: True if information should be display; false to return output
(default: true)
:param profile: Profile to source (unix like system only should set this)
(default: None)
:return: A tuple defining the execution of the command
* output - The output of the execution if the output was not displayed
* exit_status - The exit status of remotely executed script
"""
# Modify the command for remote execution
command = " ".join("'{0}'".format(argument) for argument in command)
# Execute the command and initialize for reading (close stdin/writes)
if not profile is None and not profile is "None":
command = "source " + profile + ";" + command
stdin, stdout, stderr = self.ssh.exec_command(command)
stdin.channel.shutdown_write()
stdin.close()
# Print or gather output as is occurs
output = None
if not is_displayed:
output = []
output.append(stdout.channel.recv(len(stdout.channel.in_buffer)).decode("utf-8"))
output.append(stderr.channel.recv(len(stderr.channel.in_buffer)).decode("utf-8"))
channel = stdout.channel
while not channel.closed or channel.recv_ready() or channel.recv_stderr_ready():
# Ensure the channel was not closed prematurely and all data has been ready
is_data_present = False
handles = select.select([channel], [], [])
for read in handles[0]:
# Read stdout and/or stderr if data is present
buffer = None
if read.recv_ready():
buffer = channel.recv(len(read.in_buffer)).decode("utf-8")
if is_displayed:
sys.stdout.write(buffer)
if read.recv_stderr_ready():
buffer = stderr.channel.recv_stderr(len(read.in_stderr_buffer)).decode("utf-8")
if is_displayed:
sys.stderr.write(buffer)
# Determine if the output should be updated and displayed
if buffer is not None:
is_data_present = True
if not is_displayed:
output.append(buffer)
# Ensure all the data has been read and exit loop if completed
if (not is_data_present and channel.exit_status_ready()
and not stderr.channel.recv_stderr_ready()
and not channel.recv_ready()):
# Stop reading and close the channel to stop processing
channel.shutdown_read()
channel.close()
break
# Close file handles for stdout and stderr
stdout.close()
stderr.close()
# Process the output (if available)
if output is not None:
output = "".join(output)
# Return the output from the executed command
return output, channel.recv_exit_status()
def execute_ccm_command(self, ccm_args, is_displayed=True):
"""
Execute a CCM command on the remote server
:param ccm_args: CCM arguments to execute remotely
:param is_displayed: True if information should be display; false to return output
(default: true)
:return: A tuple defining the execution of the command
* output - The output of the execution if the output was not displayed
* exit_status - The exit status of remotely executed script
"""
return self.execute(["ccm"] + ccm_args, profile=self.profile)
def execute_python_script(self, script):
"""
Execute a python script of the remote server
:param script: Inline script to convert to a file and execute remotely
:return: The output of the script execution
"""
# Create the local file to copy to remote
file_handle, filename = tempfile.mkstemp()
temp_file = os.fdopen(file_handle, "wt")
temp_file.write(script)
temp_file.close()
# Put the file into the remote user directory
self.put(filename, "python_execute.py")
command = ["python", "python_execute.py"]
# Execute the python script on the remote system, clean up, and return the output
output = self.execute(command, False)
self.remove("python_execute.py")
os.unlink(filename)
return output
def put(self, local_path, remote_path=None):
"""
Copy a file (or directory recursively) to a location on the remote server
:param local_path: Local path to copy to; can be file or directory
:param remote_path: Remote path to copy to (default: None - Copies file or directory to
home directory directory on the remote server)
"""
# Determine if local_path should be put into remote user directory
if remote_path is None:
remote_path = os.path.basename(local_path)
ftp = self.ssh.open_sftp()
if os.path.isdir(local_path):
self.__put_dir(ftp, local_path, remote_path)
else:
ftp.put(local_path, remote_path)
ftp.close()
def __put_dir(self, ftp, local_path, remote_path=None):
"""
Helper function to perform copy operation to remote server
:param ftp: SFTP handle to perform copy operation(s)
:param local_path: Local path to copy to; can be file or directory
:param remote_path: Remote path to copy to (default: None - Copies file or directory to
home directory directory on the remote server)
"""
# Determine if local_path should be put into remote user directory
if remote_path is None:
remote_path = os.path.basename(local_path)
remote_path += self.separator
# Iterate over the local path and perform copy operations to remote server
for current_path, directories, files in os.walk(local_path):
# Create the remote directory (if needed)
try:
ftp.listdir(remote_path)
except IOError:
ftp.mkdir(remote_path)
# Copy the files in the current directory to the remote path
for filename in files:
ftp.put(os.path.join(current_path, filename), remote_path + filename)
# Copy the directory in the current directory to the remote path
for directory in directories:
self.__put_dir(ftp, os.path.join(current_path, directory), remote_path + directory)
def remove(self, remote_path):
"""
Delete a file or directory recursively on the remote server
:param remote_path: Remote path to remove
"""
# Based on the remote file stats; remove a file or directory recursively
ftp = self.ssh.open_sftp()
if stat.S_ISDIR(ftp.stat(remote_path).st_mode):
self.__remove_dir(ftp, remote_path)
else:
ftp.remove(remote_path)
ftp.close()
def __remove_dir(self, ftp, remote_path):
"""
Helper function to perform delete operation on the remote server
:param ftp: SFTP handle to perform delete operation(s)
:param remote_path: Remote path to remove
"""
# Iterate over the remote path and perform remove operations
files = ftp.listdir(remote_path)
for filename in files:
# Attempt to remove the file (if exception then path is directory)
path = remote_path + self.separator + filename
try:
ftp.remove(path)
except IOError:
self.__remove_dir(ftp, path)
# Remove the original directory requested
ftp.rmdir(remote_path)
def __server_information(self):
"""
Get information about the remote server:
* User's home directory
* OS separator
* OS temporary directory
* Platform information
* Profile to source (Available only on unix-like platforms (including Mac OS))
* Platform distribution ((Available only on unix-like platforms)
:return: Remote executed script with the above information (line by line in order)
"""
return self.execute_python_script("""import os
import platform
import sys
import tempfile
# Standard system information
print(os.path.expanduser("~"))
print(os.sep)
print(tempfile.gettempdir())
print(platform.system())
# Determine the profile for unix like systems
if sys.platform == "darwin" or sys.platform == "linux" or sys.platform == "linux2":
if os.path.isfile(".profile"):
print(os.path.expanduser("~") + os.sep + ".profile")
elif os.path.isfile(".bash_profile"):
print(os.path.expanduser("~") + os.sep + ".bash_profile")
else:
print("None")
else:
print("None")
# Get the disto information for unix like system (excluding Mac OS)
if sys.platform == "linux" or sys.platform == "linux2":
print(platform.linux_distribution())
else:
print("None")
""")[0].splitlines()
class RemoteOptionsParser():
"""
Parser class to facilitate remote execution of CCM operations via command
line arguments
"""
def __init__(self):
"""
Create the parser for the remote CCM operations allowed
"""
self.parser = argparse.ArgumentParser(description="Remote",
add_help=False)
# Add the SSH arguments for the remote parser
self.parser.add_argument(
"--ssh-host",
default=None,
type=str,
help="Hostname or IP address to use for SSH connection"
)
self.parser.add_argument(
"--ssh-port",
default=22,
type=self.port,
help="Port to use for SSH connection"
)
self.parser.add_argument(
"--ssh-username",
default=None,
type=str,
help="Username to use for username/password or public key authentication"
)
self.parser.add_argument(
"--ssh-password",
default=None,
type=str,
help="Password to use for username/password or private key passphrase using public "
"key authentication"
)
self.parser.add_argument(
"--ssh-private-key",
default=None,
type=self.ssh_key,
help="Private key to use for SSH connection"
)
def parse_known_options(self):
"""
Parse the command line arguments and split out the remote options and CCM arguments
:return: A tuple defining the arguments parsed and actions to take
* remote_options - Remote options only
* ccm_args - CCM arguments only
"""
# Parse the known arguments and return the remote options and CCM arguments
remote_options, ccm_args = self.parser.parse_known_args()
return remote_options, ccm_args
@staticmethod
def ssh_key(key):
"""
SSH key parser validator (ensure file exists)
:param key: Filename/Key to validate (ensure exists)
:return: The filename/key passed in (if valid)
:raises Exception if filename/key is not a valid file
"""
value = str(key)
# Ensure the file exists locally
if not os.path.isfile(value):
raise Exception("File Does not Exist: %s" % key)
return value
@staticmethod
def port(port):
"""
Port validator
:param port: Port to validate (1 - 65535)
:return: Port passed in (if valid)
:raises ArgumentTypeError if port is not valid
"""
value = int(port)
if value <= 0 or value > 65535:
raise argparse.ArgumentTypeError("%s must be between [1 - 65535]" % port)
return value
def usage(self):
"""
Get the usage for the remote exectuion options
:return Usage for the remote execution options
"""
remote_arguments_usage = self.parser.format_help()
chunks = re.split("^(optional arguments:|options:)", remote_arguments_usage)
if len(chunks) > 2:
# Try to extract argument description only, otherwise output the whole usage string
remote_arguments_usage = chunks[2]
# Remove any blank lines and return
return "Remote Options:" + os.linesep + \
os.linesep.join([s for s in remote_arguments_usage.splitlines() if s])