[botanist] Introduce Target interface

This change introduces the Target interface, for which both QEMUTarget
and DeviceTarget are implementations. `botanist run` is modified to deal
abstractly with a Target (possibly a QEMU instance).

Test: ran locally with a QEMU config, as well as against my NUC.

Change-Id: Iba0e6b1c23925c7560fa02445ea76985a4989791
diff --git a/botanist/target/device.go b/botanist/target/device.go
index f496023..a299a21 100644
--- a/botanist/target/device.go
+++ b/botanist/target/device.go
@@ -49,23 +49,6 @@
 	IPv4Addr string `json:"ipv4"`
 }
 
-// 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)
@@ -82,13 +65,13 @@
 
 // DeviceTarget represents a target device.
 type DeviceTarget struct {
-	config  *DeviceConfig
-	opts    *DeviceOptions
+	config  DeviceConfig
+	opts    Options
 	signers []ssh.Signer
 }
 
 // NewDeviceTarget returns a new device target with a given configuration.
-func NewDeviceTarget(config DeviceConfig, opts DeviceOptions) (*DeviceTarget, error) {
+func NewDeviceTarget(config DeviceConfig, opts Options) (*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 != "" {
@@ -99,8 +82,8 @@
 		return nil, fmt.Errorf("could not parse out signers from private keys: %v", err)
 	}
 	return &DeviceTarget{
-		config:  &config,
-		opts:    &opts,
+		config:  config,
+		opts:    opts,
 		signers: signers,
 	}, nil
 }
@@ -112,8 +95,7 @@
 
 // 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
+	return netutil.GetNodeAddress(context.Background(), t.Nodename(), false)
 }
 
 // IPv4Addr returns the IPv4 address of the node. If not provided in the config, then it
@@ -122,8 +104,7 @@
 	if t.config.Network.IPv4Addr != "" {
 		return net.ParseIP(t.config.Network.IPv4Addr), nil
 	}
-	addr, err := botanist.ResolveIPv4(context.Background(), t.Nodename(), netstackTimeout)
-	return addr, err
+	return botanist.ResolveIPv4(context.Background(), t.Nodename(), netstackTimeout)
 }
 
 // SSHKey returns the private SSH key path associated with the authorized key to be paved.
@@ -195,6 +176,16 @@
 	return nil
 }
 
+// Stop stops the device.
+func (t *DeviceTarget) Stop(ctx context.Context) error {
+	return ErrUnimplemented
+}
+
+// Wait waits for the device target to stop.
+func (t *DeviceTarget) Wait(ctx context.Context) error {
+	return ErrUnimplemented
+}
+
 func parseOutSigners(keyPaths []string) ([]ssh.Signer, error) {
 	if len(keyPaths) == 0 {
 		return nil, errors.New("must supply SSH keys in the config")
diff --git a/botanist/target/errors.go b/botanist/target/errors.go
new file mode 100644
index 0000000..bdb49f7
--- /dev/null
+++ b/botanist/target/errors.go
@@ -0,0 +1,14 @@
+// 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 target
+
+import (
+	"errors"
+)
+
+var (
+	// ErrUnimplemented is an error for unimplemented methods.
+	ErrUnimplemented error = errors.New("method unimplemented")
+)
diff --git a/botanist/target/options.go b/botanist/target/options.go
new file mode 100644
index 0000000..9546bb7
--- /dev/null
+++ b/botanist/target/options.go
@@ -0,0 +1,23 @@
+// 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 target
+
+// Options represents lifecycle options for a target. The options will not necessarily make
+// sense for all target types.
+type Options 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. Ignored for QEMUTarget.
+	Netboot bool
+
+	// SSHKey is a private SSH key file, corresponding to an authorized key to be paved or
+	// to one baked into a boot image.
+	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. Ignored for QEMUTarget.
+	Fastboot string
+}
diff --git a/botanist/target/qemu.go b/botanist/target/qemu.go
index 1750505..050c10f 100644
--- a/botanist/target/qemu.go
+++ b/botanist/target/qemu.go
@@ -10,6 +10,7 @@
 	"io"
 	"io/ioutil"
 	"log"
+	"net"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -22,6 +23,16 @@
 	// qemuSystemPrefix is the prefix of the QEMU binary name, which is of the
 	// form qemu-system-<QEMU arch suffix>.
 	qemuSystemPrefix = "qemu-system"
+
+	// DefaultInterfaceName is the name given to the emulated tap interface.
+	defaultInterfaceName = "qemu"
+
+	// DefaultMACAddr is the default MAC address given to a QEMU target.
+	defaultMACAddr       = "52:54:00:63:5e:7a"
+
+	// DefaultNodename is the default nodename given to an target with the default QEMU MAC address.
+	defaultNodename      = "step-atom-yard-juicy"
+
 )
 
 // qemuTargetMapping maps the Fuchsia target name to the name recognized by QEMU.
