cros-test: Support companion DUTs

It is the second CL of a chain of CLs to enhance cros-test to support
multi-DUTs.
This CL add code to suport companion DUTs for both Tast & Tauto.
New unit test has been added.

BUG=b:199941891
TEST=./fast_build.sh -T; cros-test --input ...companion.json ....

Change-Id: Iab4188c9633b0b4f8e218b9851e1aee9757b21bf
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/dev-util/+/3216909
Commit-Queue: Seewai Fu <seewaifu@google.com>
Tested-by: Seewai Fu <seewaifu@google.com>
Reviewed-by: Derek Beckett <dbeckett@chromium.org>
Reviewed-by: Jesse McGuire <jessemcguire@google.com>
diff --git a/src/chromiumos/test/execution/cmd/cros-test/internal/driver/driver.go b/src/chromiumos/test/execution/cmd/cros-test/internal/driver/driver.go
index d29979e..1f8c9a4 100644
--- a/src/chromiumos/test/execution/cmd/cros-test/internal/driver/driver.go
+++ b/src/chromiumos/test/execution/cmd/cros-test/internal/driver/driver.go
@@ -34,7 +34,7 @@
 // Driver provides common interface to execute Tast and Autotest.
 type Driver interface {
 	// RunTests drives a test framework to execute tests.
-	RunTests(ctx context.Context, resultsDir string, primary *api.CrosTestRequest_Device, tlwAddr string, tests []*api.TestCaseMetadata) (*api.CrosTestResponse, error)
+	RunTests(ctx context.Context, resultsDir string, req *api.CrosTestRequest, tlwAddr string, tests []*api.TestCaseMetadata) (*api.CrosTestResponse, error)
 
 	// Name returns the name of the driver.
 	Name() string
diff --git a/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tast_driver.go b/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tast_driver.go
index b7fd968..78e031d 100644
--- a/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tast_driver.go
+++ b/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tast_driver.go
@@ -39,7 +39,10 @@
 }
 
 // RunTests drives a test framework to execute tests.
-func (td *TastDriver) RunTests(ctx context.Context, resultsDir string, primary *api.CrosTestRequest_Device, tlwAddr string, tests []*api.TestCaseMetadata) (*api.CrosTestResponse, error) {
+func (td *TastDriver) RunTests(ctx context.Context, resultsDir string, req *api.CrosTestRequest, tlwAddr string, tests []*api.TestCaseMetadata) (*api.CrosTestResponse, error) {
+	primary := req.Primary
+	companions := req.Companions
+
 	testNamesToIds := getTestNamesToIds(tests)
 	testNames := getTestNames(tests)
 
@@ -55,7 +58,16 @@
 		return nil, errors.NewStatusError(errors.InvalidArgument,
 			fmt.Errorf("cannot get address from primary device: %v", primary))
 	}
-	args := newTastArgs(addr, testNames, resultsDir, tlwAddr, reportServer.Address())
+	var companionAddrs []string
+	for _, c := range companions {
+		address, err := device.Address(c)
+		if err != nil {
+			return nil, errors.NewStatusError(errors.InvalidArgument,
+				fmt.Errorf("cannot get address from companion device: %v", c))
+		}
+		companionAddrs = append(companionAddrs, address)
+	}
+	args := newTastArgs(addr, companionAddrs, testNames, resultsDir, tlwAddr, reportServer.Address())
 
 	// Run tast.
 	cmd := exec.Command("/usr/bin/tast", genArgList(args)...)
@@ -136,25 +148,28 @@
 	tlwServerFlag              = "-tlwserver"
 	waitUntilReadyFlag         = "-waituntilready"
 	timeOutFlag                = "-timeout"
-	keyfile                    = "-keyfile"
-	reportsServer              = "-reports_server"
+	keyfileFlag                = "-keyfile"
+	reportsServerFlag          = "-reports_server"
+	companionDUTFlag           = "-companiondut"
 )
 
 // runArgs stores arguments to invoke Tast
 type runArgs struct {
-	target    string            // The url for the target machine.
-	patterns  []string          // The names of test to be run.
-	tastFlags map[string]string // The flags for tast.
-	runFlags  map[string]string // The flags for tast run command.
+	target     string            // The url for the target machine.
+	patterns   []string          // The names of test to be run.
+	tastFlags  map[string]string // The flags for tast.
+	runFlags   map[string]string // The flags for tast run command.
+	companions []string          // The companion DUTs to be used for testing.
 }
 
 // newTastArgs created an argument structure for invoking tast
