Local multi-dut config support

Allows local dut topology configs to be managed in protojson form and
then passed into inventoryserver, which allows complex dut setups
(beyond basic ssh config) like multi-dut, peripherals, etc...

BUG=b:188712103
TEST=unit

Change-Id: I207477257b7ec788552c6bf9b053f275c7210214
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/dev-util/+/3042331
Tested-by: C Shapiro <shapiroc@chromium.org>
Auto-Submit: C Shapiro <shapiroc@chromium.org>
Reviewed-by: Jaques Clapauch <jaquesc@google.com>
Commit-Queue: Jaques Clapauch <jaquesc@google.com>
diff --git a/src/chromiumos/test/lab/local/cmd/inventoryserver/inventoryserver.go b/src/chromiumos/test/lab/local/cmd/inventoryserver/inventoryserver.go
index db1721b..121e5ed 100644
--- a/src/chromiumos/test/lab/local/cmd/inventoryserver/inventoryserver.go
+++ b/src/chromiumos/test/lab/local/cmd/inventoryserver/inventoryserver.go
@@ -6,9 +6,14 @@
 package main
 
 import (
+	"bytes"
+	"errors"
+	"io/ioutil"
 	"log"
 	"net"
 
+	"github.com/golang/protobuf/jsonpb"
+	"github.com/golang/protobuf/proto"
 	"go.chromium.org/chromiumos/config/go/test/lab/api"
 	"google.golang.org/grpc"
 )
@@ -23,12 +28,36 @@
 type Options struct {
 	DutAddress string
 	DutPort    int
+	// File path to a serialized jsonproto payload of DutTopology.
+	// This allows local complex lab setups (e.g. multi-dut) for local testing.
+	DutTopologyConfigPath string
+}
+
+// readJsonpb reads the jsonpb at path into m.
+func readJsonpb(path string, m proto.Message) error {
+	b, err := ioutil.ReadFile(path)
+	if err != nil {
+		return err
+	}
+	return jsonpb.Unmarshal(bytes.NewReader(b), m)
 }
 
 // newInventoryServer creates a new inventory service server to listen to rpc requests.
 func newInventoryServer(l net.Listener, logger *log.Logger, options *Options) (*grpc.Server, error) {
 	dutTopology := &api.DutTopology{}
-	if len(options.DutAddress) != 0 {
+
+	dutAddress := len(options.DutAddress) != 0
+	dutTopoConfig := len(options.DutTopologyConfigPath) != 0
+
+	if dutAddress && dutTopoConfig {
+		return nil, errors.New("DutAddress and DutTopologyConfigOptions options are mutally exclusive")
+	}
+
+	if dutTopoConfig {
+		if err := readJsonpb(options.DutTopologyConfigPath, dutTopology); err != nil {
+			return nil, err
+		}
+	} else if dutAddress {
 		dutTopology = &api.DutTopology{
 			Id: &api.DutTopology_Id{
 				Value: options.DutAddress,
diff --git a/src/chromiumos/test/lab/local/cmd/inventoryserver/inventoryserver_test.go b/src/chromiumos/test/lab/local/cmd/inventoryserver/inventoryserver_test.go
index a482763..b2c8150 100644
--- a/src/chromiumos/test/lab/local/cmd/inventoryserver/inventoryserver_test.go
+++ b/src/chromiumos/test/lab/local/cmd/inventoryserver/inventoryserver_test.go
@@ -7,10 +7,14 @@
 import (
 	"bytes"
 	"context"
+	"io/ioutil"
 	"log"
 	"net"
+	"os"
+	"strings"
 	"testing"
 
+	"github.com/golang/protobuf/jsonpb"
 	"go.chromium.org/chromiumos/config/go/test/lab/api"
 	"google.golang.org/grpc"
 )
@@ -73,3 +77,58 @@
 		t.Fatalf("Expected address: %s and port: %d; Got: %s", dutAddress, dutPort, ssh.String())
 	}
 }
+
+// InventoryServer handles DutTopology config from file
+func TestInventoryServer_DutTopologyConfigOption(t *testing.T) {
+	dutAddress := "fake-hostname"
+	tmpFile, _ := ioutil.TempFile(os.TempDir(), "fakeduttopoconfig-")
+	marshal := &jsonpb.Marshaler{EmitDefaults: true, Indent: "  "}
+	jsonOutput, _ := marshal.MarshalToString(&api.DutTopology{
+		Dut: &api.Dut{
+			DutType: &api.Dut_Chromeos{
+				Chromeos: &api.Dut_ChromeOS{
+					Ssh: &api.IpEndpoint{
+						Address: dutAddress,
+					},
+				},
+			},
+		},
+	})
+
+	dutTopoConfig := []byte(jsonOutput)
+	tmpFile.Write(dutTopoConfig)
+	tmpFile.Close()
+
+	defer os.Remove(tmpFile.Name())
+
+	dutTopology := getTopology(t, &Options{
+		DutTopologyConfigPath: tmpFile.Name(),
+	})
+
+	ssh := dutTopology.Dut.GetChromeos().GetSsh()
+
+	if ssh.Address != dutAddress {
+		t.Fatalf("Failed to load config from file %s", tmpFile.Name())
+	}
+}
+
+// InventoryServer errors on conflicting config/address options
+func TestInventoryServer_DutConfig_DutAddress_Exclusive(t *testing.T) {
+	var logBuf bytes.Buffer
+	l, err := net.Listen("tcp", ":0")
+	if err != nil {
+		t.Fatal("Failed to create a net listener: ", err)
+	}
+
+	svr, err := newInventoryServer(
+		l,
+		log.New(&logBuf, "", log.LstdFlags|log.LUTC),
+		&Options{
+			DutAddress:            "fake",
+			DutTopologyConfigPath: "somepath",
+		},
+	)
+	if svr != nil || err == nil || !strings.Contains(err.Error(), "exclusive") {
+		t.Fatalf("Expected error for invalid server options")
+	}
+}
diff --git a/src/chromiumos/test/lab/local/cmd/inventoryserver/main.go b/src/chromiumos/test/lab/local/cmd/inventoryserver/main.go
index a346fde..b8721de 100644
--- a/src/chromiumos/test/lab/local/cmd/inventoryserver/main.go
+++ b/src/chromiumos/test/lab/local/cmd/inventoryserver/main.go
@@ -50,6 +50,7 @@
 		version := flag.Bool("version", false, "print version and exit")
 		dutAddress := flag.String("dut_address", "", "DUT address to connect to (see ip_endpoint.proto for format requirements)")
 		dutPort := flag.Int("dut_port", defaultSshPort, fmt.Sprintf("SSH port for the target DUT (default: %d)", defaultSshPort))
+		dutTopologyConfigPath := flag.String("dut_config", "", "Path to jsonproto serialized DutTopology config")
 		flag.Parse()
 
 		if *version {
@@ -74,8 +75,9 @@
 			l,
 			logger,
 			&Options{
-				DutAddress: *dutAddress,
-				DutPort:    *dutPort,
+				DutAddress:            *dutAddress,
+				DutPort:               *dutPort,
+				DutTopologyConfigPath: *dutTopologyConfigPath,
 			})
 		if err != nil {
 			logger.Fatalln("Failed to start inventoryservice server: ", err)