[botanist] Introduce device target abstraction

This is similar to the target abstraction for QEMU.

Change-Id: I415080428259092d43ea8f50228912ff1047b2cf
diff --git a/botanist/target/device.go b/botanist/target/device.go
index 0ea5b11..5df0ae3 100644
--- a/botanist/target/device.go
+++ b/botanist/target/device.go
@@ -5,11 +5,19 @@
 package target
 
 import (
+	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io/ioutil"
+	"net"
+	"time"
 
+	"fuchsia.googlesource.com/tools/botanist"
 	"fuchsia.googlesource.com/tools/botanist/power"
+	"fuchsia.googlesource.com/tools/build"
+	"fuchsia.googlesource.com/tools/netboot"
+	"fuchsia.googlesource.com/tools/netutil"
 
 	"golang.org/x/crypto/ssh"
 )
@@ -26,7 +34,24 @@
 	SSHKeys []string `json:"keys,omitempty"`
 }
 
-// LoadDeviceConfigs unmarshalls a slice of DeviceConfigs from a given file.
+// DeviceOptions represents lifecycle options for a target.
+type DeviceOptions struct {
+	// Netboot gives whether to netboot or pave. Netboot here is being used in the
+	// colloquial sense of only sending netsvc a kernel to mexec. If false, the target
+	// will be paved.
+	Netboot bool
+
+	// SSHKey is a private SSH key file. If provided, the corresponding authorized key
+	// will be paved.
+	SSHKey string
+
+	// Fastboot is a path to the fastboot binary. If provided, it will be assumed that
+	// the device is waiting in fastboot mode, and it will be attempted to 'continue'
+	// it into zedboot.
+	Fastboot string
+}
+
+// LoadDeviceConfigs unmarshalls a slice of device configs from a given file.
 func LoadDeviceConfigs(path string) ([]DeviceConfig, error) {
 	data, err := ioutil.ReadFile(path)
 	if err != nil {
@@ -40,25 +65,126 @@
 	return configs, nil
 }
 
-// Returns the SSH signers associated with the key paths in the botanist config file if present.
-func SSHSignersFromConfigs(configs []DeviceConfig) ([]ssh.Signer, error) {
-	processedKeys := make(map[string]bool)
-	var signers []ssh.Signer
-	for _, config := range configs {
-		for _, keyPath := range config.SSHKeys {
-			if !processedKeys[keyPath] {
-				processedKeys[keyPath] = true
-				p, err := ioutil.ReadFile(keyPath)
-				if err != nil {
-					return nil, err
-				}
-				s, err := ssh.ParsePrivateKey(p)
-				if err != nil {
-					return nil, err
-				}
-				signers = append(signers, s)
+// DeviceTarget represents a target device.
+type DeviceTarget struct {
+	config  *DeviceConfig
+	opts    *DeviceOptions
+	signers []ssh.Signer
+}
+
+// NewDeviceTarget returns a new device target with a given configuration.
+func NewDeviceTarget(config DeviceConfig, opts DeviceOptions) (*DeviceTarget, error) {
+	// If an SSH key is specified in the options, prepend it the configs list so that it
+	// corresponds to the authorized key that would be paved.
+	if opts.SSHKey != "" {
+		config.SSHKeys = append([]string{opts.SSHKey}, config.SSHKeys...)
+	}
+	signers, err := parseOutSigners(config.SSHKeys)
+	if err != nil {
+		return nil, fmt.Errorf("could not parse out signers from private keys: %v", err)
+	}
+	return &DeviceTarget{
+		config:  &config,
+		opts:    &opts,
+		signers: signers,
+	}, nil
+}
+
+// Nodename returns the name of the node.
+func (t *DeviceTarget) Nodename() string {
+	return t.config.Nodename
+}
+
+// IPv6 returns the link-local IPv6 address of the node.
+func (t *DeviceTarget) IPv6Addr() (*net.UDPAddr, error) {
+	addr, err := netutil.GetNodeAddress(context.Background(), t.Nodename(), false)
+	return addr, err
+}
+
+// Start starts the device target.
+func (t *DeviceTarget) Start(ctx context.Context, images build.Images, args []string) error {
+	if t.opts.Fastboot != "" {
+		zirconR := images.Get("zircon-r")
+		if zirconR == nil {
+			return fmt.Errorf("zircon-r not provided")
+		}
+		// If it can't find any fastboot device, the fastboot tool will hang
+		// waiting, so we add a timeout.  All fastboot operations take less
+		// than a second on a developer workstation, so two minutes to flash
+		// and continue is very generous.
+		ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
+		defer cancel()
+		if err := botanist.FastbootToZedboot(ctx, t.opts.Fastboot, zirconR.Path); err != nil {
+			return fmt.Errorf("failed to fastboot to zedboot: %v", err)
+		}
+	}
+
+	// Set up log listener and dump kernel output to stdout.
+	l, err := netboot.NewLogListener(t.Nodename())
+	if err != nil {
+		return fmt.Errorf("cannot listen: %v", err)
+	}
+	go func() {
+		defer l.Close()
+		for {
+			data, err := l.Listen()
+			if err != nil {
+				continue
+			}
+			fmt.Print(data)
+			select {
+			case <-ctx.Done():
+				return
+			default:
 			}
 		}
+	}()
+
+	addr, err := t.IPv6Addr()
+	if err != nil {
+		return err
+	}
+
+	// Boot Fuchsia.
+	var bootMode int
+	if t.opts.Netboot {
+		bootMode = botanist.ModeNetboot
+	} else {
+		bootMode = botanist.ModePave
+	}
+	return botanist.Boot(ctx, addr, bootMode, images, args, t.signers)
+}
+
+// Restart restarts the target.
+func (t *DeviceTarget) Restart(ctx context.Context) error {
+	if t.config.Power != nil {
+		if err := t.config.Power.RebootDevice(t.signers, t.Nodename()); err != nil {
+			return fmt.Errorf("failed to reboot the device: %v\n", err)
+		}
+	}
+	return nil
+}
+
+func parseOutSigners(keyPaths []string) ([]ssh.Signer, error) {
+	if len(keyPaths) == 0 {
+		return nil, errors.New("must supply SSH keys in the config")
+	}
+	var keys [][]byte
+	for _, keyPath := range keyPaths {
+		p, err := ioutil.ReadFile(keyPath)
+		if err != nil {
+			return nil, fmt.Errorf("could not read SSH key file %q: %v", keyPath, err)
+		}
+		keys = append(keys, p)
+	}
+
+	var signers []ssh.Signer
+	for _, p := range keys {
+		signer, err := ssh.ParsePrivateKey(p)
+		if err != nil {
+			return nil, err
+		}
+		signers = append(signers, signer)
 	}
 	return signers, nil
 }
diff --git a/botanist/target/device_test.go b/botanist/target/device_test.go
index 1626a2f..30687ab 100644
--- a/botanist/target/device_test.go
+++ b/botanist/target/device_test.go
@@ -7,9 +7,6 @@
 	"io/ioutil"
 	"os"
 	"testing"
-
-	"fuchsia.googlesource.com/tools/botanist/power"
-	"fuchsia.googlesource.com/tools/sshutil"
 )
 
 func TestLoadConfigs(t *testing.T) {
@@ -55,81 +52,3 @@
 		}
 	}
 }
-
-func TestSSHSignersFromConfigs(t *testing.T) {
-	tests := []struct {
-		name        string
-		device1Keys []string
-		device2Keys []string
-		expectedLen int
-		expectErr   bool
-	}{
-		// Valid configs.
-		{"ValidSameKeyConfig", []string{"valid1"}, []string{"valid1"}, 1, false},
-		{"ValidDiffKeysWithDuplicateConfig", []string{"valid1", "valid2"}, []string{"valid1"}, 2, false},
-		{"ValidDiffKeysConfig", []string{"valid1"}, []string{"valid2"}, 2, false},
-		{"ValidEmptyKeysConfig", []string{}, []string{}, 0, false},
-		// Invalid configs.
-		{"InvalidKeyFileConfig", []string{"valid1"}, []string{"invalid"}, 0, true},
-		{"MissingKeyFileConfig", []string{"missing"}, []string{}, 0, true},
-	}
-
-	validKey1, err := sshutil.GeneratePrivateKey()
-	if err != nil {
-		t.Fatalf("Failed to generate private key: %s", err)
-	}
-	validKey2, err := sshutil.GeneratePrivateKey()
-	if err != nil {
-		t.Fatalf("Failed to generate private key: %s", err)
-	}
-	invalidKey := []byte("invalidKey")
-
-	keys := []struct {
-		name        string
-		keyContents []byte
-	}{
-		{"valid1", validKey1}, {"valid2", validKey2}, {"invalid", invalidKey},
-	}
-
-	keyNameToPath := make(map[string]string)
-	keyNameToPath["missing"] = "/path/to/nonexistent/key"
-	for _, key := range keys {
-		tmpfile, err := ioutil.TempFile(os.TempDir(), key.name)
-		if err != nil {
-			t.Fatalf("Failed to create test device properties file: %s", err)
-		}
-		defer os.Remove(tmpfile.Name())
-		if _, err := tmpfile.Write(key.keyContents); err != nil {
-			t.Fatalf("Failed to write to test device properties file: %s", err)
-		}
-		if err := tmpfile.Close(); err != nil {
-			t.Fatal(err)
-		}
-		keyNameToPath[key.name] = tmpfile.Name()
-	}
-
-	for _, test := range tests {
-		var keyPaths1 []string
-		for _, keyName := range test.device1Keys {
-			keyPaths1 = append(keyPaths1, keyNameToPath[keyName])
-		}
-		var keyPaths2 []string
-		for _, keyName := range test.device2Keys {
-			keyPaths2 = append(keyPaths2, keyNameToPath[keyName])
-		}
-		configs := []DeviceConfig{
-			{"device1", &power.Client{}, keyPaths1},
-			{"device2", &power.Client{}, keyPaths2},
-		}
-		signers, err := SSHSignersFromConfigs(configs)
-		if test.expectErr && err == nil {
-			t.Errorf("Test%v: Expected errors; no errors found", test.name)
-		}
-		if !test.expectErr && err != nil {
-			t.Errorf("Test%v: Expected no errors; found error - %v", test.name, err)
-		}
-		if len(signers) != test.expectedLen {
-			t.Errorf("Test%v: Expected %d signers; found %d", test.name, test.expectedLen, len(signers))
-		}
-	}
-}
diff --git a/cmd/botanist/zedboot.go b/cmd/botanist/zedboot.go
index 57a001c..748fb87 100644
--- a/cmd/botanist/zedboot.go
+++ b/cmd/botanist/zedboot.go
@@ -22,13 +22,10 @@
 	"fuchsia.googlesource.com/tools/build"
 	"fuchsia.googlesource.com/tools/command"
 	"fuchsia.googlesource.com/tools/logger"
-	"fuchsia.googlesource.com/tools/netboot"
-	"fuchsia.googlesource.com/tools/netutil"
 	"fuchsia.googlesource.com/tools/runner"
 	"fuchsia.googlesource.com/tools/runtests"
 
 	"github.com/google/subcommands"
-	"golang.org/x/crypto/ssh"
 )
 
 // ZedbootCommand is a Command implementation for running the testing workflow on a device
@@ -184,66 +181,7 @@
 	return cmd.tarHostCmdArtifacts(summaryBuffer.Bytes(), stdoutBuf.Bytes(), tmpDir)
 }
 