-func newTastArgs(dut string, tests []string, resultsDir, tlwAddress, rsAddress string) *runArgs {
+func newTastArgs(dut string, companionDuts, tests []string, resultsDir, tlwAddress, rsAddress string) *runArgs {
 	downloadPrivateBundles := "false"
 	// Change downloadPrivateBundlesFlag to "true" if tlwServer is specified.
 	if tlwAddress != "" {
 		downloadPrivateBundles = "true"
 	}
+
 	return &runArgs{
 		target: dut,
 		tastFlags: map[string]string{
@@ -168,10 +183,11 @@
 			downloadPrivateBundlesFlag: downloadPrivateBundles,
 			timeOutFlag:                "3000",
 			resultsDirFlag:             resultsDir,
-			reportsServer:              rsAddress,
+			reportsServerFlag:          rsAddress,
 			tlwServerFlag:              tlwAddress,
 		},
-		patterns: tests, // TO-DO Support Tags
+		patterns:   tests, // TO-DO Support Tags
+		companions: companionDuts,
 	}
 }
 
@@ -184,6 +200,10 @@
 	for flag, value := range args.runFlags {
 		argList = append(argList, fmt.Sprintf("%v=%v", flag, value))
 	}
+	for i, c := range args.companions {
+		// example: -companiondut=cd1:127.0.0.1:2222
+		argList = append(argList, fmt.Sprintf("%v=cd%v:%v", companionDUTFlag, i+1, c))
+	}
 	argList = append(argList, args.target)
 	argList = append(argList, args.patterns...)
 	return argList
diff --git a/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tast_driver_test.go b/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tast_driver_test.go
index 23e11c0..2697e0a 100644
--- a/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tast_driver_test.go
+++ b/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tast_driver_test.go
@@ -9,6 +9,7 @@
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
 )
 
 // TestNewTastArgs makes sure newTastArgs creates the correct arguments for tast.
@@ -27,13 +28,13 @@
 			downloadPrivateBundlesFlag: "true",
 			timeOutFlag:                "3000",
 			resultsDirFlag:             workDir1,
-			reportsServer:              ":5555",
+			reportsServerFlag:          ":5555",
 			tlwServerFlag:              tlwAddress,
 		},
 	}
 
-	args := newTastArgs(dut1, expectedArgs.patterns, workDir1, tlwAddress, expectedArgs.runFlags[reportsServer])
-	if diff := cmp.Diff(args, &expectedArgs, cmp.AllowUnexported(runArgs{})); diff != "" {
+	args := newTastArgs(dut1, []string{}, expectedArgs.patterns, workDir1, tlwAddress, expectedArgs.runFlags[reportsServerFlag])
+	if diff := cmp.Diff(args, &expectedArgs, cmp.AllowUnexported(runArgs{}), cmpopts.EquateEmpty()); diff != "" {
 		t.Errorf("Got unexpected argument from newTastArgs (-got +want):\n%s\n%v\n--\n%v\n", diff, args, expectedArgs)
 	}
 }
@@ -54,12 +55,41 @@
 			downloadPrivateBundlesFlag: "false",
 			timeOutFlag:                "3000",
 			resultsDirFlag:             workDir1,
-			reportsServer:              ":5555",
+			reportsServerFlag:          ":5555",
 			tlwServerFlag:              "",
 		},
 	}
 
