AutoVM filter updates

Do not remove VM scheduling unit if already present

BUG=None
TEST=UT

Change-Id: Iac71e34e59dfed55593a0e8a34805aab9c1ef148
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/dev-util/+/5860131
Tested-by: Varun Srivastav <varunsrivastav@google.com>
Auto-Submit: Varun Srivastav <varunsrivastav@google.com>
Reviewed-by: Derek Beckett <dbeckett@chromium.org>
Commit-Queue: Varun Srivastav <varunsrivastav@google.com>
diff --git a/src/go.chromium.org/chromiumos/test/ctpv2/autovm_test_shifter_filter/main.go b/src/go.chromium.org/chromiumos/test/ctpv2/autovm_test_shifter_filter/main.go
index 6abadd7..6d08ec2 100644
--- a/src/go.chromium.org/chromiumos/test/ctpv2/autovm_test_shifter_filter/main.go
+++ b/src/go.chromium.org/chromiumos/test/ctpv2/autovm_test_shifter_filter/main.go
@@ -4,13 +4,9 @@
 package main
 
 import (
-	"encoding/json"
-	"errors"
 	"fmt"
 	"log"
 	"os"
-	"os/exec"
-	"regexp"
 
 	server "go.chromium.org/chromiumos/test/ctpv2/common/server_template"
 
@@ -28,7 +24,7 @@
 	success         = "SUCCESS"
 )
 
-var vmBoards = []string{"betty", "betty-arc-t", "reven-vmtest", "amd64-generic", "betty-kernelnext", "betty-arc-r", "betty-arc-v"}
+var vmBoards = []string{"betty", "reven-vmtest", "amd64-generic"}
 
 // isTestVMCompatible returns true if the test can be safely executed on a VM.
 func isTestVMCompatible(testCase *api.CTPTestCase) bool {
@@ -55,8 +51,8 @@
 	return false
 }
 
