[machine] Add Windows GPU interrogation to TMM.

Add smoke-testing for GPUs(). Found a simple, non-repetitive way to do
it while tolerating failures on Linux.

Change-Id: I3554ae6a267e0f45ff877d88eda580e23602a1e7
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/577160
Reviewed-by: Eric Boren <borenet@google.com>
Commit-Queue: Erik Rose <erikrose@google.com>
diff --git a/machine/go/test_machine_monitor/standalone/BUILD.bazel b/machine/go/test_machine_monitor/standalone/BUILD.bazel
index 04943b0..8c5bbba 100644
--- a/machine/go/test_machine_monitor/standalone/BUILD.bazel
+++ b/machine/go/test_machine_monitor/standalone/BUILD.bazel
@@ -46,6 +46,7 @@
             "//machine/go/test_machine_monitor/standalone/crossplatform",
             "//machine/go/test_machine_monitor/standalone/windows",
             "@com_github_shirou_gopsutil//host",
+            "@com_github_yusufpapurcu_wmi//:wmi",
         ],
         "//conditions:default": [],
     }),
@@ -55,5 +56,8 @@
     name = "standalone_test",
     srcs = ["standalone_test.go"],
     embed = [":standalone"],
-    deps = ["@com_github_stretchr_testify//assert"],
+    deps = [
+        "@com_github_stretchr_testify//assert",
+        "@com_github_stretchr_testify//require",
+    ],
 )
diff --git a/machine/go/test_machine_monitor/standalone/gputable/gputable.go b/machine/go/test_machine_monitor/standalone/gputable/gputable.go
index 5e00b85..91cac75 100644
--- a/machine/go/test_machine_monitor/standalone/gputable/gputable.go
+++ b/machine/go/test_machine_monitor/standalone/gputable/gputable.go
@@ -15,8 +15,11 @@
 	Devices map[string]string
 }
 
-const Nvidia = "10de"
-const Intel = "8086"
+const (
+	Nvidia = "10de"
+	Intel  = "8086"
+	VMWare = "15ad"
+)
 
 // Static lookup tables:
 var vendorMap = map[VendorID]vendorNameAndDevices{
diff --git a/machine/go/test_machine_monitor/standalone/mac/mac.go b/machine/go/test_machine_monitor/standalone/mac/mac.go
index 4ec835c..6a1947f 100644
--- a/machine/go/test_machine_monitor/standalone/mac/mac.go
+++ b/machine/go/test_machine_monitor/standalone/mac/mac.go
@@ -43,9 +43,8 @@
 	return profilerOutput[0].GPUs, nil
 }
 
-// DimensionsFromGPUs turns a slice of Mac GPUs into Swarming-style dimensions, e.g. ["Intel
-// (8086)", "Intel Coffee Lake H UHD Graphics 630 (8086:3e9b)"]. If there are no GPUs, return
-// ["none"].
+// DimensionsFromGPUs turns a slice of Mac GPUs into Swarming-style dimensions, e.g. ["8086",
+// "8086:3e9b"]. If there are no GPUs, return ["none"].
 func DimensionsFromGPUs(gpus []GPU) []string {
 	var dimensions []string
 	for _, gpu := range gpus {
@@ -92,8 +91,8 @@
 		}
 		if vendorID == "" {
 			vendorID = gputable.VendorID("UNKNOWN")
-		} else if vendorID == "15ad" {
-			// This is VMWare, which we consider as not having any GPUs.
+		} else if vendorID == gputable.VMWare {
+			// We consider VMWare as not having any GPUs.
 			return []string{"none"}
 		}
 
diff --git a/machine/go/test_machine_monitor/standalone/standalone_darwin.go b/machine/go/test_machine_monitor/standalone/standalone_darwin.go
index 9bf9c04..52e2774 100644
--- a/machine/go/test_machine_monitor/standalone/standalone_darwin.go
+++ b/machine/go/test_machine_monitor/standalone/standalone_darwin.go
@@ -30,14 +30,14 @@
 }
 
 // GPUs returns Swarming-style descriptions of all the host's GPUs, in various precisions, all
