[Autotest] multi-dut support in factory/test_that/autoserv

BUG=b/180139845
TEST=test_that --board=hana 100.107.106.131 infra_CompanionDuts --companion_hosts=100.107.106.133,chromeos6-row6-rack22-host3.cros

Change-Id: I26f33ea7f77a10aeaee11f77f0395ffe179be926
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/third_party/autotest/+/2723411
Commit-Queue: Derek Beckett <dbeckett@chromium.org>
Tested-by: Derek Beckett <dbeckett@chromium.org>
Reviewed-by: Garry Wang <xianuowang@chromium.org>
Reviewed-by: Seewai Fu <seewaifu@google.com>
diff --git a/server/autoserv b/server/autoserv
index 929e46c..77440a7 100755
--- a/server/autoserv
+++ b/server/autoserv
@@ -79,6 +79,27 @@
     sys.exit(1)
 
 
+def _get_companions(parser):
+    """Get a list of machine names from command line arg -m or a file.
+
+    @param parser: Parser for the command line arguments.
+
+    @return: A list of machine names from command line arg -m or the
+             machines file specified in the command line arg -M.
+    """
+    if parser.options.companion_hosts:
+        companions = parser.options.companion_hosts.replace(',', ' ').strip().split()
+    else:
+        companions = []
+
+    if companions:
+        for companion in companions:
+            if not companion or re.search('\s', companion):
+                parser.parser.error("Invalid companion: %s" % str(companion))
+        companions = list(set(companions))
+        companions.sort()
+    return companions
+
 def _get_machines(parser):
     """Get a list of machine names from command line arg -m or a file.
 
@@ -466,6 +487,7 @@
     ssh_options = parser.options.ssh_options
     no_use_packaging = parser.options.no_use_packaging
     in_lab = bool(parser.options.lab)
+    companion_hosts = _get_companions(parser)
 
     # can't be both a client and a server side test
     if client and server:
@@ -547,6 +569,13 @@
             'in_lab': in_lab,
             'use_client_trampoline': use_client_trampoline,
             'sync_offload_dir': parser.options.sync_offload_dir,
+            'companion_hosts': server_job.get_machine_dicts(
+                    machine_names=companion_hosts,
+                    store_dir=os.path.join(results,
+                                           parser.options.host_info_subdir),
+                    in_lab=in_lab,
+                    use_shadow_store=not parser.options.local_only_host_info,
+                    host_attributes=parser.options.host_attributes)
     }
     if parser.options.parent_job_id:
         job_kwargs['parent_job_id'] = int(parser.options.parent_job_id)
diff --git a/server/autoserv_parser.py b/server/autoserv_parser.py
index 66b7dfc..097ea2f 100644
--- a/server/autoserv_parser.py
+++ b/server/autoserv_parser.py
@@ -237,6 +237,11 @@
                                  default='2',
                                  type=str,
                                  choices=['2', '3'])
+        self.parser.add_argument('-ch',
+                                 action='store',
+                                 type=str,
+                                 dest='companion_hosts',
+                                 help='list of companion hosts for the test.')
 
         #
         # Warning! Please read before adding any new arguments!
diff --git a/server/autoserv_utils.py b/server/autoserv_utils.py
index 069dd6e..b1cfe32 100644
--- a/server/autoserv_utils.py
+++ b/server/autoserv_utils.py
@@ -17,10 +17,15 @@
 autoserv_path = os.path.join(autoserv_directory, 'autoserv')
 
 
-def autoserv_run_job_command(autoserv_directory, machines,
-                             results_directory=None, extra_args=[], job=None,
-                             queue_entry=None, verbose=True,
-                             write_pidfile=True, fast_mode=False,
+def autoserv_run_job_command(autoserv_directory,
+                             machines,
+                             results_directory=None,
+                             extra_args=[],
+                             job=None,
+                             queue_entry=None,
+                             verbose=True,
+                             write_pidfile=True,
+                             fast_mode=False,
                              ssh_verbosity=0,
                              no_console_prefix=False,
                              ssh_options=None,
@@ -28,7 +33,8 @@
                              in_lab=False,
                              host_attributes=None,
                              use_virtualenv=False,
-                             host_info_subdir=''):
+                             host_info_subdir='',
+                             companion_hosts=None):
     """
     Construct an autoserv command from a job or host queue entry.
 
@@ -68,6 +74,10 @@
                            support everywhere. Default: False.
     @param host_info_subdir: When set, a sub-directory of the results directory
                              where host info file(s) are stored.
+    @param companion_hosts: a str or list of hosts to be used as companions
+                            for the and provided to test. NOTE: these are
+                            different than  machines, where each host is a host
+                            that the test would be run on.
 
     @returns The autoserv command line as a list of executable + parameters.
 
@@ -96,6 +106,11 @@
     if machines:
         command += ['-m', machines]
 
+    if companion_hosts:
+        if not isinstance(companion_hosts, list):
+            companion_hosts = [companion_hosts]
+        command += ['-ch', ",".join(companion_hosts)]
+
     if ssh_verbosity:
         command += ['--ssh_verbosity', str(ssh_verbosity)]
 