-func generateVMSchedulingUnitOptions(board string, version string) []*api.SchedulingUnitOptions {
-	schedulingUnit := &api.SchedulingUnit{
+func generateVMSchedulingUnit(board string, version string) *api.SchedulingUnit {
+	return &api.SchedulingUnit{
 		PrimaryTarget: &api.Target{
 			SwarmingDef: &api.SwarmingDefinition{
 				DutInfo: &testapi.Dut{
@@ -72,16 +68,22 @@
 			"installPath": "gs://chromeos-image-archive/" + board + "-" + release + version,
 		},
 	}
-	var schedulingUnits []*api.SchedulingUnit
-	schedulingUnits = append(schedulingUnits, schedulingUnit)
-	schedulingUnitOption := &api.SchedulingUnitOptions{
-		SchedulingUnits: schedulingUnits,
+}
+
+// updateSchedulingUnitOption updates the list of Scheduling units. Returns nil if no VMlab Scheduling unit found.
+func updateSchedulingUnitOption(schedulingUnitOption *api.SchedulingUnitOptions, board string, version string) *api.SchedulingUnitOptions {
+	var filteredSchedulingUnits []*api.SchedulingUnit
+	for _, schedulingUnit := range schedulingUnitOption.GetSchedulingUnits() {
+		// if VM board in scheduling unit then do not remove. This will trigger VMlab flow
+		if checkBoardInVMBoardsList(getBoard(schedulingUnit)) {
+			filteredSchedulingUnits = append(filteredSchedulingUnits, schedulingUnit)
+		}
 	}
-
-	var schedulingUnitOptions []*api.SchedulingUnitOptions
-	schedulingUnitOptions = append(schedulingUnitOptions, schedulingUnitOption)
-
-	return schedulingUnitOptions
+	if len(filteredSchedulingUnits) == 0 {
+		return nil
+	}
+	schedulingUnitOption.SchedulingUnits = filteredSchedulingUnits
+	return schedulingUnitOption
 }
 
 // updateSchedulingUnitOptions updates the schedulingUnitOptions for each test case in internal test plan request.
@@ -89,8 +91,27 @@
 
 	for _, testCase := range req.GetTestCases() {
 		if isTestVMCompatible(testCase) {
-			updatedSchedulingUnitOptions := generateVMSchedulingUnitOptions(board, version)
-			testCase.SchedulingUnitOptions = updatedSchedulingUnitOptions
+			var updatedSchedulingUnitOptions []*api.SchedulingUnitOptions
+			for _, schedulingUnitOption := range testCase.GetSchedulingUnitOptions() {
+				updatedSchedulingUnitOption := updateSchedulingUnitOption(schedulingUnitOption, board, version)
+				if updatedSchedulingUnitOption == nil {
+					continue
+				}
+				updatedSchedulingUnitOptions = append(updatedSchedulingUnitOptions, updatedSchedulingUnitOption)
+			}
+			// if no schedulign unit found from intended scheduling units, then create a VM schedulign unit with available board/version
+			if len(updatedSchedulingUnitOptions) == 0 {
+				testCase.SchedulingUnitOptions = []*api.SchedulingUnitOptions{
+					{
+						SchedulingUnits: []*api.SchedulingUnit{
+							generateVMSchedulingUnit(board, version),
+						},
+					},
+				}
+			} else {
+				testCase.SchedulingUnitOptions = updatedSchedulingUnitOptions
+			}
+
 		} else {
 			log.Printf("Skippping as test : %s, isn't VM compatible", testCase.GetName())
 		}
@@ -99,125 +120,6 @@
 	return nil
 }
 
-func getBuildReportJSON(gcsPath string) ([]byte, error) {
-	// Execute the gsutil ls command
-	log.Printf("Checking image %s status...", gcsPath)
-	cmd := exec.Command("gsutil", "cat", gcsPath+"/"+buildReportJSON)
-	log.Printf("Executing command: %s", cmd.String())
-	output, err := cmd.CombinedOutput()
-	if err != nil {
-		return nil, fmt.Errorf("error executing gsutil command: %v, output: %s", err, string(output))
-	}
-	// Check if the stdout is empty. No build report found
-	if len(output) == 0 {
-		return nil, fmt.Errorf("No build report found")
-	}
-	return output, nil
-}
-
-// isVMImageAvailable reads build report in gs bucket and infers if the builder has successfully completed.
-func isVMImageAvailable(gcsPath string) (bool, error) {
-	buildReportJSON, err := getBuildReportJSON(gcsPath)
-	if err != nil {
-		log.Printf("Error while getting build report: %s", err)
-	}
-	// Unmarshal the JSON string into the map
-	var result map[string]interface{}
-	err = json.Unmarshal(buildReportJSON, &result)
-	if err != nil {
-		return false, fmt.Errorf("error parsing JSON: %v", err)
-	}
-
-	// safely parse the nested structure
-	status, ok := result["status"].(map[string]interface{})
-	if !ok {
-		return false, fmt.Errorf("status key not found or is not in expected format")
-	}
-
-	value, ok := status["value"].(string)
-	if !ok {
-		return false, fmt.Errorf("value key not found or is not in expected format")
-	}
-
-	// check the status value from build report
-	fmt.Printf("Status is %s\n", value)
-	if value == success {
-		log.Printf("Image %s is available", gcsPath)
-		return true, nil
-	}
-	log.Printf("Image %s is not available", gcsPath)
-	return false, nil
-}
-
-// getImageVersion returns the image version from a given image gs url(installPath) ex-`R128-15964.4.0`.
-func getImageVersionFromInstallPath(installPath string) (string, error) {
-	if installPath == "" {
-		return "", errors.New("Empty Installpath")
-	}
-	// Define a regex pattern to match "R" followed by digits and a hyphen
-	re := regexp.MustCompile(`/R(.+)$`)
-
-	match := re.FindStringSubmatch(installPath)
-	if len(match) < 2 {
-		return "", errors.New("milestone version not found in installPath")
-	}
-
-	milestone := match[1]
-	return "R" + milestone, nil
-}
-
-// getTargetedImageVersion fetches the chromeos image version of boards on which tests are to be executed.
-// Note: Is same for all boards for a single CTP request.
-func getTargetedImageVersion(req *api.InternalTestplan, log *log.Logger) string {
-	// find the image version from the first valid gcs installPath
-	for _, tc := range req.TestCases {
-		for _, units := range tc.GetSchedulingUnitOptions() {
-			for _, unit := range units.GetSchedulingUnits() {
-				gcsInstallPath := unit.GetDynamicUpdateLookupTable()[installPath]
-				imageVersion, err := getImageVersionFromInstallPath(gcsInstallPath)
-				if err != nil {
-					// log the error and try for other install paths.
-					log.Printf("Error while getting image version for gcsInstallPath %s: %s", gcsInstallPath, err)
-					continue
-				}
-				log.Printf("Found image version for the request: %s", imageVersion)
-				return imageVersion
-			}
-		}
-	}
-	return ""
-}
-
-// isAnyVMImageAvailable returns true if any corresponding VM image for same version is available. Availability
-// means that the builder has successfully completed building image and should be available to lease a VM with it.
-func isAnyVMImageAvailable(req *api.InternalTestplan, log *log.Logger) (bool, string, string) {
-	// fetch image version of intended boards. Same version of VM boards will be used, if available.
-	targetedVersion := getTargetedImageVersion(req, log)
-	if targetedVersion == "" {
-		log.Printf("Failed to find image version from the request. Skipping AutoVM test shifter filter execution.")
-		return false, "", ""
-	}
-
-	// check for all valid VM boards for image availability. Stop as soon as any is found available.
-	log.Printf("Checking availability for VM board images ...")
-	for _, board := range vmBoards {
-		// form the gcsImagePath for VM board to check image availability.
-		gcsImagePath := gsBucketPath + board + "-" + release + targetedVersion
-		log.Printf("GCS image path for board %s: %s", board, gcsImagePath)
-
-		exists, err := isVMImageAvailable(gcsImagePath)
-		if err != nil {
-			log.Printf("Error while checking image availability: %s", err)
-			continue
-		}
-		if exists {
-			return exists, board, targetedVersion
-		}
-	}
-	log.Printf("No images found available.")
-	return false, "", ""
-}
-
 func executor(req *api.InternalTestplan, log *log.Logger) (*api.InternalTestplan, error) {
 
 	available, board, version := isAnyVMImageAvailable(req, log)
diff --git a/src/go.chromium.org/chromiumos/test/ctpv2/autovm_test_shifter_filter/main_test.go b/src/go.chromium.org/chromiumos/test/ctpv2/autovm_test_shifter_filter/main_test.go
index 9e67ea5..d76ce85 100644
--- a/src/go.chromium.org/chromiumos/test/ctpv2/autovm_test_shifter_filter/main_test.go
+++ b/src/go.chromium.org/chromiumos/test/ctpv2/autovm_test_shifter_filter/main_test.go
@@ -70,6 +70,31 @@
 			},
 		},
 	}
+	SchedulingUnitDedede := []*api.SchedulingUnit{
+		{
+			PrimaryTarget: &api.Target{
+				SwarmingDef: &api.SwarmingDefinition{
+					DutInfo: &testapi.Dut{
+						DutType: &testapi.Dut_Chromeos{
+							Chromeos: &testapi.Dut_ChromeOS{
+								DutModel: &testapi.DutModel{
+									BuildTarget: "dedede",
+								},
+							},
+						},
+					},
+					SwarmingLabels: []string{
+						"label-peripheral_wifi_state:WORKING",
+						"label-wificell:True",
+					},
+				},
+			},
+			DynamicUpdateLookupTable: map[string]string{
+				"board":       "betty",
+				"installPath": "gs://chromeos-image-archive/dedede-release/R128-15964.4.0",
+			},
+		},
+	}
 
 	var SchedulingUnitOptions []*api.SchedulingUnitOptions
 	for _, board := range boards {
@@ -78,7 +103,10 @@
 			SchedulingUnitOptions = append(SchedulingUnitOptions, &api.SchedulingUnitOptions{SchedulingUnits: SchedulingUnitSkyrim})
 		case "betty":
 			SchedulingUnitOptions = append(SchedulingUnitOptions, &api.SchedulingUnitOptions{SchedulingUnits: SchedulingUnitBetty})
+		case "dedede":
+			SchedulingUnitOptions = append(SchedulingUnitOptions, &api.SchedulingUnitOptions{SchedulingUnits: SchedulingUnitDedede})
 		}
+
 	}
 
 	return SchedulingUnitOptions
@@ -255,7 +283,7 @@
 			t.Errorf("Error expected to be nil, got %s", err)
 		}
 		if version != expectedVersion {
-			t.Errorf("Expected R128-15964.4.0, got %s", version)
+			t.Errorf("Expected %s, got %s", expectedVersion, version)
 		}
 	}
 }
