Merge "Extend ACTS to run against remote testbeds"
diff --git a/acts/framework/acts/controllers/adb.py b/acts/framework/acts/controllers/adb.py
index be3c408..bbd54bf 100644
--- a/acts/framework/acts/controllers/adb.py
+++ b/acts/framework/acts/controllers/adb.py
@@ -19,10 +19,11 @@
 import logging
 import random
 import socket
-import subprocess
 import time
 
 from acts.controllers.utils_lib import host_utils
+from acts.controllers.utils_lib.ssh import connection
+from acts.libs.proc import job
 
 class AdbError(Exception):
     """Raised when there is an error in adb operations."""
@@ -38,7 +39,7 @@
                 ) % (self.cmd, self.ret_code, self.stdout, self.stderr)
 
 
-class AdbProxy():
+class AdbProxy(object):
     """Proxy class for ADB.
 
     For syntactic reasons, the '-' in adb commands need to be replaced with
@@ -48,12 +49,41 @@
     >> adb.devices() # will return the console output of "adb devices".
     """
 
-    def __init__(self, serial=""):
+    _SERVER_LOCAL_PORT = None
+
+    def __init__(self, serial="", ssh_connection=None):
+        """Construct an instance of AdbProxy.
+
+        Args:
+            serial: str serial number of Android device from `adb devices`
+            ssh_connection: SshConnection instance if the Android device is
+                            conected to a remote host that we can reach via SSH.
+        """
         self.serial = serial
+        adb_path = str(self._exec_cmd("which adb"), "utf-8").strip()
+        adb_cmd = [adb_path]
         if serial:
-            self.adb_str = "adb -s {}".format(serial)
-        else:
-            self.adb_str = "adb"
+            adb_cmd.append("-s %s" % serial)
+        if ssh_connection is not None and not AdbProxy._SERVER_LOCAL_PORT:
+            # Kill all existing adb processes on the remote host (if any)
+            # Note that if there are none, then pkill exits with non-zero status
+            ssh_connection.run("pkill adb", ignore_status=True)
+            # Copy over the adb binary to a temp dir
+            temp_dir = ssh_connection.run("mktemp -d").stdout.strip()
+            ssh_connection.send_file(adb_path, temp_dir)
+            # Start up a new adb server running as root from the copied binary.
+            remote_adb_cmd = "%s/adb %s root" % (
+                temp_dir,
+                "-s %s" % serial if serial else "")
+            ssh_connection.run(remote_adb_cmd)
+            # Proxy a local port to the adb server port
+            local_port = ssh_connection.create_ssh_tunnel(5037)
+            AdbProxy._SERVER_LOCAL_PORT = local_port
+
+        if AdbProxy._SERVER_LOCAL_PORT:
+            adb_cmd.append("-P %d" % local_port)
+        self.adb_cmd = " ".join(adb_cmd)
+        self._ssh_connection = ssh_connection
 
     def _exec_cmd(self, cmd):
         """Executes adb commands in a new shell.
@@ -70,12 +100,9 @@
         Raises:
             AdbError is raised if the adb command exit code is not 0.
         """
-        proc = subprocess.Popen(cmd,
-                                stdout=subprocess.PIPE,
-                                stderr=subprocess.PIPE,
-                                shell=True)
-        (out, err) = proc.communicate()
-        ret = proc.returncode
+        result = job.run(cmd, ignore_status=True)
+        ret, out, err = result.exit_status, result.raw_stdout, result.raw_stderr
+
         logging.debug("cmd: %s, stdout: %s, stderr: %s, ret: %s", cmd, out,
                       err, ret)
         if ret == 0:
@@ -93,6 +120,10 @@
             host_port: Port number to use on the computer.
             device_port: Port number to use on the android device.
         """
+        if self._ssh_connection:
+            # TODO(wiley): We need to actually pick a free port on the
+            #              intermediate host
+            self._ssh_connection.create_ssh_tunnel(host_port, local_port=host_port)
         self.forward("tcp:{} tcp:{}".format(host_port, device_port))
 
     def getprop(self, prop_name):
diff --git a/acts/framework/acts/controllers/android_device.py b/acts/framework/acts/controllers/android_device.py
index 94d2017..a7e0d08 100644
--- a/acts/framework/acts/controllers/android_device.py
+++ b/acts/framework/acts/controllers/android_device.py
@@ -30,6 +30,8 @@
 from acts.controllers import fastboot
 from acts.controllers import sl4a_client
 from acts.controllers.utils_lib import host_utils
+from acts.controllers.utils_lib.ssh import connection
+from acts.controllers.utils_lib.ssh import settings
 
 ACTS_CONTROLLER_CONFIG_NAME = "AndroidDevice"
 ACTS_CONTROLLER_REFERENCE_NAME = "android_devices"
@@ -72,10 +74,9 @@
     else:
         # Configs is a list of dicts.
         ads = get_instances_with_configs(configs)
-    connected_ads = list_adb_devices()
 
     for ad in ads:
-        if ad.serial not in connected_ads:
+        if not ad.is_connected():
             raise DoesNotExistError(("Android device %s is specified in config"
                                      " but is not attached.") % ad.serial)
     _start_services_on_ads(ads)
@@ -209,7 +210,12 @@
             raise AndroidDeviceError(
                 "Required value 'serial' is missing in AndroidDevice config %s."
                 % c)
-        ad = AndroidDevice(serial)
+        ssh_config = c.pop("ssh_config", None)
+        ssh_connection = None
+        if ssh_config is not None:
+            ssh_settings = settings.from_config(ssh_config)
+            ssh_connection = connection.SshConnection(ssh_settings)
+        ad = AndroidDevice(serial, ssh_connection=ssh_connection)
         ad.load_config(c)
         results.append(ad)
     return results
@@ -338,7 +344,8 @@
                   via fastboot.
     """
 