diff --git a/server/hosts/__init__.py b/server/hosts/__init__.py
index e58e28d..77a8912 100644
--- a/server/hosts/__init__.py
+++ b/server/hosts/__init__.py
@@ -22,6 +22,8 @@
     # factory function
     from autotest_lib.server.hosts.factory import create_host
     from autotest_lib.server.hosts.factory import create_target_machine
+    from autotest_lib.server.hosts.factory import create_companion_hosts
+
 except ImportError:
     # host abstract classes
     from base_classes import Host
@@ -37,3 +39,4 @@
     # factory function
     from factory import create_host
     from factory import create_target_machine
+    from factory import create_companion_hosts
\ No newline at end of file
diff --git a/server/hosts/factory.py b/server/hosts/factory.py
index 4a421b9..a5b01d8 100644
--- a/server/hosts/factory.py
+++ b/server/hosts/factory.py
@@ -187,6 +187,20 @@
                  ignore_timeout=False)
 
 
+def create_companion_hosts(companion_hosts):
+    """Wrapped for create_hosts for making host objects on companion duts.
+
+    @param companion_hosts: str or list of extra_host hostnames
+
+    @returns: A list of host objects for each host in companion_hosts
+    """
+    if not isinstance(companion_hosts, list):
+        companion_hosts = [companion_hosts]
+    hosts = []
+    for host in companion_hosts:
+        hosts.append(create_host(host))
+    return hosts
+
 # TODO(kevcheng): Update the creation method so it's not a research project
 # determining the class inheritance model.
 def create_host(machine, host_class=None, connectivity_class=None, **args):
diff --git a/server/server_job.py b/server/server_job.py
index 2bbda25..20eb9b5 100644
--- a/server/server_job.py
+++ b/server/server_job.py
@@ -255,7 +255,8 @@
                  control_filename=SERVER_CONTROL_FILENAME,
                  parent_job_id=None, in_lab=False,
                  use_client_trampoline=False,
-                 sync_offload_dir=''):
+                 sync_offload_dir='',
+                 companion_hosts=None):
         """
         Create a server side job object.
 
@@ -295,6 +296,10 @@
                 control file.
         @param sync_offload_dir: String; relative path to synchronous offload
                 dir, relative to the results directory. Ignored if empty.
+        @param companion_hosts: a str or list of hosts to be used as companions
+                for the and provided to test. NOTE: these are different than
+                machines, where each host is a host that the test would be run
+                on.
         """
         super(server_job, self).__init__(resultdir=resultdir)
         self.control = control
@@ -327,6 +332,7 @@
         self._control_filename = control_filename
         self._disable_sysinfo = disable_sysinfo
         self._use_client_trampoline = use_client_trampoline
+        self._companion_hosts = companion_hosts
 
         self.logging = logging_manager.get_logging_manager(
                 manage_stdout_and_stderr=True, redirect_fds=True)
@@ -844,6 +850,8 @@
                     logging.debug("Results dir is %s", self.resultdir)
                     logging.debug("Synchronous offload dir is %s", sync_dir)
                 logging.info("Processing control file")
+                if self._companion_hosts:
+                    namespace['companion_hosts'] = self._companion_hosts
                 namespace['use_packaging'] = use_packaging
                 namespace['synchronous_offload_dir'] = sync_dir
                 namespace['extended_timeout'] = self.extended_timeout