-// flattened into a single array, e.g. ["Intel (8086)", "Intel Broadwell HD Graphics 6000
-// (8086:1626)", "Intel (8086)", "8086:9a49", "8086:9a49-22.0.5"]. At most, an array element may
-// have 4 elements of precision: vendor ID, vendor name, device ID, and device name (in that order).
-// However, the formats of these are device- and OS-dependent.
+// flattened into a single array, e.g. ["8086", "8086:1626", "8086", "8086:9a49",
+// "8086:9a49-22.0.5"]. At most, an array element may have 4 elements of precision: vendor ID,
+// vendor name, device ID, and device name (in that order). However, the formats of these are
+// device- and OS-dependent.
 func GPUs(ctx context.Context) ([]string, error) {
 	xml, err := common.TrimmedCommandOutput(ctx, "system_profiler", "SPDisplaysDataType", "-xml")
 	if err != nil {
-		return nil, skerr.Wrapf(err, "failed to run System Profiler to get GPU info. Output was '%s'", xml)
+		return nil, skerr.Wrapf(err, "failed to run System Profiler to get GPU info. Output was %q", xml)
 	}
 
 	gpus, err := mac.GPUsFromSystemProfilerXML(xml)
diff --git a/machine/go/test_machine_monitor/standalone/standalone_test.go b/machine/go/test_machine_monitor/standalone/standalone_test.go
index d743670..551e4cb 100644
--- a/machine/go/test_machine_monitor/standalone/standalone_test.go
+++ b/machine/go/test_machine_monitor/standalone/standalone_test.go
@@ -6,6 +6,7 @@
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 // Smoke-test CPUs(). The interesting (and hopefully thus the error-prone) parts of it have been
@@ -13,7 +14,7 @@
 // straight line through, determined by the platform the tests are running on.
 func TestCPUs_Smoke(t *testing.T) {
 	cpus, err := CPUs(context.Background())
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	if len(cpus) != 2 && len(cpus) != 3 {
 		assert.Fail(t, "Length of CPUs() output should have at least an ISA and a bit-width element.")
 	}
@@ -24,6 +25,20 @@
 
 func TestOSVersions_Smoke(t *testing.T) {
 	versions, err := OSVersions(context.Background())
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	assert.GreaterOrEqual(t, len(versions), 2, "OSVersions() should return at least PlatformName and PlatformName-SomeVersion.")
 }
+
+func TestGPUs_Smoke(t *testing.T) {
+	gpus, err := GPUs(context.Background())
+	if err != nil {
+		if strings.Contains(err.Error(), "failed to run lspci") {
+			// This assertion is allowed to fail on Linux CI machines, which may not have lspci
+			// installed.
+			return
+		} else {
+			require.NoError(t, err)
+		}
+	}
+	assert.GreaterOrEqual(t, len(gpus), 1, "GPUs() should return at least 1 dimension ({\"none\"} at worst).")
+}
diff --git a/machine/go/test_machine_monitor/standalone/standalone_windows.go b/machine/go/test_machine_monitor/standalone/standalone_windows.go
index c324040..0a045f5 100644
--- a/machine/go/test_machine_monitor/standalone/standalone_windows.go
+++ b/machine/go/test_machine_monitor/standalone/standalone_windows.go
@@ -4,6 +4,7 @@
 	"context"
 
 	"github.com/shirou/gopsutil/host"
+	"github.com/yusufpapurcu/wmi"
 	"go.skia.org/infra/go/skerr"
 	"go.skia.org/infra/machine/go/test_machine_monitor/standalone/crossplatform"
 	"go.skia.org/infra/machine/go/test_machine_monitor/standalone/windows"
@@ -25,7 +26,14 @@
 	return crossplatform.CPUs("", "")
 }
 
+// GPUs returns Swarming-style dimensions representing all GPUs on the host. Each GPU may yield up
+// to 3 returned elements: "vendorID", "vendorID:deviceID", and "vendorID:deviceID-driverVersion".
+// If no GPUs are found or if the host is running within VMWare, returns ["none"].
 func GPUs(ctx context.Context) ([]string, error) {
-	var ret []string
-	return ret, nil
+	var results []windows.GPUQueryResult
+	err := wmi.Query("SELECT DriverVersion, PNPDeviceID FROM Win32_VideoController", &results)
+	if err != nil {
+		return nil, skerr.Wrapf(err, "failed to run WMI query to get GPU info")
+	}
+	return windows.GPUs(results), nil
 }
diff --git a/machine/go/test_machine_monitor/standalone/windows/BUILD.bazel b/machine/go/test_machine_monitor/standalone/windows/BUILD.bazel
index f64a6da..bfd8cae 100644
--- a/machine/go/test_machine_monitor/standalone/windows/BUILD.bazel
+++ b/machine/go/test_machine_monitor/standalone/windows/BUILD.bazel
@@ -6,7 +6,10 @@
     srcs = ["windows.go"],
     importpath = "go.skia.org/infra/machine/go/test_machine_monitor/standalone/windows",
     visibility = ["//visibility:public"],
-    deps = ["//go/skerr"],
+    deps = [
+        "//go/skerr",
+        "//machine/go/test_machine_monitor/standalone/gputable",
+    ],
 )
 
 go_test(
@@ -14,6 +17,7 @@
     srcs = ["windows_test.go"],
     embed = [":windows"],
     deps = [
+        "//machine/go/test_machine_monitor/standalone/gputable",
         "@com_github_stretchr_testify//assert",
         "@com_github_stretchr_testify//require",
     ],
diff --git a/machine/go/test_machine_monitor/standalone/windows/windows.go b/machine/go/test_machine_monitor/standalone/windows/windows.go
index 3e1c6ba..580d55b 100644
--- a/machine/go/test_machine_monitor/standalone/windows/windows.go
+++ b/machine/go/test_machine_monitor/standalone/windows/windows.go
@@ -4,8 +4,10 @@
 
 import (
 	"regexp"
+	"strings"
 
 	"go.skia.org/infra/go/skerr"
+	"go.skia.org/infra/machine/go/test_machine_monitor/standalone/gputable"
 )
 
 var platformRegex = regexp.MustCompile(`Microsoft Windows ([^ ]+)`)
@@ -33,3 +35,40 @@
 
 	return []string{"Windows", "Windows-" + major, "Windows-" + major + "-" + build}, nil
 }
