[acts] Added multidevice support to Botanist.

Added multi-device support in both run.go and zedboot.go.
This enables multi-fuchsia device tests (such as a subset
of BT ACTS tests) to be run in our infra.

Bug: IN-890 #comment
Change-Id: I7d3496695d2155899752fc079248a6e311303ee5
diff --git a/botanist/common.go b/botanist/common.go
index 43b017a..db7fd3d 100644
--- a/botanist/common.go
+++ b/botanist/common.go
@@ -8,8 +8,8 @@
 	"context"
 	"encoding/json"
 	"fmt"
+	"io/ioutil"
 	"net"
-	"os"
 	"strings"
 	"time"
 
@@ -63,12 +63,23 @@
 	SSHKeys []string `json:"keys,omitempty"`
 }
 
-// LoadDeviceProperties unmarshalls the DeviceProperties found in a given file.
-func LoadDeviceProperties(propertiesFile string, properties *DeviceProperties) error {
-	file, err := os.Open(propertiesFile)
+// LoadDeviceProperties unmarshalls a slice of DeviceProperties from a given file.
+// For backwards compatibility, it supports unmarshalling a single DeviceProperties object also
+// TODO(IN-1028): Update all botanist configs to use JSON list format
+func LoadDeviceProperties(path string) ([]DeviceProperties, error) {
+	data, err := ioutil.ReadFile(path)
 	if err != nil {
-		return err
+		return nil, fmt.Errorf("failed to read device properties file %q", path)
 	}
 
-	return json.NewDecoder(file).Decode(properties)
+	var propertiesSlice []DeviceProperties
+
+	if err := json.Unmarshal(data, &propertiesSlice); err != nil {
+		var properties DeviceProperties
+		if err := json.Unmarshal(data, &properties); err != nil {
+			return nil, err
+		}
+		propertiesSlice = append(propertiesSlice, properties)
+	}
+	return propertiesSlice, nil
 }
diff --git a/botanist/common_test.go b/botanist/common_test.go
new file mode 100644
index 0000000..94f5785
--- /dev/null
+++ b/botanist/common_test.go
@@ -0,0 +1,56 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+package botanist
+
+import (
+	"io/ioutil"
+	"os"
+	"testing"
+)
+
+func TestLoadDevicePropertiesSlice(t *testing.T) {
+	tests := []struct {
+		name        string
+		jsonStr     string
+		expectedLen int
+		expectErr   bool
+	}{
+		// Valid configs.
+		{"ValidListConfig", `[{"nodename":"upper-drank-wick-creek"},{"nodename":"siren-swoop-wick-hasty"}]`, 2, false},
+		{"ValidSingleConfig", `{"nodename":"upper-drank-wick-creek"}`, 1, false},
+		// Invalid configs.
+		{"InvalidListConfig", `{{"nodename":"upper-drank-wick-creek"},{"nodename":"siren-swoop-wick-hasty"}}`, 0, true},
+		{"InvalidSingleConfig", `{"upper-drank-wick-creek"}`, 0, true},
+	}
+	for _, test := range tests {
+		tmpfile, err := ioutil.TempFile(os.TempDir(), "common_test")
+		if err != nil {
+			t.Fatalf("Failed to create test device properties file: %s", err)
+		}
+		defer os.Remove(tmpfile.Name())
+
+		content := []byte(test.jsonStr)
+		if _, err := tmpfile.Write(content); err != nil {
+			t.Fatalf("Failed to write to test device properties file: %s", err)
+		}
+
+		propertiesSlice, err := LoadDeviceProperties(tmpfile.Name())
+
+		if test.expectErr && err == nil {
+			t.Errorf("Test%v: Exepected errors; no errors found", test.name)
+		}
+
+		if !test.expectErr && err != nil {
+			t.Errorf("Test%v: Exepected no errors; found error - %v", test.name, err)
+		}
+
+		if len(propertiesSlice) != test.expectedLen {
+			t.Errorf("Test%v: Expected %d nodes; found %d", test.name, test.expectedLen, len(propertiesSlice))
+		}
+
+		if err := tmpfile.Close(); err != nil {
+			t.Fatal(err)
+		}
+	}
+}
diff --git a/cmd/botanist/run.go b/cmd/botanist/run.go
index 09ed928..e8141e7 100644
--- a/cmd/botanist/run.go
+++ b/cmd/botanist/run.go
@@ -191,10 +191,13 @@
 		return fmt.Errorf("failed to load images: %v", err)
 	}
 
-	var properties botanist.DeviceProperties
-	if err := botanist.LoadDeviceProperties(r.deviceFile, &properties); err != nil {
-		return fmt.Errorf("failed to open device properties file \"%v\": %v", r.deviceFile, err)
+	propertiesSlice, err := botanist.LoadDeviceProperties(r.deviceFile)
+	if err != nil {
+		return fmt.Errorf("failed to load device properties file %q", r.deviceFile)
+	} else if len(propertiesSlice) != 1 {
+		return fmt.Errorf("expected 1 entry in the device properties file; found %d", len(propertiesSlice))
 	}
+	properties := propertiesSlice[0]
 
 	// Merge config file and command-line keys.
 	privKeyPaths := properties.SSHKeys
diff --git a/cmd/botanist/zedboot.go b/cmd/botanist/zedboot.go
index 6198e20..77ecfa2 100644
--- a/cmd/botanist/zedboot.go
+++ b/cmd/botanist/zedboot.go
@@ -208,32 +208,51 @@
 	return cmd.tarHostCmdArtifacts(summaryBuffer.Bytes(), stdoutBuf.Bytes(), tmpDir)
 }
 