diff --git a/server/site_tests/infra_CompanionDuts/control b/server/site_tests/infra_CompanionDuts/control
new file mode 100644
index 0000000..b4d1914
--- /dev/null
+++ b/server/site_tests/infra_CompanionDuts/control
@@ -0,0 +1,22 @@
+# Copyright 2021 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.
+
+AUTHOR = 'dbeckett'
+NAME = 'infra_CompanionDuts'
+TIME = 'SHORT'
+TEST_CATEGORY = 'General'
+TEST_CLASS = 'stub'
+TEST_TYPE = 'server'
+
+DOC = """
+Verify the companion dut flag reaches a test.
+"""
+
+
+def run(machine):
+    host = hosts.create_host(machine)
+    companions = hosts.create_companion_hosts(companion_hosts)
+    job.run_test('infra_CompanionDuts', host=host, companions=companions)
+
+parallel_simple(run, machines)
diff --git a/server/site_tests/infra_CompanionDuts/infra_CompanionDuts.py b/server/site_tests/infra_CompanionDuts/infra_CompanionDuts.py
new file mode 100644
index 0000000..aa03c09
--- /dev/null
+++ b/server/site_tests/infra_CompanionDuts/infra_CompanionDuts.py
@@ -0,0 +1,28 @@
+# Copyright 2021 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.
+
+from autotest_lib.client.common_lib import error
+from autotest_lib.server import test
+
+
+class infra_CompanionDuts(test.test):
+    """
+    Verify the companion dut flag reaches a test.
+
+    """
+    version = 1
+
+    def run_once(self, host, companions):
+        """
+        Starting point of this test.
+
+        Note: base class sets host as self._host.
+
+        """
+        self.host = host
+        for c in companions:
+            dut_out = c.run('echo True').stdout.strip()
+            if dut_out != 'True':
+                raise error.TestError("Companion DUT stdout != True (got: %s)",
+                                      dut_out)
diff --git a/site_utils/test_runner_utils.py b/site_utils/test_runner_utils.py
index 94bce75..000a2c1 100755
--- a/site_utils/test_runner_utils.py
+++ b/site_utils/test_runner_utils.py
@@ -252,7 +252,7 @@
 def run_job(job, host, info, autotest_path, results_directory, fast_mode,
             id_digits=1, ssh_verbosity=0, ssh_options=None,
             args=None, pretend=False,
-            autoserv_verbose=False):
+            autoserv_verbose=False, companion_hosts=None):
     """
     Shell out to autoserv to run an individual test job.
 
@@ -274,6 +274,7 @@
     @param pretend: If True, will print out autoserv commands rather than
                     running them.
     @param autoserv_verbose: If true, pass the --verbose flag to autoserv.
+    @param companion_hosts: Companion hosts for the test.
 
     @returns: a tuple, return code of the job and absolute path of directory
               where results were stored.
@@ -305,7 +306,8 @@
                 no_console_prefix=True,
                 use_packaging=False,
                 host_attributes=info.attributes,
-                host_info_subdir=_HOST_INFO_SUBDIR)
+                host_info_subdir=_HOST_INFO_SUBDIR,
+                companion_hosts=companion_hosts)
 
         code = _run_autoserv(command, pretend)
         return code, results_directory
@@ -454,7 +456,8 @@
                       autoserv_verbose=False,
                       iterations=1,
                       host_attributes={},
-                      job_retry=True):
+                      job_retry=True,
+                      companion_hosts=None):
     """Perform local run of tests.
 
     This method enforces satisfaction of test dependencies for tests that are
@@ -483,6 +486,7 @@
     @param iterations: int number of times to schedule tests.
     @param host_attributes: Dict of host attributes to pass into autoserv.
     @param job_retry: If False, tests will not be retried at all.
+    @param companion_hosts: companion hosts for the test.
 
     @returns: A list of return codes each job that has run. Or [1] if
               provision failed prior to running any jobs.
@@ -544,6 +548,7 @@
                 args,
                 pretend,
                 autoserv_verbose,
+                companion_hosts
         )
         codes.append(code)
         logging.debug("Code: %s, Results in %s", code, abs_dir)
@@ -696,7 +701,8 @@
                                    debug=False,
                                    allow_chrome_crashes=False,
                                    host_attributes={},
-                                   job_retry=True):
+                                   job_retry=True,
+                                   companion_hosts=None):
     """
     Perform a test_that run, from the |autotest_path|.
 
@@ -730,6 +736,7 @@
     @param allow_chrome_crashes: If True, allow chrome crashes.
     @param host_attributes: Dict of host attributes to pass into autoserv.
     @param job_retry: If False, tests will not be retried at all.
+    @param companion_hosts: companion hosts for the test.
 
     @return: A return code that test_that should exit with.
     """
@@ -766,8 +773,8 @@
                               autoserv_verbose=debug,
                               iterations=iterations,
                               host_attributes=host_attributes,
-                              job_retry=job_retry)
-
+                              job_retry=job_retry,
+                              companion_hosts=companion_hosts)
     if pretend:
         logging.info('Finished pretend run. Exiting.')
         return 0
diff --git a/site_utils/test_runner_utils_unittest.py b/site_utils/test_runner_utils_unittest.py
index 10c574b..daafa6c 100755
--- a/site_utils/test_runner_utils_unittest.py
+++ b/site_utils/test_runner_utils_unittest.py
@@ -216,6 +216,7 @@
                     mox.StrContains(self.args),
                     False,
                     False,
+                    None,
             ).AndReturn((0, '/fake/dir'))
 
         self.mox.ReplayAll()
diff --git a/site_utils/test_that.py b/site_utils/test_that.py
index 64a01a3..caf5e64 100755
--- a/site_utils/test_that.py
+++ b/site_utils/test_that.py
@@ -164,6 +164,10 @@
     parser.add_argument('--ssh_private_key', action='store',
                         default=test_runner_utils.TEST_KEY_PATH,
                         help='Path to the private ssh key.')
+    parser.add_argument('--companion_hosts',
+                        action='store',
+                        default=None,
+                        help='Companion duts for the test.')
     return parser.parse_args(argv), remote_argv
 
 
@@ -329,7 +333,8 @@
                 debug=arguments.debug,
                 allow_chrome_crashes=arguments.allow_chrome_crashes,
                 pretend=arguments.pretend,
-                job_retry=arguments.retry)
+                job_retry=arguments.retry,
+                companion_hosts=arguments.companion_hosts)
 
 
 def _main_for_lab_run(argv, arguments):