blob: 0fc50af6f9437f0eb5245b9f82bfd5903be779e9 [file] [log] [blame] [edit]
# Lint as: python2, python3
# Copyright (c) 2015 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import six.moves.http_client
import logging
import socket
import tempfile
import time
import six.moves.xmlrpc_client
import common
from autotest_lib.client.bin import utils
from autotest_lib.client.common_lib import error
from autotest_lib.client.common_lib.cros import retry
try:
import jsonrpclib
except ImportError:
jsonrpclib = None
class RpcServerTracker(object):
"""
This class keeps track of all the RPC server connections started on a remote
host. The caller can use either |xmlrpc_connect| or |jsonrpc_connect| to
start the required type of rpc server on the remote host.
The host will cleanup all the open RPC server connections on disconnect.
"""
_RPC_PROXY_URL_FORMAT = 'http://localhost:%d'
_RPC_HOST_ADDRESS_FORMAT = 'localhost:%d'
_RPC_SHUTDOWN_POLLING_PERIOD_SECONDS = 2
_RPC_SHUTDOWN_TIMEOUT_SECONDS = 10
def __init__(self, host):
"""
@param port: The host object associated with this instance of
RpcServerTracker.
"""
self._host = host
self._rpc_proxy_map = {}
def _setup_port(self, port, command_name, remote_pid=None):
"""Sets up a tunnel process and register it to rpc_server_tracker.
Chrome OS on the target closes down most external ports for security.
We could open the port, but doing that would conflict with security
tests that check that only expected ports are open. So, to get to
the port on the target we use an ssh tunnel.
This method assumes that xmlrpc and jsonrpc never conflict, since
we can only either have an xmlrpc or a jsonrpc server listening on
a remote port. As such, it enforces a single proxy->remote port
policy, i.e if one starts a jsonrpc proxy/server from port A->B,
and then tries to start an xmlrpc proxy forwarded to the same port,
the xmlrpc proxy will override the jsonrpc tunnel process, however:
1. None of the methods on the xmlrpc proxy will work because
the server listening on B is jsonrpc.
2. The xmlrpc client cannot initiate a termination of the JsonRPC
server, as the only use case currently is goofy, which is tied to
the factory image. It is much easier to handle a failed xmlrpc
call on the client than it is to terminate goofy in this scenario,
as doing the latter might leave the DUT in a hard to recover state.
With the current implementation newer rpc proxy connections will
terminate the tunnel processes of older rpc connections tunneling
to the same remote port. If methods are invoked on the client
after this has happened they will fail with connection closed errors.
@param port: The remote forwarding port.
@param command_name: The name of the remote process, to terminate
using pkill.
@param remote_pid: The PID of the remote background process
as a string.
@return the local port which is used for port forwarding on the ssh
client.
"""
self.disconnect(port)
local_port = utils.get_unused_port()
tunnel_proc = self._host.create_ssh_tunnel(port, local_port)
self._rpc_proxy_map[port] = (command_name, tunnel_proc, remote_pid)
return local_port
def _setup_rpc(self, port, command_name, remote_pid=None):
"""Construct a URL for an rpc connection using ssh tunnel.
@param port: The remote forwarding port.
@param command_name: The name of the remote process, to terminate
using pkill.
@param remote_pid: The PID of the remote background process
as a string.
@return a url that we can use to initiate the rpc connection.
"""
return self._RPC_PROXY_URL_FORMAT % self._setup_port(
port, command_name, remote_pid=remote_pid)
def tunnel_connect(self, port):
"""Construct a host address using ssh tunnel.
@param port: The remote forwarding port.
@return a host address using ssh tunnel.
"""
return self._RPC_HOST_ADDRESS_FORMAT % self._setup_port(port, None)
def xmlrpc_connect(self, command, port, command_name=None,
ready_test_name=None, timeout_seconds=10,
logfile=None, request_timeout_seconds=None,
server_desc=None):
"""Connect to an XMLRPC server on the host.
The `command` argument should be a simple shell command that
starts an XMLRPC server on the given `port`. The command
must not daemonize, and must terminate cleanly on SIGTERM.
The command is started in the background on the host, and a
local XMLRPC client for the server is created and returned
to the caller.
Note that the process of creating an XMLRPC client makes no
attempt to connect to the remote server; the caller is
responsible for determining whether the server is running
correctly, and is ready to serve requests.
Optionally, the caller can pass ready_test_name, a string
containing the name of a method to call on the proxy. This
method should take no parameters and return successfully only
when the server is ready to process client requests. When
ready_test_name is set, xmlrpc_connect will block until the
proxy is ready, and throw a TestError if the server isn't
ready by timeout_seconds.
If a server is already running on the remote port, this
method will kill it and disconnect the tunnel process
associated with the connection before establishing a new one,
by consulting the rpc_proxy_map in disconnect.
@param command Shell command to start the server.
@param port Port number on which the server is expected to
be serving.
@param command_name String to use as input to `pkill` to
terminate the XMLRPC server on the host.
@param ready_test_name String containing the name of a
method defined on the XMLRPC server.
@param timeout_seconds Number of seconds to wait
for the server to become 'ready.' Will throw a
TestFail error if server is not ready in time.
@param logfile Logfile to send output when running
'command' argument.
@param request_timeout_seconds Timeout in seconds for an XMLRPC request.
@param server_desc: Extra text to report in socket.error descriptions.
"""
# Clean up any existing state. If the caller is willing
# to believe their server is down, we ought to clean up
# any tunnels we might have sitting around.
self.disconnect(port)
remote_pid = None
if command is not None:
if logfile:
remote_cmd = '%s > %s 2>&1' % (command, logfile)
else:
remote_cmd = command
remote_pid = self._host.run_background(remote_cmd)
logging.debug('Started XMLRPC server on host %s, pid = %s',
self._host.hostname, remote_pid)
# Tunnel through SSH to be able to reach that remote port.
rpc_url = self._setup_rpc(port, command_name, remote_pid=remote_pid)
if not server_desc:
server_desc = "<%s '%s:%s'>" % (command_name or 'XMLRPC',
self._host.hostname, port)
server_desc = '%s (%s)' % (server_desc, rpc_url.replace('http://', ''))
if request_timeout_seconds is not None:
proxy = TimeoutXMLRPCServerProxy(
rpc_url, timeout=request_timeout_seconds, allow_none=True)
else:
proxy = six.moves.xmlrpc_client.ServerProxy(rpc_url, allow_none=True)
if ready_test_name is not None:
# retry.retry logs each attempt; calculate delay_sec to
# keep log spam to a dull roar.
@retry.retry((socket.error,
six.moves.xmlrpc_client.ProtocolError,
six.moves.http_client.BadStatusLine),
timeout_min=timeout_seconds / 60.0,
delay_sec=min(max(timeout_seconds / 20.0, 0.1), 1))
def ready_test():
""" Call proxy.ready_test_name(). """
try:
getattr(proxy, ready_test_name)()
except socket.error as e:
e.filename = server_desc
raise
try:
logging.info('Waiting %d seconds for XMLRPC server '
'to start.', timeout_seconds)
ready_test()
except Exception as exc:
log_lines = []
if logfile:
logging.warn('Failed to start XMLRPC server; getting log.')
with tempfile.NamedTemporaryFile() as temp:
self._host.get_file(logfile, temp.name)
with open(temp.name) as f:
log_lines = f.read().rstrip().splitlines()
else:
logging.warn('Failed to start XMLRPC server; no log.')
logging.error(
'Failed to start XMLRPC server: %s.%s: %s.',
type(exc).__module__, type(exc).__name__,
str(exc).rstrip('.'))
if isinstance(exc, six.moves.http_client.BadStatusLine):
# BadStatusLine: inject the last log line into the message,
# using the 'line' and 'args' attributes.
if log_lines:
if exc.line:
exc.line = '%s -- Log tail: %r' % (
exc.line, log_lines[-1])
else:
exc.line = 'Log tail: %r' % (
log_lines[-1])
exc.args = (exc.line,)
elif isinstance(exc, socket.error):
# socket.error: inject the last log line into the message,
# using the 'filename' attribute.
if log_lines:
if exc.filename:
exc.filename = '%s -- Log tail: %r' % (
exc.filename, log_lines[-1])
else:
exc.filename = 'Log tail: %r' % log_lines[-1]
elif log_lines:
# Unusual failure: can't inject the last log line,
# so report it via logging.
logging.error('Log tail: %r', log_lines[-1])
if len(log_lines) > 1:
# The failure messages include only the last line,
# so report the whole thing if it had more lines.
logging.error('Full XMLRPC server log:\n%s',
'\n'.join(log_lines))
self.disconnect(port)
raise
logging.info('XMLRPC server started successfully.')
return proxy
def jsonrpc_connect(self, port):
"""Creates a jsonrpc proxy connection through an ssh tunnel.
This method exists to facilitate communication with goofy (which is
the default system manager on all factory images) and as such, leaves
most of the rpc server sanity checking to the caller. Unlike
xmlrpc_connect, this method does not facilitate the creation of a remote
jsonrpc server, as the only clients of this code are factory tests,
for which the goofy system manager is built in to the image and starts
when the target boots.
One can theoretically create multiple jsonrpc proxies all forwarded
to the same remote port, provided the remote port has an rpc server
listening. However, in doing so we stand the risk of leaking an
existing tunnel process, so we always disconnect any older tunnels
we might have through disconnect.
@param port: port on the remote host that is serving this proxy.
@return: The client proxy.
"""
if not jsonrpclib:
logging.warning('Jsonrpclib could not be imported. Check that '
'site-packages contains jsonrpclib.')
return None
proxy = jsonrpclib.jsonrpc.ServerProxy(self._setup_rpc(port, None))
logging.info('Established a jsonrpc connection through port %s.', port)
return proxy
def disconnect(self, port, pkill=True):
"""Disconnect from an RPC server on the host.
Terminates the remote RPC server previously started for
the given `port`. Also closes the local ssh tunnel created
for the connection to the host. This function does not
directly alter the state of a previously returned RPC
client object; however disconnection will cause all
subsequent calls to methods on the object to fail.
This function does nothing if requested to disconnect a port
that was not previously connected via _setup_rpc.
@param port Port number passed to a previous call to `_setup_rpc()`.
@param pkill: if True, ssh in to the server and pkill the process.
"""
if port not in self._rpc_proxy_map:
return
remote_name, tunnel_proc, remote_pid = self._rpc_proxy_map[port]
if pkill and remote_name:
# We use 'pkill' to find our target process rather than
# a PID, because the host may have rebooted since
# connecting, and we don't want to kill an innocent
# process with the same PID.
#
# 'pkill' helpfully exits with status 1 if no target
# process is found, for which run() will throw an
# exception. We don't want that, so we the ignore
# status.
self._host.run("pkill -f '%s'" % remote_name, ignore_status=True)
if remote_pid:
logging.info('Waiting for RPC server "%s" shutdown',
remote_name)
start_time = time.time()
while (time.time() - start_time <
self._RPC_SHUTDOWN_TIMEOUT_SECONDS):
running_processes = self._host.run(
"pgrep -f '%s'" % remote_name,
ignore_status=True).stdout.split()
if not remote_pid in running_processes:
logging.info('Shut down RPC server.')
break
time.sleep(self._RPC_SHUTDOWN_POLLING_PERIOD_SECONDS)
else:
raise error.TestError('Failed to shutdown RPC server %s' %
remote_name)
self._host.disconnect_ssh_tunnel(tunnel_proc)
del self._rpc_proxy_map[port]
def disconnect_all(self):
"""Disconnect all known RPC proxy ports."""
for port in self._rpc_proxy_map.keys():
self.disconnect(port)
class TimeoutXMLRPCServerProxy(six.moves.xmlrpc_client.ServerProxy):
"""XMLRPC ServerProxy supporting timeout."""
def __init__(self, uri, timeout=20, *args, **kwargs):
"""Initializes a TimeoutXMLRPCServerProxy.
@param uri: URI to a XMLRPC server.
@param timeout: Timeout in seconds for a XMLRPC request.
@param *args: args to xmlrpclib.ServerProxy.
@param **kwargs: kwargs to xmlrpclib.ServerProxy.
"""
if timeout:
kwargs['transport'] = TimeoutXMLRPCTransport(timeout=timeout)
six.moves.xmlrpc_client.ServerProxy.__init__(self, uri, *args, **kwargs)
class TimeoutXMLRPCTransport(six.moves.xmlrpc_client.Transport):
"""A Transport subclass supporting timeout."""
def __init__(self, timeout=20, *args, **kwargs):
"""Initializes a TimeoutXMLRPCTransport.
@param timeout: Timeout in seconds for a HTTP request through this transport layer.
@param *args: args to xmlrpclib.Transport.
@param **kwargs: kwargs to xmlrpclib.Transport.
"""
six.moves.xmlrpc_client.Transport.__init__(self, *args, **kwargs)
self.timeout = timeout
def make_connection(self, host):
"""Overwrites make_connection in xmlrpclib.Transport with timeout.
@param host: Host address to connect.
@return: A httplib.HTTPConnection connecting to host with timeout.
"""
conn = six.moves.http_client.HTTPConnection(host, timeout=self.timeout)
return conn