-func (cmd *ZedbootCommand) runTests(ctx context.Context, imgs build.Images, nodename string, cmdlineArgs []string, signers []ssh.Signer) error {
-	// Set up log listener and dump kernel output to stdout.
-	l, err := netboot.NewLogListener(nodename)
-	if err != nil {
-		return fmt.Errorf("cannot listen: %v", err)
-	}
-	go func() {
-		defer l.Close()
-		logger.Debugf(ctx, "starting log listener\n")
-		for {
-			data, err := l.Listen()
-			if err != nil {
-				continue
-			}
-			fmt.Print(data)
-			select {
-			case <-ctx.Done():
-				return
-			default:
-			}
-		}
-	}()
+func (cmd *ZedbootCommand) runTests(ctx context.Context, imgs build.Images, nodes []botanist.DeviceProperties, cmdlineArgs []string, signers []ssh.Signer) error {
+	var err error
 
-	addr, err := botanist.GetNodeAddress(ctx, nodename, false)
-	if err != nil {
-		return err
+	// Set up log listener and dump kernel output to stdout.
+	for _, node := range nodes {
+		l, err := netboot.NewLogListener(node.Nodename)
+		if err != nil {
+			return fmt.Errorf("cannot listen: %v\n", err)
+		}
+		go func(nodename string) {
+			defer l.Close()
+			logger.Debugf(ctx, "starting log listener for <<%s>>\n", nodename)
+			for {
+				data, err := l.Listen()
+				if err != nil {
+					continue
+				}
+				if len(nodes) == 1 {
+					fmt.Print(data)
+				} else {
+					// Print each line with nodename prepended when there are multiple nodes
+					lines := strings.Split(data, "\n")
+					for _, line := range lines {
+						if len(line) > 0 {
+							fmt.Printf("<<%s>> %s\n", nodename, line)
+						}
+					}
+				}
+
+				select {
+				case <-ctx.Done():
+					return
+				default:
+				}
+			}
+		}(node.Nodename)
+	}
+
+	var addrs []*net.UDPAddr
+	for _, node := range nodes {
+		addr, err := botanist.GetNodeAddress(ctx, node.Nodename, false)
+		if err != nil {
+			return err
+		}
+		addrs = append(addrs, addr)
 	}
 
 	// Boot fuchsia.
@@ -243,8 +262,10 @@
 	} else {
 		bootMode = botanist.ModePave
 	}
-	if err = botanist.Boot(ctx, addr, bootMode, imgs, cmdlineArgs, signers); err != nil {
-		return err
+	for _, addr := range addrs {
+		if err = botanist.Boot(ctx, addr, bootMode, imgs, cmdlineArgs, signers); err != nil {
+			return err
+		}
 	}
 
 	// Handle host commands
@@ -254,7 +275,11 @@
 	}
 
 	logger.Debugf(ctx, "waiting for %q\n", cmd.summaryFilename)
+	if len(addrs) != 1 {
+		return fmt.Errorf("Non-host tests should have exactly 1 node defined in config, found %v", len(addrs))
+	}
 
+	addr := addrs[0]
 	// Poll for summary.json; this relies on runtest being executed using
 	// autorun and it eventually producing the summary.json file.
 	t := tftp.NewClient()
@@ -345,32 +370,41 @@
 }
 
 func (cmd *ZedbootCommand) execute(ctx context.Context, cmdlineArgs []string) error {
-	var properties botanist.DeviceProperties
-	if err := botanist.LoadDeviceProperties(cmd.propertiesFile, &properties); err != nil {
-		return fmt.Errorf("failed to open device properties file \"%v\"", cmd.propertiesFile)
+	propertiesSlice, err := botanist.LoadDeviceProperties(cmd.propertiesFile)
+
+	if err != nil {
+		return fmt.Errorf("failed to load device properties file %q", cmd.propertiesFile)
 	}
 
+	processedKeys := make(map[string]bool)
 	var signers []ssh.Signer
-	for _, keyPath := range properties.SSHKeys {
-		p, err := ioutil.ReadFile(keyPath)
-		if err != nil {
-			return err
+	for _, properties := range propertiesSlice {
+		for _, keyPath := range properties.SSHKeys {
+			if !processedKeys[keyPath] {
+				processedKeys[keyPath] = true
+				p, err := ioutil.ReadFile(keyPath)
+				if err != nil {
+					return err
+				}
+				s, err := ssh.ParsePrivateKey(p)
+				if err != nil {
+					return err
+				}
+				signers = append(signers, s)
+			}
 		}
-		s, err := ssh.ParsePrivateKey(p)
-		if err != nil {
-			return err
-		}
-		signers = append(signers, s)
 	}
 
-	if properties.PDU != nil {
-		defer func() {
-			logger.Debugf(ctx, "rebooting the node %q\n", properties.Nodename)
+	for _, properties := range propertiesSlice {
+		if properties.PDU != nil {
+			defer func() {
+				logger.Debugf(ctx, "rebooting the node %q\n", properties.Nodename)
 
-			if err := botanist.RebootDevice(properties.PDU, signers, properties.Nodename); err != nil {
-				logger.Errorf(ctx, "failed to reboot the device: %v\n", err)
-			}
-		}()
+				if err := botanist.RebootDevice(properties.PDU, signers, properties.Nodename); err != nil {
+					logger.Errorf(ctx, "failed to reboot the device: %v", err)
+				}
+			}()
+		}
 	}
 
 	ctx, cancel := context.WithCancel(ctx)
@@ -407,7 +441,7 @@
 				return
 			}
 		}
-		errs <- cmd.runTests(ctx, imgs, properties.Nodename, cmdlineArgs, signers)
+		errs <- cmd.runTests(ctx, imgs, propertiesSlice, cmdlineArgs, signers)
 	}()
 
 	select {