@@ -344,7 +372,7 @@
 
 }
 
-func Test_UpdateSchedulingUnitOptions_VMCompatibleTest(t *testing.T) {
+func Test_UpdateSchedulingUnitOptions_VMCompatibleTest_WithVMSchedulingUnit(t *testing.T) {
 	schedulingUnitOptions := GenerateSchedulingUnitOptions([]string{"skyrim", "betty"})
 	testCase := GenerateCTPTestCase("test1", true, schedulingUnitOptions)
 	internalTestPlan := &api.InternalTestplan{
@@ -355,13 +383,41 @@
 	if err != nil {
 		t.Errorf("Expected nil, got %s", err)
 	}
+
+	if len(internalTestPlan.TestCases[0].GetSchedulingUnitOptions()) != 1 {
+		t.Errorf("Expected 2, got %d", len(internalTestPlan.TestCases[0].SchedulingUnitOptions))
+	}
+	expectedBoard := "betty"
+	updatedBoard := internalTestPlan.TestCases[0].GetSchedulingUnitOptions()[0].GetSchedulingUnits()[0].GetDynamicUpdateLookupTable()["board"]
+	if expectedBoard != updatedBoard {
+		t.Errorf("Expected %s, got %s", expectedBoard, updatedBoard)
+	}
+	if len(internalTestPlan.TestCases[0].GetSchedulingUnitOptions()[0].GetSchedulingUnits()) != 1 {
+		t.Errorf("Expected 1, got %d", len(internalTestPlan.TestCases[0].SchedulingUnitOptions))
+	}
+}
+
+func Test_UpdateSchedulingUnitOptions_VMCompatibleTest_WithNoVMSchedulingUnit(t *testing.T) {
+	schedulingUnitOptions := GenerateSchedulingUnitOptions([]string{"skyrim", "dedede"})
+	testCase := GenerateCTPTestCase("test1", true, schedulingUnitOptions)
+	internalTestPlan := &api.InternalTestplan{
+		TestCases: []*api.CTPTestCase{testCase},
+	}
+
+	err := updateSchedulingUnitOptions(internalTestPlan, "reven-vmtest", "R128-15964.4.0", log.New(os.Stdout, "DEBUG", log.LstdFlags))
+	if err != nil {
+		t.Errorf("Expected nil, got %s", err)
+	}
+
 	if len(internalTestPlan.TestCases[0].GetSchedulingUnitOptions()) != 1 {
 		t.Errorf("Expected 2, got %d", len(internalTestPlan.TestCases[0].SchedulingUnitOptions))
 	}
 	expectedBoard := "reven-vmtest"
 	updatedBoard := internalTestPlan.TestCases[0].GetSchedulingUnitOptions()[0].GetSchedulingUnits()[0].GetDynamicUpdateLookupTable()["board"]
 	if expectedBoard != updatedBoard {
-		t.Errorf("Expected reven-vmtest, got %d", len(internalTestPlan.TestCases[0].SchedulingUnitOptions))
+		t.Errorf("Expected %s, got %s", expectedBoard, updatedBoard)
 	}
-
+	if len(internalTestPlan.TestCases[0].GetSchedulingUnitOptions()[0].GetSchedulingUnits()) != 1 {
+		t.Errorf("Expected 1, got %d", len(internalTestPlan.TestCases[0].SchedulingUnitOptions))
+	}
 }
diff --git a/src/go.chromium.org/chromiumos/test/ctpv2/autovm_test_shifter_filter/utils.go b/src/go.chromium.org/chromiumos/test/ctpv2/autovm_test_shifter_filter/utils.go
new file mode 100644
index 0000000..cc0e415
--- /dev/null
+++ b/src/go.chromium.org/chromiumos/test/ctpv2/autovm_test_shifter_filter/utils.go
@@ -0,0 +1,170 @@
+// Copyright 2024 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package main
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log"
+	"os/exec"
+	"regexp"
+
+	"go.chromium.org/chromiumos/config/go/test/api"
+	labapi "go.chromium.org/chromiumos/config/go/test/lab/api"
+)
+
+// dutModelFromDut returns the dut model for a DUT.
+func dutModelFromDut(dut *labapi.Dut) *labapi.DutModel {
+	if dut == nil {
+		return nil
+	}
+
+	switch hw := dut.GetDutType().(type) {
+	case *labapi.Dut_Chromeos:
+		return hw.Chromeos.GetDutModel()
+	case *labapi.Dut_Android_:
+		return hw.Android.GetDutModel()
+	case *labapi.Dut_Devboard_:
+		return hw.Devboard.GetDutModel()
+	}
+	return nil
+}
+
+// getBoard returns board value for a scheduling unit.
+func getBoard(unit *api.SchedulingUnit) string {
+	return dutModelFromDut(unit.GetPrimaryTarget().GetSwarmingDef().GetDutInfo()).GetBuildTarget()
+}
+
+func checkBoardInVMBoardsList(board string) bool {
+	for _, vmBoard := range vmBoards {
+		if board == vmBoard {
+			return true
+		}
+	}
+	return false
+}
+
+func getBuildReportJSON(gcsPath string) ([]byte, error) {
+	// Execute the gsutil ls command
+	log.Printf("Checking image %s status...", gcsPath)
+	cmd := exec.Command("gsutil", "cat", gcsPath+"/"+buildReportJSON)
+	log.Printf("Executing command: %s", cmd.String())
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return nil, fmt.Errorf("error executing gsutil command: %v, output: %s", err, string(output))
+	}
+	// Check if the stdout is empty. No build report found
+	if len(output) == 0 {
+		return nil, fmt.Errorf("No build report found")
+	}
+	return output, nil
+}
+
+// isVMImageAvailable reads build report in gs bucket and infers if the builder has successfully completed.
+func isVMImageAvailable(gcsPath string) (bool, error) {
+	buildReportJSON, err := getBuildReportJSON(gcsPath)
+	if err != nil {
+		log.Printf("Error while getting build report: %s", err)
+	}
+	// Unmarshal the JSON string into the map
+	var result map[string]interface{}
+	err = json.Unmarshal(buildReportJSON, &result)
+	if err != nil {
+		return false, fmt.Errorf("error parsing JSON: %v", err)
+	}
+
+	// safely parse the nested structure
+	status, ok := result["status"].(map[string]interface{})
+	if !ok {
+		return false, fmt.Errorf("status key not found or is not in expected format")
+	}
+
+	value, ok := status["value"].(string)
+	if !ok {
+		return false, fmt.Errorf("value key not found or is not in expected format")
+	}
+
+	// check the status value from build report
+	log.Printf("Status is %s\n", value)
+	if value == success {
+		log.Printf("Image %s is available", gcsPath)
+		return true, nil
+	}
+	log.Printf("Image %s is not available", gcsPath)
+	return false, nil
+}
+
+// getImageVersion returns the image version from a given image gs url(installPath) ex-`R128-15964.4.0`.
+func getImageVersionFromInstallPath(installPath string) (string, error) {
+	if installPath == "" {
+		return "", errors.New("Empty Installpath")
+	}
+	// Capturing group regex. match[0] contains everything including /R, match[1] contains everything captured as part of (.+)
+	re := regexp.MustCompile(`/R(.+)$`)
+
+	match := re.FindStringSubmatch(installPath)
+	if len(match) < 2 {
+		return "", errors.New("milestone version not found in installPath")
+	}
+
+	milestone := match[1]
+	return "R" + milestone, nil
+}
+
+// getTargetedImageVersion fetches the chromeos image version of boards on which tests are to be executed.
+// Note: Is same for all boards for a single CTP request.
+func getTargetedImageVersion(req *api.InternalTestplan, log *log.Logger) string {
+	// find the image version from the first valid gcs installPath
+	for _, tc := range req.TestCases {
+		for _, units := range tc.GetSchedulingUnitOptions() {
+			for _, unit := range units.GetSchedulingUnits() {
+				if unit.GetDynamicUpdateLookupTable() != nil {
+					gcsInstallPath, exists := unit.GetDynamicUpdateLookupTable()[installPath]
+					if exists {
+						imageVersion, err := getImageVersionFromInstallPath(gcsInstallPath)
+						if err != nil {
+							log.Printf("Error while getting image version for gcsInstallPath %s: %s", gcsInstallPath, err)
+							continue
+						}
+						log.Printf("Found image version for the request: %s", imageVersion)
+						return imageVersion
+					}
+				}
+			}
+		}
+	}
+	return ""
+}
+
+// isAnyVMImageAvailable returns true if any corresponding VM image for same version is available. Availability
+// means that the builder has successfully completed building image and should be available to lease a VM with it.
+func isAnyVMImageAvailable(req *api.InternalTestplan, log *log.Logger) (bool, string, string) {
+	// fetch image version of intended boards. Same version of VM boards will be used, if available.
+	targetedVersion := getTargetedImageVersion(req, log)
+	if targetedVersion == "" {
+		log.Printf("Failed to find image version from the request. Skipping AutoVM test shifter filter execution.")
+		return false, "", ""
+	}
+
+	// check for all valid VM boards for image availability. Stop as soon as any is found available.
+	log.Printf("Checking availability for VM board images ...")
+	for _, board := range vmBoards {
+		// form the gcsImagePath for VM board to check image availability.
+		gcsImagePath := gsBucketPath + board + "-" + release + targetedVersion
+		log.Printf("GCS image path for board %s: %s", board, gcsImagePath)
+
+		exists, err := isVMImageAvailable(gcsImagePath)
+		if err != nil {
+			log.Printf("Error while checking image availability: %s", err)
+			continue
+		}
+		if exists {
+			return exists, board, targetedVersion
+		}
+	}
+	log.Printf("No images found available.")
+	return false, "", ""
+}