-    def __init__(self, serial="", host_port=None, device_port=8080):
+    def __init__(self, serial="", host_port=None, device_port=8080,
+                 ssh_connection=None):
         self.serial = serial
         self.h_port = host_port
         self.d_port = device_port
@@ -352,10 +359,12 @@
         self._event_dispatchers = {}
         self.adb_logcat_process = None
         self.adb_logcat_file_path = None
-        self.adb = adb.AdbProxy(serial)
-        self.fastboot = fastboot.FastbootProxy(serial)
+        self.adb = adb.AdbProxy(serial, ssh_connection=ssh_connection)
+        self.fastboot = fastboot.FastbootProxy(
+                serial, ssh_connection=ssh_connection)
         if not self.is_bootloader:
             self.root_adb()
+        self._ssh_connection = ssh_connection
 
     def clean_up(self):
         """Cleans up the AndroidDevice object and releases any resources it
@@ -364,6 +373,9 @@
         self.stop_services()
         if self.h_port:
             self.adb.forward("--remove tcp:%d" % self.h_port)
+        if self._ssh_connection:
+            self._ssh_connection.close()
+
 
     # TODO(angli): This function shall be refactored to accommodate all services
     # and not have hard coded switch for SL4A when b/29157104 is done.
@@ -399,6 +411,11 @@
         self.terminate_all_sessions()
         sl4a_client.stop_sl4a(self.adb)
 
+    def is_connected(self):
+        out = self.adb.devices()
+        devices = _parse_device_list(out, "device")
+        return self.serial in devices
+
     @property
     def build_info(self):
         """Get the build info of this Android device, including build id and
diff --git a/acts/framework/acts/controllers/fastboot.py b/acts/framework/acts/controllers/fastboot.py
index b06e3f3..7cfbcbb 100644
--- a/acts/framework/acts/controllers/fastboot.py
+++ b/acts/framework/acts/controllers/fastboot.py
@@ -53,15 +53,19 @@
     >> fb.devices() # will return the console output of "fastboot devices".
     """
 
-    def __init__(self, serial=""):
+    def __init__(self, serial="", ssh_connection=None):
         self.serial = serial
         if serial:
             self.fastboot_str = "fastboot -s {}".format(serial)
         else:
             self.fastboot_str = "fastboot"
+        self.ssh_connection = ssh_connection
 
     def _exec_fastboot_cmd(self, name, arg_str):
-        return exe_cmd(' '.join((self.fastboot_str, name, arg_str)))
+        command = ' '.join((self.fastboot_str, name, arg_str))
+        if self.ssh_connection:
+            return self.connection.run(command).raw_stdout
+        return exe_cmd(command)
 
     def args(self, *args):
         return exe_cmd(' '.join((self.fastboot_str, ) + args))
diff --git a/acts/framework/acts/controllers/utils_lib/ssh/connection.py b/acts/framework/acts/controllers/utils_lib/ssh/connection.py
index b56d43e..3295d2a 100644
--- a/acts/framework/acts/controllers/utils_lib/ssh/connection.py
+++ b/acts/framework/acts/controllers/utils_lib/ssh/connection.py
@@ -333,3 +333,14 @@
                       local_port, port, tunnel_proc.pid)
         self._tunnels.append(tunnel_proc)
         return local_port
+
+    def send_file(self, local_path, remote_path):
+        """Send a file from the local host to the remote host.
+
+        Args:
+            local_path: string path of file to send on local host.
+            remote_path: string path to copy file to on remote host.
+        """
+        # TODO: This may belong somewhere else: b/32572515
+        user_host = self._formatter.format_host_name(self._settings)
+        job.run('scp %s %s:%s' % (local_path, user_host, remote_path))
diff --git a/acts/framework/acts/controllers/utils_lib/ssh/settings.py b/acts/framework/acts/controllers/utils_lib/ssh/settings.py
index 97f94b6..b206db6 100644
--- a/acts/framework/acts/controllers/utils_lib/ssh/settings.py
+++ b/acts/framework/acts/controllers/utils_lib/ssh/settings.py
@@ -13,6 +13,28 @@
 # limitations under the License.
 
 
+"""Create a SshSettings from a dictionary from an ACTS config
+
+Args:
+    config dict instance from an ACTS config
+
+Returns:
+    An instance of SshSettings or None
+"""
+def from_config(config):
+    if config is None:
+        return None  # Having no settings is not an error
+
+    user = config.get('user', None)
+    host = config.get('host', None)
+    port = config.get('port', 22)
+    if user is None or host is None:
+        raise ValueError('Malformed SSH config did not include user and '
+                         'host keys: %s' % config)
+
+    return SshSettings(host, user, port=port)
+
+
 class SshSettings(object):
     """Contains settings for ssh.
 
diff --git a/acts/framework/tests/acts_android_device_test.py b/acts/framework/tests/acts_android_device_test.py
index d91e719..64123b9 100755
--- a/acts/framework/tests/acts_android_device_test.py
+++ b/acts/framework/tests/acts_android_device_test.py
@@ -95,6 +95,9 @@
         elif params == "sys.boot_completed":
             return "1"
 
+    def devices(self):
+        return bytearray("\t".join([str(self.serial), "device"]), "utf-8")
+
     def bugreport(self, params):
         expected = os.path.join(logging.log_path,
                                 "AndroidDevice%s" % self.serial, "BugReports",