-func (cmd *ZedbootCommand) runTests(ctx context.Context, imgs build.Images, nodes []target.DeviceConfig, cmdlineArgs []string, signers []ssh.Signer) error {
-	var err error
-
-	// 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 := netutil.GetNodeAddress(ctx, node.Nodename, false)
-		if err != nil {
-			return err
-		}
-		addrs = append(addrs, addr)
-	}
-
-	// Boot fuchsia.
-	var bootMode int
-	if cmd.netboot {
-		bootMode = botanist.ModeNetboot
-	} else {
-		bootMode = botanist.ModePave
-	}
-	for _, addr := range addrs {
-		if err = botanist.Boot(ctx, addr, bootMode, imgs, cmdlineArgs, signers); err != nil {
-			return err
-		}
-	}
-
+func (cmd *ZedbootCommand) runTests(ctx context.Context, imgs build.Images, addr *net.UDPAddr, cmdlineArgs []string) error {
 	// Handle host commands
 	// TODO(IN-831): Remove when host-target-interaction infra is ready
 	if cmd.hostCmd != "" {
@@ -251,11 +189,6 @@
 	}
 
 	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]
 	return runtests.PollForSummary(ctx, addr, cmd.summaryFilename, cmd.testResultsDir, cmd.outputArchive, cmd.filePollInterval)
 }
 