@@ -63,6 +74,7 @@
 // QEMUTarget is a QEMU target.
 type QEMUTarget struct {
 	config QEMUConfig
+	opts   Options
 
 	c chan error
 
@@ -71,24 +83,40 @@
 }
 
 // NewQEMUTarget returns a new QEMU target with a given configuration.
-func NewQEMUTarget(config QEMUConfig) *QEMUTarget {
+func NewQEMUTarget(config QEMUConfig, opts Options) *QEMUTarget {
 	return &QEMUTarget{
 		config: config,
+		opts:   opts,
 		c:      make(chan error),
 	}
 }
 
+// Nodename returns the name of the target node.
+func (t *QEMUTarget) Nodename() string {
+	return defaultNodename
+}
+
+// IPv4Addr returns a nil address, as DHCP is not currently configured.
+func (t *QEMUTarget) IPv4Addr() (net.IP, error) {
+	return  nil, nil
+}
+
+// SSHKey returns the private SSH key path associated with the authorized key to be pavet.
+func (t *QEMUTarget) SSHKey() string {
+	return t.opts.SSHKey
+}
+
 // Start starts the QEMU target.
-func (d *QEMUTarget) Start(ctx context.Context, images build.Images, args []string) error {
-	qemuTarget, ok := qemuTargetMapping[d.config.Target]
+func (t *QEMUTarget) Start(ctx context.Context, images build.Images, args []string) error {
+	qemuTarget, ok := qemuTargetMapping[t.config.Target]
 	if !ok {
-		return fmt.Errorf("invalid target %q", d.config.Target)
+		return fmt.Errorf("invalid target %q", t.config.Target)
 	}
 
-	if d.config.Path == "" {
+	if t.config.Path == "" {
 		return fmt.Errorf("directory must be set")
 	}
-	qemuSystem := filepath.Join(d.config.Path, fmt.Sprintf("%s-%s", qemuSystemPrefix, qemuTarget))
+	qemuSystem := filepath.Join(t.config.Path, fmt.Sprintf("%s-%s", qemuSystemPrefix, qemuTarget))
 	if _, err := os.Stat(qemuSystem); err != nil {
 		return fmt.Errorf("could not find qemu-system binary %q: %v", qemuSystem, err)
 	}
@@ -109,11 +137,11 @@
 			File: storageFull.Path,
 		})
 	}
-	if d.config.MinFS != nil {
-		if _, err := os.Stat(d.config.MinFS.Image); err != nil {
-			return fmt.Errorf("could not find minfs image %q: %v", d.config.MinFS.Image, err)
+	if t.config.MinFS != nil {
+		if _, err := os.Stat(t.config.MinFS.Image); err != nil {
+			return fmt.Errorf("could not find minfs image %q: %v", t.config.MinFS.Image, err)
 		}
-		file, err := filepath.Abs(d.config.MinFS.Image)
+		file, err := filepath.Abs(t.config.MinFS.Image)
 		if err != nil {
 			return err
 		}
@@ -126,22 +154,26 @@
 		drives = append(drives, qemu.Drive{
 			ID:   "testdisk",
 			File: file,
-			Addr: d.config.MinFS.PCIAddress,
+			Addr: t.config.MinFS.PCIAddress,
 		})
 	}
 
 	networks := []qemu.Netdev{
 		qemu.Netdev{
 			ID: "net0",
+			Tap: &qemu.NetdevTap{
+				Name: defaultInterfaceName,
+			},
+			MAC: defaultMACAddr,
 		},
 	}
 
 	config := qemu.Config{
 		Binary:   qemuSystem,
 		Target:   qemuTarget,
-		CPU:      d.config.CPU,
-		Memory:   d.config.Memory,
-		KVM:      d.config.KVM,
+		CPU:      t.config.CPU,
+		Memory:   t.config.Memory,
+		KVM:      t.config.KVM,
 		Kernel:   qemuKernel.Path,
 		Initrd:   zirconA.Path,
 		Drives:   drives,
@@ -154,7 +186,7 @@
 	args = append(args, "devmgr.suspend-timeout-debug=true")
 	// Do not print colors.
 	args = append(args, "TERM=dumb")
-	if d.config.Target == "x64" {
+	if t.config.Target == "x64" {
 		// Necessary to redirect to stdout.
 		args = append(args, "kernel.serial=legacy")
 	}
@@ -172,7 +204,7 @@
 		return err
 	}
 
-	d.cmd = &exec.Cmd{
+	t.cmd = &exec.Cmd{
 		Path:   invocation[0],
 		Args:   invocation,
 		Dir:    workdir,
@@ -181,7 +213,7 @@
 	}
 	log.Printf("QEMU invocation:\n%s", invocation)
 
-	if err := d.cmd.Start(); err != nil {
+	if err := t.cmd.Start(); err != nil {
 		os.RemoveAll(workdir)
 		return fmt.Errorf("failed to start: %v", err)
 	}
@@ -190,20 +222,25 @@
 	// method is invoked or not.
 	go func() {
 		defer os.RemoveAll(workdir)
-		d.c <- qemu.CheckExitCode(d.cmd.Wait())
+		t.c <- qemu.CheckExitCode(t.cmd.Wait())
 	}()
 
 	return nil
 }
 
+// Wait waits for the QEMU target to stop.
+func (t *QEMUTarget) Restart(ctx context.Context) error {
+	return ErrUnimplemented
+}
+
 // Stop stops the QEMU target.
-func (d *QEMUTarget) Stop(ctx context.Context) error {
-	return d.cmd.Process.Kill()
+func (t *QEMUTarget) Stop(ctx context.Context) error {
+	return t.cmd.Process.Kill()
 }
 
 // Wait waits for the QEMU target to stop.
-func (d *QEMUTarget) Wait(ctx context.Context) error {
-	return <-d.c
+func (t *QEMUTarget) Wait(ctx context.Context) error {
+	return <-t.c
 }
 
 func overwriteFileWithCopy(path string) error {
diff --git a/cmd/botanist/qemu.go b/cmd/botanist/qemu.go
index c3164d0..a76a5b2 100644
--- a/cmd/botanist/qemu.go
+++ b/cmd/botanist/qemu.go
@@ -90,8 +90,7 @@
 		}
 	}
 
-	t := target.NewQEMUTarget(config)
-
+	t := target.NewQEMUTarget(config, target.Options{})
 	if err := t.Start(ctx, imgs, cmdlineArgs); err != nil {
 		return err
 	}
diff --git a/cmd/botanist/run.go b/cmd/botanist/run.go
index 4315505..a3839d2 100644
--- a/cmd/botanist/run.go
+++ b/cmd/botanist/run.go
@@ -5,10 +5,12 @@
 
 import (
 	"context"
+	"encoding/json"
 	"flag"
 	"fmt"
 	"io"
 	"io/ioutil"
+	"net"
 	"os"
 	"time"
 
@@ -23,13 +25,39 @@
 	"github.com/google/subcommands"
 )
 
-const netstackTimeout time.Duration = 1 * time.Minute
+const (
+	netstackTimeout time.Duration = 1 * time.Minute
+)
+
+// Target represents a fuchsia instance.
+type Target interface {
+	// Nodename returns the name of the target node.
+	Nodename() string
+
+	// IPv4Addr returns the IPv4 address of the target.
+	IPv4Addr() (net.IP, error)
+
+	// SSHKey returns the private key corresponding an authorized SSH key of the target.
+	SSHKey() string
+
+	// Start starts the target.
+	Start(ctx context.Context, images build.Images, args []string) error
+
+	// Restart restarts the target.
+	Restart(ctx context.Context) error
+
+	// Stop stops the target.
+	Stop(ctx context.Context) error
+
+	// Wait waits for the target to finish running.
+	Wait(ctx context.Context) error
+}
 
 // RunCommand is a Command implementation for booting a device and running a
 // given command locally.
 type RunCommand struct {
-	// DeviceFile is the path to a file of device config.
-	deviceFile string
+	// ConfigFile is the path to the target configurations.
+	configFile string
 
 	// ImageManifests is a list of paths to image manifests (e.g., images.json)
 	imageManifests command.StringsFlag
@@ -77,7 +105,7 @@
 }
 
 func (r *RunCommand) SetFlags(f *flag.FlagSet) {
-	f.StringVar(&r.deviceFile, "device", "/etc/botanist/config.json", "path to file of device config")
+	f.StringVar(&r.configFile, "config", "/etc/botanist/device.json", "path to file of device config")
 	f.Var(&r.imageManifests, "images", "paths to image manifests")
 	f.BoolVar(&r.netboot, "netboot", false, "if set, botanist will not pave; but will netboot instead")
 	f.StringVar(&r.fastboot, "fastboot", "", "path to the fastboot tool; if set, the device will be flashed into Zedboot. A zircon-r must be supplied via -images")
@@ -89,11 +117,11 @@
 	f.StringVar(&r.sshKey, "ssh", "", "file containing a private SSH user key; if not provided, a private key will be generated.")
 }
 
-func (r *RunCommand) runCmd(ctx context.Context, args []string, device *target.DeviceTarget, syslog io.Writer) error {
-	nodename := device.Nodename()
+func (r *RunCommand) runCmd(ctx context.Context, args []string, t Target, syslog io.Writer) error {
+	nodename := t.Nodename()
 	// If having paved, SSH in and stream syslogs back to a file sink.
 	if !r.netboot && syslog != nil {
-		p, err := ioutil.ReadFile(device.SSHKey())
+		p, err := ioutil.ReadFile(t.SSHKey())
 		if err != nil {
 			return err
 		}
@@ -115,7 +143,7 @@
 		}()
 	}
 
-	ip, err := device.IPv4Addr()
+	ip, err := t.IPv4Addr()
 	if err == nil {
 		logger.Infof(ctx, "IPv4 address of %s found: %s", nodename, ip)
 	} else {
@@ -126,7 +154,7 @@
 		os.Environ(),
 		fmt.Sprintf("FUCHSIA_NODENAME=%s", nodename),
 		fmt.Sprintf("FUCHSIA_IPV4_ADDR=%v", ip),
-		fmt.Sprintf("FUCHSIA_SSH_KEY=%s", device.SSHKey()),
+		fmt.Sprintf("FUCHSIA_SSH_KEY=%s", t.SSHKey()),
 	)
 
 	ctx, cancel := context.WithTimeout(ctx, r.timeout)
@@ -169,21 +197,34 @@
 		return fmt.Errorf("failed to load images: %v", err)
 	}
 
-	configs, err := target.LoadDeviceConfigs(r.deviceFile)
-	if err != nil {
-		return fmt.Errorf("failed to load target config file %q", r.deviceFile)
-	} else if len(configs) != 1 {
-		return fmt.Errorf("`botanist run` only supports configuration for a single target")
-	}
-	opts := target.DeviceOptions{
+	opts := target.Options{
 		Netboot:  r.netboot,
 		Fastboot: r.fastboot,
 		SSHKey:   r.sshKey,
 	}
-	device, err := target.NewDeviceTarget(configs[0], opts)
+
+	data, err := ioutil.ReadFile(r.configFile)
 	if err != nil {
-		return err
+		return fmt.Errorf("could not open config file: %v", err)
 	}
+	var objs []json.RawMessage
+	if err := json.Unmarshal(data, &objs); err != nil {
+		return fmt.Errorf("could not unmarshal config file as a JSON list: %v", err)
+	}
+
+	var targets []Target
+	for _, obj := range objs {
+		t, err := DeriveTarget(obj, opts)
+		if err != nil {
+			return err
+		}
+		targets = append(targets, t)
+	}
+
+	if len(targets) != 1 {
+		return fmt.Errorf("`botanist run` only supports configuration for a single target")
+	}
+	t := targets[0]
 
 	var syslog io.WriteCloser
 	if r.syslogFile != "" {
@@ -195,20 +236,25 @@
 	}
 
 	defer func() {
-		logger.Debugf(ctx, "rebooting the node %q\n", device.Nodename())
-		device.Restart(ctx)
+		logger.Debugf(ctx, "stopping or rebooting the node %q\n", t.Nodename())
+		if err := t.Stop(ctx); err == target.ErrUnimplemented {
+			t.Restart(ctx)
+		}
 	}()
 
 	ctx, cancel := context.WithCancel(ctx)
 	defer cancel()
 	errs := make(chan error)
 	go func() {
-		if err := device.Start(ctx, imgs, r.zirconArgs); err != nil {
+		if err := t.Start(ctx, imgs, r.zirconArgs); err != nil {
+			errs <- err
+		}
+		if err := t.Wait(ctx); err != nil && err != target.ErrUnimplemented {
 			errs <- err
 		}
 	}()
 	go func() {
-		errs <- r.runCmd(ctx, args, device, syslog)
+		errs <- r.runCmd(ctx, args, t, syslog)
 	}()
 
 	select {
@@ -230,3 +276,31 @@
 	}
 	return subcommands.ExitSuccess
 }
+
+func DeriveTarget(obj []byte, opts target.Options) (Target, error) {
+	type typed struct {
+		Type string `json:"type"`
+	}
+	var x typed
+
+	if err := json.Unmarshal(obj, &x); err != nil {
+		return nil, fmt.Errorf("object in list has no \"type\" field: %v", err)
+	}
+	switch x.Type {
+	case "qemu":
+		var cfg target.QEMUConfig
+		if err := json.Unmarshal(obj, &cfg); err != nil {
+			return nil, fmt.Errorf("invalid QEMU config found: %v", err)
+		}
+		return target.NewQEMUTarget(cfg, opts), nil
+	case "device":
+		var cfg target.DeviceConfig
+		if err := json.Unmarshal(obj, &cfg); err != nil {
+			return nil, fmt.Errorf("invalid device config found: %v", err)
+		}
+		t, err := target.NewDeviceTarget(cfg, opts)
+		return t, err
+	default:
+		return nil, fmt.Errorf("unknown type found: %q", x.Type)
+	}
+}
diff --git a/cmd/botanist/zedboot.go b/cmd/botanist/zedboot.go
index 13de934..052e32f 100644
--- a/cmd/botanist/zedboot.go
+++ b/cmd/botanist/zedboot.go
@@ -198,7 +198,7 @@
 	if err != nil {
 		return fmt.Errorf("failed to load target config file %q", cmd.configFile)
 	}
-	opts := target.DeviceOptions{
+	opts := target.Options{
 		Netboot:  cmd.netboot,
 		Fastboot: cmd.fastboot,
 	}
diff --git a/cmd/health_checker/main.go b/cmd/health_checker/main.go
index 48190fe..add3f95 100644
--- a/cmd/health_checker/main.go
+++ b/cmd/health_checker/main.go
@@ -109,7 +109,7 @@
 
 	var devices []*target.DeviceTarget
 	for _, config := range configs {
-		device, err := target.NewDeviceTarget(config, target.DeviceOptions{})
+		device, err := target.NewDeviceTarget(config, target.Options{})
 		if err != nil {
 			log.Fatal(err)
 		}