-	args := newTastArgs(dut1, expectedArgs.patterns, workDir1, "", expectedArgs.runFlags[reportsServer])
+	args := newTastArgs(dut1, []string{}, expectedArgs.patterns, workDir1, "", expectedArgs.runFlags[reportsServerFlag])
+	if diff := cmp.Diff(args, &expectedArgs, cmp.AllowUnexported(runArgs{}), cmpopts.EquateEmpty()); diff != "" {
+		t.Errorf("Got unexpected argument from newTastArgs (-got +want):\n%s", diff)
+	}
+}
+
+// TestNewTastArgsCompanions makes sure newTastArgs creates the correct arguments for tast with companion DUTs.
+func TestNewTastArgsCompanions(t *testing.T) {
+	companions := []string{"companion_dut1_address", "companion_dut2_address"}
+	expectedArgs := runArgs{
+		target:   dut1,
+		patterns: []string{test1, test2, test3, test4, test5},
+		tastFlags: map[string]string{
+			verboseFlag: "true",
+			logTimeFlag: "false",
+		},
+		runFlags: map[string]string{
+			sshRetriesFlag:             "2",
+			downloadDataFlag:           "batch",
+			buildFlag:                  "false",
+			downloadPrivateBundlesFlag: "false",
+			timeOutFlag:                "3000",
+			resultsDirFlag:             workDir1,
+			reportsServerFlag:          ":5555",
+			tlwServerFlag:              "",
+		},
+		companions: companions,
+	}
+
+	args := newTastArgs(dut1, companions, expectedArgs.patterns, workDir1, "", expectedArgs.runFlags[reportsServerFlag])
 	if diff := cmp.Diff(args, &expectedArgs, cmp.AllowUnexported(runArgs{})); diff != "" {
 		t.Errorf("Got unexpected argument from newTastArgs (-got +want):\n%s", diff)
 	}
@@ -67,6 +97,7 @@
 
 // TestGenArgList makes sure genArgList generates the correct list of argument for tast.
 func TestGenArgList(t *testing.T) {
+	companions := []string{"companion_dut1_address", "companion_dut2_address"}
 	args := runArgs{
 		target:   dut1,
 		patterns: []string{test1, test2},
@@ -82,8 +113,9 @@
 			timeOutFlag:                "3000",
 			resultsDirFlag:             workDir1,
 			tlwServerFlag:              tlwAddress,
-			reportsServer:              "127.0.0.1:3333",
+			reportsServerFlag:          "127.0.0.1:3333",
 		},
+		companions: companions,
 	}
 
 	var expectedArgList []string
@@ -95,6 +127,9 @@
 	for key, value := range args.runFlags {
 		expectedArgList = append(expectedArgList, fmt.Sprintf("%v=%v", key, value))
 	}
+	for i, c := range companions {
+		expectedArgList = append(expectedArgList, fmt.Sprintf("%v=cd%v:%v", companionDUTFlag, i+1, c))
+	}
 	dutIndex := len(expectedArgList)
 	expectedArgList = append(expectedArgList, dut1)
 	expectedArgList = append(expectedArgList, test1)
diff --git a/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tauto_driver.go b/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tauto_driver.go
index 4eecaa8..bfba197 100644
--- a/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tauto_driver.go
+++ b/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tauto_driver.go
@@ -11,6 +11,7 @@
 	"fmt"
 	"log"
 	"os/exec"
+	"strings"
 	"sync"
 
 	"chromiumos/lro"
@@ -47,7 +48,9 @@
 }
 
 // RunTests drives a test framework to execute tests.