@@ -265,22 +198,22 @@
 	if err != nil {
 		return fmt.Errorf("failed to load target config file %q", cmd.configFile)
 	}
-
-	signers, err := target.SSHSignersFromConfigs(configs)
-	if err != nil {
-		return err
+	opts := target.DeviceOptions{
+		Netboot:  cmd.netboot,
+		Fastboot: cmd.fastboot,
 	}
 
+	var devices []*target.DeviceTarget
 	for _, config := range configs {
-		if config.Power != nil {
-			defer func(cfg *target.DeviceConfig) {
-				logger.Debugf(ctx, "rebooting the node %q\n", cfg.Nodename)
-
-				if err := cfg.Power.RebootDevice(signers, cfg.Nodename); err != nil {
-					logger.Errorf(ctx, "failed to reboot the device: %v", err)
-				}
-			}(&config)
+		device, err := target.NewDeviceTarget(config, opts)
+		if err != nil {
+			return err
 		}
+		devices = append(devices, device)
+	}
+
+	for _, device := range devices {
+		defer device.Restart(ctx)
 	}
 
 	imgs, err := build.LoadImages(cmd.imageManifests...)
@@ -291,27 +224,21 @@
 	ctx, cancel := context.WithCancel(ctx)
 	defer cancel()
 	errs := make(chan error)
-	go func() {
-		if cmd.fastboot != "" {
-			zirconR := imgs.Get("zircon-r")
-			if zirconR == nil {
-				errs <- fmt.Errorf("zircon-r not provided")
-				return
-			}
-			// If it can't find any fastboot device, the fastboot
-			// tool will hang waiting, so we add a timeout.
-			// All fastboot operations take less than a second on
-			// a developer workstation, so two minutes to flash and
-			// continue is very generous.
-			ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
-			defer cancel()
-			logger.Debugf(ctx, "flashing to zedboot with fastboot\n")
-			if err := botanist.FastbootToZedboot(ctx, cmd.fastboot, zirconR.Path); err != nil {
+
+	for _, device := range devices {
+		go func() {
+			if err := device.Start(ctx, imgs, cmdlineArgs); err != nil {
 				errs <- err
-				return
 			}
+		}()
+	}
+	go func() {
+		addr, err := devices[0].IPv6Addr()
+		if err != nil {
+			errs <- err
+			return
 		}
-		errs <- cmd.runTests(ctx, imgs, configs, cmdlineArgs, signers)
+		errs <- cmd.runTests(ctx, imgs, addr, cmdlineArgs)
 	}()
 
 	select {
diff --git a/cmd/health_checker/main.go b/cmd/health_checker/main.go
index 5fba855..48190fe 100644
--- a/cmd/health_checker/main.go
+++ b/cmd/health_checker/main.go
@@ -4,6 +4,7 @@
 package main
 
 import (
+	"context"
 	"encoding/json"
 	"flag"
 	"fmt"
@@ -75,21 +76,6 @@
 	return HealthCheckResult{nodename, healthyState, ""}
 }
 
-func reboot(config target.DeviceConfig) error {
-	if config.Power == nil {
-		return fmt.Errorf("Failed to reboot the device: missing power management info in botanist config file.")
-	}
-	signers, err := target.SSHSignersFromConfigs([]target.DeviceConfig{config})
-	if err != nil {
-		return fmt.Errorf("Failed to reboot the device: %v.", err)
-	}
-
-	if err = config.Power.RebootDevice(signers, config.Nodename); err != nil {
-		return fmt.Errorf("Failed to reboot the device: %v.", err)
-	}
-	return nil
-}
-
 func printHealthCheckResults(checkResults []HealthCheckResult) error {
 	output, err := json.Marshal(checkResults)
 	if err != nil {
@@ -120,15 +106,25 @@
 	if err != nil {
 		log.Fatal(err)
 	}
-	var checkResultSlice []HealthCheckResult
+
+	var devices []*target.DeviceTarget
 	for _, config := range configs {
-		nodename := config.Nodename
+		device, err := target.NewDeviceTarget(config, target.DeviceOptions{})
+		if err != nil {
+			log.Fatal(err)
+		}
+		devices = append(devices, device)
+	}
+
+	var checkResultSlice []HealthCheckResult
+	for _, device := range devices {
+		nodename := device.Nodename()
 		if nodename == "" {
-			log.Fatal("Failed to retrieve nodename from config file")
+			log.Fatal("no nodename in config")
 		}
 		checkResult := checkHealth(client, nodename)
 		if checkResult.State == unhealthyState && rebootIfUnhealthy {
-			if rebootErr := reboot(config); rebootErr != nil {
+			if rebootErr := device.Restart(context.Background()); rebootErr != nil {
 				checkResult.ErrorMsg += "; " + rebootErr.Error()
 			}
 		}