mirror of
https://salsa.debian.org/freeipa-team/freeipa.git
synced 2025-02-25 18:55:28 -06:00
test_integration.host: Move transport-related functionality to a new module
This will make it possible to use a different mechanism for cases like - Paramiko is not available - Hosts without SSH servers (e.g. Windows) Add BaseHost, Transport & Command base classes that define the interface and common functionality, and Host, ParamikoTransport & SSHCommand with specific details. The {get,put}_file_contents methods are left on Host for convenience; all other Transport methods must be now accessed through the transport. Part of the work for https://fedorahosted.org/freeipa/ticket/3890
This commit is contained in:
parent
7d2d1cb59d
commit
758c73e149
@ -21,121 +21,17 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import threading
|
|
||||||
import subprocess
|
|
||||||
from contextlib import contextmanager
|
|
||||||
import errno
|
|
||||||
|
|
||||||
import paramiko
|
|
||||||
|
|
||||||
from ipapython.ipaldap import IPAdmin
|
from ipapython.ipaldap import IPAdmin
|
||||||
from ipapython import ipautil
|
from ipapython import ipautil
|
||||||
from ipapython.ipa_log_manager import log_mgr
|
from ipapython.ipa_log_manager import log_mgr
|
||||||
|
from ipatests.test_integration import transport
|
||||||
|
|
||||||
|
|
||||||
class RemoteCommand(object):
|
class BaseHost(object):
|
||||||
"""A Popen-style object representing a remote command
|
|
||||||
|
|
||||||
Unlike subprocess.Popen, this does not run the given command; instead
|
|
||||||
it only starts a shell. The command must be written to stdin manually.
|
|
||||||
|
|
||||||
The standard error and output are handled by this class. They're not
|
|
||||||
available for file-like reading. They are logged by default.
|
|
||||||
To make sure reading doesn't stall after one buffer fills up, they are read
|
|
||||||
in parallel using threads.
|
|
||||||
|
|
||||||
After calling wait(), stdout_text and stderr_text attributes will be
|
|
||||||
strings containing the output, and returncode will contain the
|
|
||||||
exit code.
|
|
||||||
|
|
||||||
:param host: The Host on which the command is run
|
|
||||||
:param argv: The command that will be run (for logging only)
|
|
||||||
:param index: An identification number added to the logs
|
|
||||||
:param log_stdout: If false, stdout will not be logged
|
|
||||||
"""
|
|
||||||
def __init__(self, host, argv, index, log_stdout=True):
|
|
||||||
self.returncode = None
|
|
||||||
self.host = host
|
|
||||||
self.argv = argv
|
|
||||||
self._stdout_lines = []
|
|
||||||
self._stderr_lines = []
|
|
||||||
self.running_threads = set()
|
|
||||||
|
|
||||||
self.logger_name = '%s.cmd%s' % (self.host.logger_name, index)
|
|
||||||
self.log = log_mgr.get_logger(self.logger_name)
|
|
||||||
|
|
||||||
self.log.info('RUN %s', argv)
|
|
||||||
|
|
||||||
self._ssh = host.transport.open_channel('session')
|
|
||||||
|
|
||||||
self._ssh.invoke_shell()
|
|
||||||
stdin = self.stdin = self._ssh.makefile('wb')
|
|
||||||
stdout = self._ssh.makefile('rb')
|
|
||||||
stderr = self._ssh.makefile_stderr('rb')
|
|
||||||
|
|
||||||
self._start_pipe_thread(self._stdout_lines, stdout, 'out', log_stdout)
|
|
||||||
self._start_pipe_thread(self._stderr_lines, stderr, 'err', True)
|
|
||||||
|
|
||||||
self._done = False
|
|
||||||
|
|
||||||
def wait(self, raiseonerr=True):
|
|
||||||
"""Wait for the remote process to exit
|
|
||||||
|
|
||||||
Raises an excption if the exit code is not 0.
|
|
||||||
"""
|
|
||||||
if self._done:
|
|
||||||
return self.returncode
|
|
||||||
|
|
||||||
self._ssh.shutdown_write()
|
|
||||||
while self.running_threads:
|
|
||||||
self.running_threads.pop().join()
|
|
||||||
|
|
||||||
self.stdout_text = ''.join(self._stdout_lines)
|
|
||||||
self.stderr_text = ''.join(self._stderr_lines)
|
|
||||||
self.returncode = self._ssh.recv_exit_status()
|
|
||||||
self._ssh.close()
|
|
||||||
|
|
||||||
self._done = True
|
|
||||||
|
|
||||||
if raiseonerr and self.returncode:
|
|
||||||
self.log.error('Exit code: %s', self.returncode)
|
|
||||||
raise subprocess.CalledProcessError(self.returncode, self.argv)
|
|
||||||
else:
|
|
||||||
self.log.debug('Exit code: %s', self.returncode)
|
|
||||||
return self.returncode
|
|
||||||
|
|
||||||
def _start_pipe_thread(self, result_list, stream, name, do_log=True):
|
|
||||||
log = log_mgr.get_logger('%s.%s' % (self.logger_name, name))
|
|
||||||
|
|
||||||
def read_stream():
|
|
||||||
for line in stream:
|
|
||||||
if do_log:
|
|
||||||
log.debug(line.rstrip('\n'))
|
|
||||||
result_list.append(line)
|
|
||||||
|
|
||||||
thread = threading.Thread(target=read_stream)
|
|
||||||
self.running_threads.add(thread)
|
|
||||||
thread.start()
|
|
||||||
return thread
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def sftp_open(sftp, filename, mode='r'):
|
|
||||||
"""Context manager that provides a file-like object over a SFTP channel
|
|
||||||
|
|
||||||
This provides compatibility with older Paramiko versions.
|
|
||||||
(In Paramiko 1.10+, file objects from `sftp.open` are directly usable as
|
|
||||||
context managers).
|
|
||||||
"""
|
|
||||||
file = sftp.open(filename, mode)
|
|
||||||
try:
|
|
||||||
yield file
|
|
||||||
finally:
|
|
||||||
file.close()
|
|
||||||
|
|
||||||
|
|
||||||
class Host(object):
|
|
||||||
"""Representation of a remote IPA host"""
|
"""Representation of a remote IPA host"""
|
||||||
|
transport_class = None
|
||||||
|
|
||||||
def __init__(self, domain, hostname, role, index, ip=None):
|
def __init__(self, domain, hostname, role, index, ip=None):
|
||||||
self.domain = domain
|
self.domain = domain
|
||||||
self.role = role
|
self.role = role
|
||||||
@ -175,8 +71,6 @@ class Host(object):
|
|||||||
|
|
||||||
self.env_sh_path = os.path.join(domain.config.test_dir, 'env.sh')
|
self.env_sh_path = os.path.join(domain.config.test_dir, 'env.sh')
|
||||||
|
|
||||||
self._command_index = 0
|
|
||||||
|
|
||||||
self.log_collectors = []
|
self.log_collectors = []
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -224,13 +118,48 @@ class Host(object):
|
|||||||
|
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
@property
|
||||||
|
def transport(self):
|
||||||
|
try:
|
||||||
|
return self._transport
|
||||||
|
except AttributeError:
|
||||||
|
cls = self.transport_class
|
||||||
|
if cls:
|
||||||
|
# transport_class is None in the base class and must be
|
||||||
|
# set in subclasses.
|
||||||
|
# Pylint reports that calling None will fail
|
||||||
|
self._transport = cls(self) # pylint: disable=E1102
|
||||||
|
else:
|
||||||
|
raise NotImplementedError('transport class not available')
|
||||||
|
return self._transport
|
||||||
|
|
||||||
|
def get_file_contents(self, filename):
|
||||||
|
"""Shortcut for transport.get_file_contents"""
|
||||||
|
return self.transport.get_file_contents(filename)
|
||||||
|
|
||||||
|
def put_file_contents(self, filename, contents):
|
||||||
|
"""Shortcut for transport.put_file_contents"""
|
||||||
|
self.transport.put_file_contents(filename, contents)
|
||||||
|
|
||||||
|
def ldap_connect(self):
|
||||||
|
"""Return an LDAPClient authenticated to this host as directory manager
|
||||||
|
"""
|
||||||
|
ldap = IPAdmin(self.external_hostname)
|
||||||
|
ldap.do_simple_bind(self.config.dirman_dn,
|
||||||
|
self.config.dirman_password)
|
||||||
|
return ldap
|
||||||
|
|
||||||
|
def collect_log(self, filename):
|
||||||
|
for collector in self.log_collectors:
|
||||||
|
collector(self, filename)
|
||||||
|
|
||||||
def run_command(self, argv, set_env=True, stdin_text=None,
|
def run_command(self, argv, set_env=True, stdin_text=None,
|
||||||
log_stdout=True, raiseonerr=True,
|
log_stdout=True, raiseonerr=True,
|
||||||
cwd=None):
|
cwd=None):
|
||||||
"""Run the given command on this host
|
"""Run the given command on this host
|
||||||
|
|
||||||
Returns a RemoteCommand instance. The command will have already run
|
Returns a Shell instance. The command will have already run in the
|
||||||
when this method returns, so its stdout_text, stderr_text, and
|
shell when this method returns, so its stdout_text, stderr_text, and
|
||||||
returncode attributes will be available.
|
returncode attributes will be available.
|
||||||
|
|
||||||
:param argv: Command to run, as either a Popen-style list, or a string
|
:param argv: Command to run, as either a Popen-style list, or a string
|
||||||
@ -242,30 +171,43 @@ class Host(object):
|
|||||||
(but will still be available as cmd.stdout_text)
|
(but will still be available as cmd.stdout_text)
|
||||||
:param raiseonerr: If true, an exception will be raised if the command
|
:param raiseonerr: If true, an exception will be raised if the command
|
||||||
does not exit with return code 0
|
does not exit with return code 0
|
||||||
|
:param cwd: The working directory for the command
|
||||||
"""
|
"""
|
||||||
assert self.transport
|
raise NotImplementedError()
|
||||||
|
|
||||||
self._command_index += 1
|
|
||||||
command = RemoteCommand(self, argv, index=self._command_index,
|
|
||||||
log_stdout=log_stdout)
|
|
||||||
|
|
||||||
|
class Host(BaseHost):
|
||||||
|
"""A Unix host"""
|
||||||
|
transport_class = transport.ParamikoTransport
|
||||||
|
|
||||||
|
def run_command(self, argv, set_env=True, stdin_text=None,
|
||||||
|
log_stdout=True, raiseonerr=True,
|
||||||
|
cwd=None):
|
||||||
|
# This will give us a Bash shell
|
||||||
|
command = self.transport.start_shell(argv, log_stdout=log_stdout)
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
if cwd is None:
|
if cwd is None:
|
||||||
cwd = self.config.test_dir
|
cwd = self.config.test_dir
|
||||||
command.stdin.write('cd %s\n' % ipautil.shell_quote(cwd))
|
command.stdin.write('cd %s\n' % ipautil.shell_quote(cwd))
|
||||||
|
|
||||||
|
# Set the environment
|
||||||
if set_env:
|
if set_env:
|
||||||
command.stdin.write('. %s\n' %
|
command.stdin.write('. %s\n' %
|
||||||
ipautil.shell_quote(self.env_sh_path))
|
ipautil.shell_quote(self.env_sh_path))
|
||||||
command.stdin.write('set -e\n')
|
command.stdin.write('set -e\n')
|
||||||
|
|
||||||
if isinstance(argv, basestring):
|
if isinstance(argv, basestring):
|
||||||
|
# Run a shell command given as a string
|
||||||
command.stdin.write('(')
|
command.stdin.write('(')
|
||||||
command.stdin.write(argv)
|
command.stdin.write(argv)
|
||||||
command.stdin.write(')')
|
command.stdin.write(')')
|
||||||
else:
|
else:
|
||||||
|
# Run a command given as a popen-style list (no shell expansion)
|
||||||
for arg in argv:
|
for arg in argv:
|
||||||
command.stdin.write(ipautil.shell_quote(arg))
|
command.stdin.write(ipautil.shell_quote(arg))
|
||||||
command.stdin.write(' ')
|
command.stdin.write(' ')
|
||||||
|
|
||||||
command.stdin.write(';exit\n')
|
command.stdin.write(';exit\n')
|
||||||
if stdin_text:
|
if stdin_text:
|
||||||
command.stdin.write(stdin_text)
|
command.stdin.write(stdin_text)
|
||||||
@ -273,92 +215,3 @@ class Host(object):
|
|||||||
|
|
||||||
command.wait(raiseonerr=raiseonerr)
|
command.wait(raiseonerr=raiseonerr)
|
||||||
return command
|
return command
|
||||||
|
|
||||||
@property
|
|
||||||
def transport(self):
|
|
||||||
"""Paramiko Transport connected to this host"""
|
|
||||||
try:
|
|
||||||
return self._transport
|
|
||||||
except AttributeError:
|
|
||||||
sock = socket.create_connection((self.external_hostname,
|
|
||||||
self.ssh_port))
|
|
||||||
self._transport = transport = paramiko.Transport(sock)
|
|
||||||
transport.connect(hostkey=self.host_key)
|
|
||||||
if self.root_ssh_key_filename:
|
|
||||||
self.log.debug('Authenticating with private RSA key')
|
|
||||||
filename = os.path.expanduser(self.root_ssh_key_filename)
|
|
||||||
key = paramiko.RSAKey.from_private_key_file(filename)
|
|
||||||
transport.auth_publickey(username='root', key=key)
|
|
||||||
elif self.root_password:
|
|
||||||
self.log.debug('Authenticating with password')
|
|
||||||
transport.auth_password(username='root',
|
|
||||||
password=self.root_password)
|
|
||||||
else:
|
|
||||||
self.log.critical('No SSH credentials configured')
|
|
||||||
raise RuntimeError('No SSH credentials configured')
|
|
||||||
return transport
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sftp(self):
|
|
||||||
"""Paramiko SFTPClient connected to this host"""
|
|
||||||
try:
|
|
||||||
return self._sftp
|
|
||||||
except AttributeError:
|
|
||||||
transport = self.transport
|
|
||||||
self._sftp = paramiko.SFTPClient.from_transport(transport)
|
|
||||||
return self._sftp
|
|
||||||
|
|
||||||
def ldap_connect(self):
|
|
||||||
"""Return an LDAPClient authenticated to this host as directory manager
|
|
||||||
"""
|
|
||||||
ldap = IPAdmin(self.external_hostname)
|
|
||||||
ldap.do_simple_bind(self.config.dirman_dn,
|
|
||||||
self.config.dirman_password)
|
|
||||||
return ldap
|
|
||||||
|
|
||||||
def mkdir_recursive(self, path):
|
|
||||||
"""`mkdir -p` on the remote host"""
|
|
||||||
try:
|
|
||||||
self.sftp.chdir(path or '/')
|
|
||||||
except IOError as e:
|
|
||||||
if not path or path == '/':
|
|
||||||
raise
|
|
||||||
self.mkdir_recursive(os.path.dirname(path))
|
|
||||||
self.sftp.mkdir(path)
|
|
||||||
self.sftp.chdir(path)
|
|
||||||
|
|
||||||
def get_file_contents(self, filename):
|
|
||||||
"""Read the named remote file and return the contents as a string"""
|
|
||||||
self.log.debug('READ %s', filename)
|
|
||||||
with sftp_open(self.sftp, filename) as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
def put_file_contents(self, filename, contents):
|
|
||||||
"""Write the given string to the named remote file"""
|
|
||||||
self.log.info('WRITE %s', filename)
|
|
||||||
with sftp_open(self.sftp, filename, 'w') as f:
|
|
||||||
f.write(contents)
|
|
||||||
|
|
||||||
def file_exists(self, filename):
|
|
||||||
"""Return true if the named remote file exists"""
|
|
||||||
self.log.debug('STAT %s', filename)
|
|
||||||
try:
|
|
||||||
self.sftp.stat(filename)
|
|
||||||
except IOError, e:
|
|
||||||
if e.errno == errno.ENOENT:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_file(self, remotepath, localpath):
|
|
||||||
self.log.debug('GET %s', remotepath)
|
|
||||||
self.sftp.get(remotepath, localpath)
|
|
||||||
|
|
||||||
def put_file(self, localpath, remotepath):
|
|
||||||
self.log.info('PUT %s', remotepath)
|
|
||||||
self.sftp.put(localpath, remotepath)
|
|
||||||
|
|
||||||
def collect_log(self, filename):
|
|
||||||
for collector in self.log_collectors:
|
|
||||||
collector(self, filename)
|
|
||||||
|
@ -40,7 +40,7 @@ log = log_mgr.get_logger(__name__)
|
|||||||
def prepare_host(host):
|
def prepare_host(host):
|
||||||
env_filename = os.path.join(host.config.test_dir, 'env.sh')
|
env_filename = os.path.join(host.config.test_dir, 'env.sh')
|
||||||
host.collect_log(env_filename)
|
host.collect_log(env_filename)
|
||||||
host.mkdir_recursive(host.config.test_dir)
|
host.transport.mkdir_recursive(host.config.test_dir)
|
||||||
host.put_file_contents(env_filename, env_to_script(host.to_env()))
|
host.put_file_contents(env_filename, env_to_script(host.to_env()))
|
||||||
|
|
||||||
|
|
||||||
@ -51,10 +51,10 @@ def apply_common_fixes(host):
|
|||||||
|
|
||||||
|
|
||||||
def backup_file(host, filename):
|
def backup_file(host, filename):
|
||||||
if host.file_exists(filename):
|
if host.transport.file_exists(filename):
|
||||||
backupname = os.path.join(host.config.test_dir, 'file_backup',
|
backupname = os.path.join(host.config.test_dir, 'file_backup',
|
||||||
filename.lstrip('/'))
|
filename.lstrip('/'))
|
||||||
host.mkdir_recursive(os.path.dirname(backupname))
|
host.transport.mkdir_recursive(os.path.dirname(backupname))
|
||||||
host.run_command(['cp', '-af', filename, backupname])
|
host.run_command(['cp', '-af', filename, backupname])
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
@ -63,7 +63,7 @@ def backup_file(host, filename):
|
|||||||
ipautil.shell_quote(filename),
|
ipautil.shell_quote(filename),
|
||||||
ipautil.shell_quote(rmname)))
|
ipautil.shell_quote(rmname)))
|
||||||
contents = host.get_file_contents(rmname)
|
contents = host.get_file_contents(rmname)
|
||||||
host.mkdir_recursive(os.path.dirname(rmname))
|
host.transport.mkdir_recursive(os.path.dirname(rmname))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@ -103,7 +103,7 @@ class CALessBase(IntegrationTest):
|
|||||||
host.mkdir_recursive(cls.crl_path)
|
host.mkdir_recursive(cls.crl_path)
|
||||||
for source in glob.glob(os.path.join(base, '*.crl')):
|
for source in glob.glob(os.path.join(base, '*.crl')):
|
||||||
dest = os.path.join(cls.crl_path, os.path.basename(source))
|
dest = os.path.join(cls.crl_path, os.path.basename(source))
|
||||||
host.put_file(source, dest)
|
host.transport.put_file(source, dest)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def uninstall(cls):
|
def uninstall(cls):
|
||||||
@ -174,7 +174,7 @@ class CALessBase(IntegrationTest):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def copy_cert(cls, host, filename):
|
def copy_cert(cls, host, filename):
|
||||||
host.put_file(os.path.join(cls.cert_dir, filename),
|
host.transport.put_file(os.path.join(cls.cert_dir, filename),
|
||||||
os.path.join(host.config.test_dir, filename))
|
os.path.join(host.config.test_dir, filename))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -211,7 +211,8 @@ class CALessBase(IntegrationTest):
|
|||||||
if dirsrv_pkcs12_exists:
|
if dirsrv_pkcs12_exists:
|
||||||
files_to_copy.append(dirsrv_pkcs12)
|
files_to_copy.append(dirsrv_pkcs12)
|
||||||
for filename in set(files_to_copy):
|
for filename in set(files_to_copy):
|
||||||
master.put_file(os.path.join(self.cert_dir, filename),
|
master.transport.put_file(
|
||||||
|
os.path.join(self.cert_dir, filename),
|
||||||
os.path.join(master.config.test_dir, filename))
|
os.path.join(master.config.test_dir, filename))
|
||||||
|
|
||||||
self.collect_log(replica, '/var/log/ipareplica-install.log')
|
self.collect_log(replica, '/var/log/ipareplica-install.log')
|
||||||
|
303
ipatests/test_integration/transport.py
Normal file
303
ipatests/test_integration/transport.py
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
# Authors:
|
||||||
|
# Petr Viktorin <pviktori@redhat.com>
|
||||||
|
#
|
||||||
|
# Copyright (C) 2013 Red Hat
|
||||||
|
# see file 'COPYING' for use and warranty information
|
||||||
|
#
|
||||||
|
# This program 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.
|
||||||
|
#
|
||||||
|
# This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""Objects for communicating with remote hosts"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import errno
|
||||||
|
|
||||||
|
from ipapython.ipa_log_manager import log_mgr
|
||||||
|
|
||||||
|
try:
|
||||||
|
import paramiko
|
||||||
|
have_paramiko = True
|
||||||
|
except ImportError:
|
||||||
|
have_paramiko = False
|
||||||
|
|
||||||
|
|
||||||
|
class Transport(object):
|
||||||
|
"""Mechanism for communicating with remote hosts
|
||||||
|
|
||||||
|
The Transport can manipulate files on a remote host, and open a Command.
|
||||||
|
|
||||||
|
The base class defines an interface that specific subclasses implement.
|
||||||
|
"""
|
||||||
|
def __init__(self, host):
|
||||||
|
self.host = host
|
||||||
|
self.logger_name = '%s.%s' % (host.logger_name, type(self).__name__)
|
||||||
|
self.log = log_mgr.get_logger(self.logger_name)
|
||||||
|
self._command_index = 0
|
||||||
|
|
||||||
|
def get_file_contents(self, filename):
|
||||||
|
"""Read the named remote file and return the contents as a string"""
|
||||||
|
raise NotImplementedError('Transport.get_file_contents')
|
||||||
|
|
||||||
|
def put_file_contents(self, filename, contents):
|
||||||
|
"""Write the given string to the named remote file"""
|
||||||
|
raise NotImplementedError('Transport.put_file_contents')
|
||||||
|
|
||||||
|
def file_exists(self, filename):
|
||||||
|
"""Return true if the named remote file exists"""
|
||||||
|
raise NotImplementedError('Transport.file_exists')
|
||||||
|
|
||||||
|
def mkdir(self, path):
|
||||||
|
"""Make the named directory"""
|
||||||
|
raise NotImplementedError('Transport.mkdir')
|
||||||
|
|
||||||
|
def start_shell(self, argv, log_stdout=True):
|
||||||
|
"""Start a Shell
|
||||||
|
|
||||||
|
:param argv: The command this shell is intended to run (used for
|
||||||
|
logging only)
|
||||||
|
:param log_stdout: If false, the stdout will not be logged (useful when
|
||||||
|
binary output is expected)
|
||||||
|
|
||||||
|
Given a `shell` from this method, the caller can then use
|
||||||
|
``shell.stdin.write()`` to input any command(s), call ``shell.wait()``
|
||||||
|
to let the command run, and then inspect ``returncode``,
|
||||||
|
``stdout_text`` or ``stderr_text``.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError('Transport.start_shell')
|
||||||
|
|
||||||
|
def mkdir_recursive(self, path):
|
||||||
|
"""`mkdir -p` on the remote host"""
|
||||||
|
if not path or path == '/':
|
||||||
|
raise ValueError('Invalid path')
|
||||||
|
if not self.file_exists(path or '/'):
|
||||||
|
self.mkdir_recursive(os.path.dirname(path))
|
||||||
|
self.mkdir(path)
|
||||||
|
|
||||||
|
def get_file(self, remotepath, localpath):
|
||||||
|
"""Copy a file from the remote host to a local file"""
|
||||||
|
contents = self.get_file_contents(remotepath)
|
||||||
|
with open(localpath, 'wb') as local_file:
|
||||||
|
local_file.write(contents)
|
||||||
|
|
||||||
|
def put_file(self, localpath, remotepath):
|
||||||
|
"""Copy a local file to the remote host"""
|
||||||
|
with open(localpath, 'rb') as local_file:
|
||||||
|
contents = local_file.read()
|
||||||
|
self.put_file_contents(remotepath, contents)
|
||||||
|
|
||||||
|
def get_next_command_logger_name(self):
|
||||||
|
self._command_index += 1
|
||||||
|
return '%s.cmd%s' % (self.host.logger_name, self._command_index)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(object):
|
||||||
|
"""A Popen-style object representing a remote command
|
||||||
|
|
||||||
|
Instances of this class should only be created via method of a concrete
|
||||||
|
Transport, such as start_shell.
|
||||||
|
|
||||||
|
The standard error and output are handled by this class. They're not
|
||||||
|
available for file-like reading, and are logged by default.
|
||||||
|
To make sure reading doesn't stall after one buffer fills up, they are read
|
||||||
|
in parallel using threads.
|
||||||
|
|
||||||
|
After calling wait(), ``stdout_text`` and ``stderr_text`` attributes will
|
||||||
|
be strings containing the output, and ``returncode`` will contain the
|
||||||
|
exit code.
|
||||||
|
"""
|
||||||
|
def __init__(self, argv, logger_name=None, log_stdout=True):
|
||||||
|
self.returncode = None
|
||||||
|
self.argv = argv
|
||||||
|
self._done = False
|
||||||
|
|
||||||
|
if logger_name:
|
||||||
|
self.logger_name = logger_name
|
||||||
|
else:
|
||||||
|
self.logger_name = '%s.%s' % (self.__module__, type(self).__name__)
|
||||||
|
self.log = log_mgr.get_logger(self.logger_name)
|
||||||
|
|
||||||
|
def wait(self, raiseonerr=True):
|
||||||
|
"""Wait for the remote process to exit
|
||||||
|
|
||||||
|
Raises an excption if the exit code is not 0, unless raiseonerr is
|
||||||
|
true.
|
||||||
|
"""
|
||||||
|
if self._done:
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
self._end_process()
|
||||||
|
|
||||||
|
self._done = True
|
||||||
|
|
||||||
|
if raiseonerr and self.returncode:
|
||||||
|
self.log.error('Exit code: %s', self.returncode)
|
||||||
|
raise subprocess.CalledProcessError(self.returncode, self.argv)
|
||||||
|
else:
|
||||||
|
self.log.debug('Exit code: %s', self.returncode)
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
def _end_process(self):
|
||||||
|
"""Wait until the process exits and output is received, close channel
|
||||||
|
|
||||||
|
Called from wait()
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class ParamikoTransport(Transport):
|
||||||
|
"""Transport that uses the Paramiko SSH2 library"""
|
||||||
|
def __init__(self, host):
|
||||||
|
super(ParamikoTransport, self).__init__(host)
|
||||||
|
sock = socket.create_connection((host.external_hostname,
|
||||||
|
host.ssh_port))
|
||||||
|
self._transport = transport = paramiko.Transport(sock)
|
||||||
|
transport.connect(hostkey=host.host_key)
|
||||||
|
if host.root_ssh_key_filename:
|
||||||
|
self.log.debug('Authenticating with private RSA key')
|
||||||
|
filename = os.path.expanduser(host.root_ssh_key_filename)
|
||||||
|
key = paramiko.RSAKey.from_private_key_file(filename)
|
||||||
|
transport.auth_publickey(username='root', key=key)
|
||||||
|
elif host.root_password:
|
||||||
|
self.log.debug('Authenticating with password')
|
||||||
|
transport.auth_password(username='root',
|
||||||
|
password=host.root_password)
|
||||||
|
else:
|
||||||
|
self.log.critical('No SSH credentials configured')
|
||||||
|
raise RuntimeError('No SSH credentials configured')
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def sftp_open(self, filename, mode='r'):
|
||||||
|
"""Context manager that provides a file-like object over a SFTP channel
|
||||||
|
|
||||||
|
This provides compatibility with older Paramiko versions.
|
||||||
|
(In Paramiko 1.10+, file objects from `sftp.open` are directly usable
|
||||||
|
as context managers).
|
||||||
|
"""
|
||||||
|
file = self.sftp.open(filename, mode)
|
||||||
|
try:
|
||||||
|
yield file
|
||||||
|
finally:
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sftp(self):
|
||||||
|
"""Paramiko SFTPClient connected to this host"""
|
||||||
|
try:
|
||||||
|
return self._sftp
|
||||||
|
except AttributeError:
|
||||||
|
transport = self._transport
|
||||||
|
self._sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
return self._sftp
|
||||||
|
|
||||||
|
def get_file_contents(self, filename):
|
||||||
|
"""Read the named remote file and return the contents as a string"""
|
||||||
|
self.log.debug('READ %s', filename)
|
||||||
|
with self.sftp_open(filename) as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def put_file_contents(self, filename, contents):
|
||||||
|
"""Write the given string to the named remote file"""
|
||||||
|
self.log.info('WRITE %s', filename)
|
||||||
|
with self.sftp_open(filename, 'w') as f:
|
||||||
|
f.write(contents)
|
||||||
|
|
||||||
|
def file_exists(self, filename):
|
||||||
|
"""Return true if the named remote file exists"""
|
||||||
|
self.log.debug('STAT %s', filename)
|
||||||
|
try:
|
||||||
|
self.sftp.stat(filename)
|
||||||
|
except IOError, e:
|
||||||
|
if e.errno == errno.ENOENT:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
return True
|
||||||
|
|
||||||
|
def mkdir(self, path):
|
||||||
|
self.log.info('MKDIR %s', path)
|
||||||
|
self.sftp.mkdir(path)
|
||||||
|
|
||||||
|
def start_shell(self, argv, log_stdout=True):
|
||||||
|
logger_name = self.get_next_command_logger_name()
|
||||||
|
ssh = self._transport.open_channel('session')
|
||||||
|
self.log.info('RUN %s', argv)
|
||||||
|
return SSHCommand(ssh, argv, logger_name=logger_name,
|
||||||
|
log_stdout=log_stdout)
|
||||||
|
|
||||||
|
def get_file(self, remotepath, localpath):
|
||||||
|
self.log.debug('GET %s', remotepath)
|
||||||
|
self.sftp.get(remotepath, localpath)
|
||||||
|
|
||||||
|
def put_file(self, localpath, remotepath):
|
||||||
|
self.log.info('PUT %s', remotepath)
|
||||||
|
self.sftp.put(localpath, remotepath)
|
||||||
|
|
||||||
|
|
||||||
|
class SSHCommand(Command):
|
||||||
|
"""Command implementation for ParamikoTransport and OpenSSHTranspport"""
|
||||||
|
def __init__(self, ssh, argv, logger_name, log_stdout=True,
|
||||||
|
collect_output=True):
|
||||||
|
super(SSHCommand, self).__init__(argv, logger_name,
|
||||||
|
log_stdout=log_stdout)
|
||||||
|
self._stdout_lines = []
|
||||||
|
self._stderr_lines = []
|
||||||
|
self.running_threads = set()
|
||||||
|
|
||||||
|
self._ssh = ssh
|
||||||
|
|
||||||
|
self.log.debug('RUN %s', argv)
|
||||||
|
|
||||||
|
self._ssh.invoke_shell()
|
||||||
|
stdin = self.stdin = self._ssh.makefile('wb')
|
||||||
|
stdout = self._ssh.makefile('rb')
|
||||||
|
stderr = self._ssh.makefile_stderr('rb')
|
||||||
|
|
||||||
|
if collect_output:
|
||||||
|
self._start_pipe_thread(self._stdout_lines, stdout, 'out',
|
||||||
|
log_stdout)
|
||||||
|
self._start_pipe_thread(self._stderr_lines, stderr, 'err', True)
|
||||||
|
|
||||||
|
def _end_process(self, raiseonerr=True):
|
||||||
|
self._ssh.shutdown_write()
|
||||||
|
|
||||||
|
while self.running_threads:
|
||||||
|
self.running_threads.pop().join()
|
||||||
|
|
||||||
|
self.stdout_text = ''.join(self._stdout_lines)
|
||||||
|
self.stderr_text = ''.join(self._stderr_lines)
|
||||||
|
self.returncode = self._ssh.recv_exit_status()
|
||||||
|
self._ssh.close()
|
||||||
|
|
||||||
|
def _start_pipe_thread(self, result_list, stream, name, do_log=True):
|
||||||
|
"""Start a thread that copies lines from ``stream`` to ``result_list``
|
||||||
|
|
||||||
|
If do_log is true, also logs the lines under ``name``
|
||||||
|
|
||||||
|
The thread is added to ``self.running_threads``.
|
||||||
|
"""
|
||||||
|
log = log_mgr.get_logger('%s.%s' % (self.logger_name, name))
|
||||||
|
|
||||||
|
def read_stream():
|
||||||
|
for line in stream:
|
||||||
|
if do_log:
|
||||||
|
log.debug(line.rstrip('\n'))
|
||||||
|
result_list.append(line)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=read_stream)
|
||||||
|
self.running_threads.add(thread)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
Loading…
Reference in New Issue
Block a user