-func (td *TautoDriver) RunTests(ctx context.Context, resultsDir string, primary *api.CrosTestRequest_Device, tlwAddr string, tests []*api.TestCaseMetadata) (*api.CrosTestResponse, error) {
+func (td *TautoDriver) RunTests(ctx context.Context, resultsDir string, req *api.CrosTestRequest, tlwAddr string, tests []*api.TestCaseMetadata) (*api.CrosTestResponse, error) {
+	primary := req.Primary
+	companions := req.Companions
 	testNamesToIds := getTestNamesToIds(tests)
 	testNames := getTestNames(tests)
 
@@ -55,7 +58,15 @@
 	if err != nil {
 		return nil, fmt.Errorf("cannot get address from DUT: %v", primary)
 	}
-	args := newTautoArgs(addr, testNames, resultsDir)
+	var companionAddrs []string
+	for _, c := range companions {
+		address, err := device.Address(c)
+		if err != nil {
+			return nil, fmt.Errorf("cannot get address from companion device: %v", c)
+		}
+		companionAddrs = append(companionAddrs, address)
+	}
+	args := newTautoArgs(addr, companionAddrs, testNames, resultsDir)
 
 	// Run RTD.
 	cmd := exec.Command("/usr/bin/test_that", genTautoArgList(args)...)
@@ -106,8 +117,9 @@
 
 // Flag names. More to be populated once impl details are firmed.
 const (
-	autotestDir         = "--autotest_dir"
+	autotestDirFlag     = "--autotest_dir"
 	tautoResultsDirFlag = "--results_dir"
+	companionFlag       = "--companion_hosts"
 )
 
 // tautoRunArgs stores arguments to invoke tauto
@@ -118,13 +130,17 @@
 }
 
 // newTautoArgs created an argument structure for invoking tauto
-func newTautoArgs(dut string, tests []string, resultsDir string) *tautoRunArgs {
+func newTautoArgs(dut string, companions, tests []string, resultsDir string) *tautoRunArgs {
 	args := tautoRunArgs{
 		target: dut,
 		runFlags: map[string]string{
-			autotestDir: common.AutotestDir,
+			autotestDirFlag: common.AutotestDir,
 		},
 	}
+	if len(companions) > 0 {
+		companionsAddresses := strings.Join(companions, ",")
+		args.runFlags[companionFlag] = companionsAddresses
+	}
 
 	args.patterns = tests // TO-DO Support Tags
 	args.runFlags[tautoResultsDirFlag] = resultsDir
diff --git a/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tauto_driver_test.go b/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tauto_driver_test.go
index 80dcd44..003ddc4 100644
--- a/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tauto_driver_test.go
+++ b/src/chromiumos/test/execution/cmd/cros-test/internal/driver/tauto_driver_test.go
@@ -13,18 +13,20 @@
 
 // TestNewTautoArgs makes sure newTautoArgs creates the correct arguments for tauto.
 func TestNewTautoArgs(t *testing.T) {
+	companions := []string{"companion1", "companion2"}
 	expectedArgs := tautoRunArgs{
 		target:   dut1,
 		patterns: []string{test1, test2, test3, test4, test5},
 		runFlags: map[string]string{
 			tautoResultsDirFlag: workDir1,
-			autotestDir:         "/usr/local/autotest/",
+			autotestDirFlag:     "/usr/local/autotest/",
+			companionFlag:       "companion1,companion2",
 		},
 	}
 
 	dut := dut1
 	tests := []string{test1, test2, test3, test4, test5}
-	args := newTautoArgs(dut, tests, workDir1)
+	args := newTautoArgs(dut, companions, tests, workDir1)
 	if diff := cmp.Diff(args, &expectedArgs, cmp.AllowUnexported(tautoRunArgs{})); diff != "" {
 		t.Errorf("Got unexpected argument from newTautoArgs (-got +want):\n%s", diff)
 	}
@@ -37,7 +39,8 @@
 		patterns: []string{test1, test2},
 		runFlags: map[string]string{
 			tautoResultsDirFlag: workDir1,
-			autotestDir:         "/usr/local/autotest/",
+			autotestDirFlag:     "/usr/local/autotest/",
+			companionFlag:       "companion1,companion2",
 		},
 	}
 
diff --git a/src/chromiumos/test/execution/cmd/cros-test/testexecserver.go b/src/chromiumos/test/execution/cmd/cros-test/testexecserver.go
index 76a160b..d7aa750 100644
--- a/src/chromiumos/test/execution/cmd/cros-test/testexecserver.go
+++ b/src/chromiumos/test/execution/cmd/cros-test/testexecserver.go
@@ -70,7 +70,7 @@
 			return nil, statuserrors.NewStatusError(statuserrors.IOCreateError,
 				fmt.Errorf("failed to create result directory %v", resultsDir))
 		}
-		rspn, err := driver.RunTests(ctx, resultsDir, req.Primary, tlwAddr, tests)
+		rspn, err := driver.RunTests(ctx, resultsDir, req, tlwAddr, tests)
 		if err != nil {
 			return nil, err
 		}
diff --git a/src/chromiumos/test/execution/cmd/cros-test/testexecserver_test.go b/src/chromiumos/test/execution/cmd/cros-test/testexecserver_test.go
index 39720a6..41f449b 100644
--- a/src/chromiumos/test/execution/cmd/cros-test/testexecserver_test.go
+++ b/src/chromiumos/test/execution/cmd/cros-test/testexecserver_test.go
@@ -56,6 +56,18 @@
 				},
 			},
 		},
+		Companions: []*api.CrosTestRequest_Device{
+			{
+				Dut: &labapi.Dut{
+					Id: &labapi.Dut_Id{Value: "CompanionDut1"},
+					DutType: &labapi.Dut_Chromeos{
+						Chromeos: &labapi.Dut_ChromeOS{
+							Ssh: &labapi.IpEndpoint{Address: "127.0.0.1", Port: 2223},
+						},
+					},
+				},
+			},
+		},
 	}
 
 	m := jsonpb.Marshaler{}
diff --git a/src/chromiumos/test/execution/data/companions.json b/src/chromiumos/test/execution/data/companions.json
new file mode 100644
index 0000000..bf68648
--- /dev/null
+++ b/src/chromiumos/test/execution/data/companions.json
@@ -0,0 +1,38 @@
+{
+    "test_suites" : [
+        {
+            "name" : "suite1",
+            "test_case_ids" : {
+                "test_case_ids" : [
+                    {
+                        "value" : "tast.meta.CompanionDUTs"
+                    }
+                ]
+            }
+        }
+    ],
+    "primary":{
+            "dut":{
+                "id":{"value":"primary_dut"},
+                "chromeos":{
+                    "ssh":{
+                        "address":"127.0.0.1",
+                        "port":2222
+                   }
+                }
+            }
+        },
+    "companions":[
+        {
+            "dut": {
+                "id": {"value":"CompanionDut1"},
+                "chromeos": {
+                    "ssh":{
+                        "address":"127.0.0.1",
+                        "port":2223
+                   }
+                }
+            }
+        }
+    ]
+}