+
+type GPUQueryResult struct {
+	DriverVersion string
+	PNPDeviceID   string
+}
+
+var gpuVendorRegex = regexp.MustCompile(`VEN_([0-9A-F]{4})`)
+var gpuDeviceRegex = regexp.MustCompile(`DEV_([0-9A-F]{4})`)
+
+func GPUs(results []GPUQueryResult) []string {
+	// Extract the first group of the regex from a raw device ID, returning "UNKNOWN" on failure.
+	extract := func(regex *regexp.Regexp, rawDeviceID string) string {
+		groups := regex.FindStringSubmatch(rawDeviceID)
+		if groups != nil {
+			return strings.ToLower(groups[1])
+		}
+		return "UNKNOWN"
+	}
+
+	var dimensions []string
+	for _, result := range results {
+		vendorID := extract(gpuVendorRegex, result.PNPDeviceID)
+		deviceID := extract(gpuDeviceRegex, result.PNPDeviceID)
+
+		if vendorID == gputable.VMWare {
+			return []string{"none"}
+		}
+		dimensions = append(dimensions, vendorID, vendorID+":"+deviceID)
+		if result.DriverVersion != "" {
+			dimensions = append(dimensions, vendorID+":"+deviceID+"-"+result.DriverVersion)
+		}
+	}
+	if len(dimensions) == 0 {
+		return []string{"none"}
+	}
+	return dimensions
+}
diff --git a/machine/go/test_machine_monitor/standalone/windows/windows_test.go b/machine/go/test_machine_monitor/standalone/windows/windows_test.go
index 33b7d32..28c9945 100644
--- a/machine/go/test_machine_monitor/standalone/windows/windows_test.go
+++ b/machine/go/test_machine_monitor/standalone/windows/windows_test.go
@@ -1,10 +1,12 @@
 package windows
 
 import (
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"go.skia.org/infra/machine/go/test_machine_monitor/standalone/gputable"
 )
 
 func TestOSVersions_HappyPath(t *testing.T) {
@@ -21,3 +23,58 @@
 	_, err := OSVersions("Schlockosoft Grindows", "10.0.17763 Build 17763")
 	require.Error(t, err)
 }
+
+func TestGPUsFindsVendorButNotDeviceID_ReturnsUnknown(t *testing.T) {
+	assert.Equal(
+		t,
+		GPUs(
+			[]GPUQueryResult{
+				{
+					DriverVersion: "",
+					PNPDeviceID:   `PCI\VEN_80A1&SUBSYS_20898086&REV_00\3&11583659&1&10`,
+				},
+			},
+		),
+		[]string{"80a1", "80a1:UNKNOWN"}, // Note lowercase "a" as well.
+	)
+}
+
+func TestGPUsFindsDeviceIDAndVendorAndVersion(t *testing.T) {
+	assert.Equal(
+		t,
+		GPUs(
+			[]GPUQueryResult{
+				{
+					DriverVersion: "1.2.3",
+					PNPDeviceID:   `PCI\VEN_8086&DEV_3E9B&SUBSYS_20898086&REV_00\3&11583659&1&10`,
+				},
+			},
+		),
+		[]string{"8086", "8086:3e9b", "8086:3e9b-1.2.3"},
+	)
+}
+
+func TestGPUsFindsNoGPUs(t *testing.T) {
+	assert.Equal(
+		t,
+		GPUs(
+			[]GPUQueryResult{},
+		),
+		[]string{"none"},
+	)
+}
+
+func TestGPUsSeesVMWare_ReturnsNone(t *testing.T) {
+	assert.Equal(
+		t,
+		GPUs(
+			[]GPUQueryResult{
+				{
+					DriverVersion: "1.2.3",
+					PNPDeviceID:   `PCI\VEN_` + strings.ToUpper(gputable.VMWare) + `&DEV_3E9B&SUBSYS_20898086&REV_00\3&11583659&1&10`,
+				},
+			},
+		),
+		[]string{"none"},
+	)
+}