[botanist] Move all botanist code here from infra/infra.

This is in preparation to move all of tools.git to fuchsia.git.

Bug: 10321

Change-Id: Ic6b0ddbfc6836619b69712b7d0775a18e9983ae0
diff --git a/botanist/cmd/main.go b/botanist/cmd/main.go
new file mode 100644
index 0000000..c2fc2d5
--- /dev/null
+++ b/botanist/cmd/main.go
@@ -0,0 +1,46 @@
+// Copyright 2017 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 main
+
+import (
+	"context"
+	"flag"
+	"os"
+	"syscall"
+
+	"github.com/google/subcommands"
+
+	"go.fuchsia.dev/tools/color"
+	"go.fuchsia.dev/tools/command"
+	"go.fuchsia.dev/tools/logger"
+)
+
+var (
+	colors color.EnableColor
+	level  logger.LogLevel
+)
+
+func init() {
+	colors = color.ColorAuto
+	level = logger.InfoLevel
+
+	flag.Var(&colors, "color", "use color in output, can be never, auto, always")
+	flag.Var(&level, "level", "output verbosity, can be fatal, error, warning, info, debug or trace")
+}
+
+func main() {
+	subcommands.Register(subcommands.HelpCommand(), "")
+	subcommands.Register(subcommands.CommandsCommand(), "")
+	subcommands.Register(subcommands.FlagsCommand(), "")
+	subcommands.Register(&ZedbootCommand{}, "")
+	subcommands.Register(&QEMUCommand{}, "")
+	subcommands.Register(&RunCommand{}, "")
+
+	flag.Parse()
+
+	log := logger.NewLogger(level, color.NewColor(colors), os.Stdout, os.Stderr, "botanist ")
+	ctx := logger.WithLogger(context.Background(), log)
+	ctx = command.CancelOnSignals(ctx, syscall.SIGTERM)
+	os.Exit(int(subcommands.Execute(ctx)))
+}
diff --git a/botanist/cmd/qemu.go b/botanist/cmd/qemu.go
new file mode 100644
index 0000000..320fcdf
--- /dev/null
+++ b/botanist/cmd/qemu.go
@@ -0,0 +1,115 @@
+// Copyright 2018 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 main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+
+	"github.com/google/subcommands"
+	"go.fuchsia.dev/tools/botanist/target"
+	"go.fuchsia.dev/tools/build"
+	"go.fuchsia.dev/tools/logger"
+)
+
+// QEMUBinPrefix is the prefix of the QEMU binary name, which is of the form
+// qemu-system-<QEMU arch suffix>.
+const qemuBinPrefix = "qemu-system"
+
+// QEMUCommand is a Command implementation for running the testing workflow on an emulated
+// target within QEMU.
+type QEMUCommand struct {
+	// ImageManifest is the path an image manifest specifying a QEMU kernel, a zircon
+	// kernel, and block image to back QEMU storage.
+	imageManifest string
+
+	// QEMUBinDir is a path to a directory of QEMU binaries.
+	qemuBinDir string
+
+	// MinFSImage is a path to a minFS image to be mounted on target, and to where test
+	// results will be written.
+	minFSImage string
+
+	// MinFSBlkDevPCIAddr is a minFS block device PCI address.
+	minFSBlkDevPCIAddr string
+
+	// TargetArch is the target architecture to be emulated within QEMU
+	targetArch string
+
+	// EnableKVM dictates whether to enable KVM.
+	enableKVM bool
+
+	// CPU is the number of processors to emulate.
+	cpu int
+
+	// Memory is the amount of memory (in MB) to provide.
+	memory int
+}
+
+func (*QEMUCommand) Name() string {
+	return "qemu"
+}
+
+func (*QEMUCommand) Usage() string {
+	return "qemu [flags...] [kernel command-line arguments...]\n\nflags:\n"
+}
+
+func (*QEMUCommand) Synopsis() string {
+	return "boots a QEMU device with a MinFS image as a block device."
+}
+
+func (cmd *QEMUCommand) SetFlags(f *flag.FlagSet) {
+	f.StringVar(&cmd.imageManifest, "images", "", "path to an image manifest")
+	f.StringVar(&cmd.qemuBinDir, "qemu-dir", "", "")
+	f.StringVar(&cmd.minFSImage, "minfs", "", "path to minFS image")
+	f.StringVar(&cmd.minFSBlkDevPCIAddr, "pci-addr", "06.0", "minFS block device PCI address")
+	f.StringVar(&cmd.targetArch, "arch", "", "target architecture (x64 or arm64)")
+	f.BoolVar(&cmd.enableKVM, "use-kvm", false, "whether to enable KVM")
+	f.IntVar(&cmd.cpu, "cpu", 4, "number of processors to emulate")
+	f.IntVar(&cmd.memory, "memory", 4096, "amount of memory (in MB) to provide")
+}
+
+func (cmd *QEMUCommand) execute(ctx context.Context, cmdlineArgs []string) error {
+	if cmd.qemuBinDir == "" {
+		return fmt.Errorf("-qemu-dir must be set")
+	}
+
+	imgs, err := build.LoadImages(cmd.imageManifest)
+	if err != nil {
+		return err
+	}
+
+	// TODO: pass this directly from a file.
+	config := target.QEMUConfig{
+		CPU:            cmd.cpu,
+		Memory:         cmd.memory,
+		Path:           cmd.qemuBinDir,
+		Target:         cmd.targetArch,
+		KVM:            cmd.enableKVM,
+		UserNetworking: true,
+	}
+	if cmd.minFSImage != "" {
+		config.MinFS = &target.MinFS{
+			Image:      cmd.minFSImage,
+			PCIAddress: cmd.minFSBlkDevPCIAddr,
+		}
+	}
+
+	t := target.NewQEMUTarget(config, target.Options{})
+	if err := t.Start(ctx, imgs, cmdlineArgs); err != nil {
+		return err
+	}
+
+	return t.Wait(ctx)
+}
+
+func (cmd *QEMUCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
+	if err := cmd.execute(ctx, f.Args()); err != nil {
+		logger.Errorf(ctx, "%v\n", err)
+		return subcommands.ExitFailure
+	}
+	return subcommands.ExitSuccess
+}
diff --git a/botanist/cmd/run.go b/botanist/cmd/run.go
new file mode 100644
index 0000000..c88134f
--- /dev/null
+++ b/botanist/cmd/run.go
@@ -0,0 +1,387 @@
+// Copyright 2018 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 main
+
+import (
+	"context"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net"
+	"os"
+	"path/filepath"
+	"sync"
+	"time"
+
+	"go.fuchsia.dev/tools/botanist"
+	"go.fuchsia.dev/tools/botanist/target"
+	"go.fuchsia.dev/tools/build"
+	"go.fuchsia.dev/tools/command"
+	"go.fuchsia.dev/tools/logger"
+	"go.fuchsia.dev/tools/runner"
+	"go.fuchsia.dev/tools/sshutil"
+
+	"github.com/google/subcommands"
+)
+
+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)
+
+	// Serial returns the serial device associated with the target for serial i/o.
+	Serial() io.ReadWriteCloser
+
+	// 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 {
+	// 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
+
+	// Netboot tells botanist to netboot (and not to pave).
+	netboot bool
+
+	// ZirconArgs are kernel command-line arguments to pass on boot.
+	zirconArgs command.StringsFlag
+
+	// Timeout is the duration allowed for the command to finish execution.
+	timeout time.Duration
+
+	// CmdStdout is the file to which the command's stdout will be redirected.
+	cmdStdout string
+
+	// CmdStderr is the file to which the command's stderr will be redirected.
+	cmdStderr string
+
+	// SysloggerFile, if nonempty, is the file to where the system's logs will be written.
+	syslogFile string
+
+	// SshKey is the path to a private SSH user key.
+	sshKey string
+
+	// SerialLogFile, if nonempty, is the file where the system's serial logs will be written.
+	serialLogFile string
+}
+
+func (*RunCommand) Name() string {
+	return "run"
+}
+
+func (*RunCommand) Usage() string {
+	return `
+botanist run [flags...] [command...]
+
+flags:
+`
+}
+
+func (*RunCommand) Synopsis() string {
+	return "boots a device and runs a local command"
+}
+
+func (r *RunCommand) SetFlags(f *flag.FlagSet) {
+	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.Var(&r.zirconArgs, "zircon-args", "kernel command-line arguments")
+	f.DurationVar(&r.timeout, "timeout", 10*time.Minute, "duration allowed for the command to finish execution.")
+	f.StringVar(&r.cmdStdout, "stdout", "", "file to redirect the command's stdout into; if unspecified, it will be redirected to the process' stdout")
+	f.StringVar(&r.cmdStderr, "stderr", "", "file to redirect the command's stderr into; if unspecified, it will be redirected to the process' stderr")
+	f.StringVar(&r.syslogFile, "syslog", "", "file to write the systems logs to")
+	f.StringVar(&r.sshKey, "ssh", "", "file containing a private SSH user key; if not provided, a private key will be generated.")
+	f.StringVar(&r.serialLogFile, "serial-log", "", "file to write the serial logs to.")
+}
+
+func (r *RunCommand) runCmd(ctx context.Context, args []string, t Target) error {
+	nodename := t.Nodename()
+	ip, err := t.IPv4Addr()
+	if err == nil {
+		logger.Infof(ctx, "IPv4 address of %s found: %s", nodename, ip)
+	} else {
+		logger.Errorf(ctx, "could not resolve IPv4 address of %s: %v", nodename, err)
+	}
+
+	env := append(
+		os.Environ(),
+		fmt.Sprintf("FUCHSIA_NODENAME=%s", nodename),
+		fmt.Sprintf("FUCHSIA_IPV4_ADDR=%v", ip),
+		fmt.Sprintf("FUCHSIA_SSH_KEY=%s", t.SSHKey()),
+	)
+
+	ctx, cancel := context.WithTimeout(ctx, r.timeout)
+	defer cancel()
+
+	stdout := os.Stdout
+	if r.cmdStdout != "" {
+		f, err := os.Create(r.cmdStdout)
+		if err != nil {
+			return err
+		}
+		defer f.Close()
+		stdout = f
+	}
+	stderr := os.Stderr
+	if r.cmdStderr != "" {
+		f, err := os.Create(r.cmdStderr)
+		if err != nil {
+			return err
+		}
+		defer f.Close()
+		stderr = f
+	}
+
+	runner := runner.SubprocessRunner{
+		Env: env,
+	}
+	if err := runner.Run(ctx, args, stdout, stderr); err != nil {
+		if ctx.Err() != nil {
+			return fmt.Errorf("command timed out after %v", r.timeout)
+		}
+		return err
+	}
+	return nil
+}
+
+func getIndexedFilename(filename string, index int) string {
+	ext := filepath.Ext(filename)
+	name := filename[:len(filename)-len(ext)]
+	return fmt.Sprintf("%s-%d%s", name, index, ext)
+}
+
+func (r *RunCommand) execute(ctx context.Context, args []string) error {
+	imgs, err := build.LoadImages(r.imageManifests...)
+	if err != nil {
+		return fmt.Errorf("failed to load images: %v", err)
+	}
+
+	opts := target.Options{
+		Netboot: r.netboot,
+		SSHKey:  r.sshKey,
+	}
+
+	data, err := ioutil.ReadFile(r.configFile)
+	if err != nil {
+		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(ctx, obj, opts)
+		if err != nil {
+			return err
+		}
+		targets = append(targets, t)
+	}
+
+	ctx, cancel := context.WithCancel(ctx)
+	defer cancel()
+	errs := make(chan error)
+	var wg sync.WaitGroup
+
+	for i, t := range targets {
+		defer func() {
+			logger.Debugf(ctx, "stopping or rebooting the node %q\n", t.Nodename())
+			if err := t.Stop(ctx); err == target.ErrUnimplemented {
+				t.Restart(ctx)
+			}
+		}()
+
+		var syslogFile, serialLogFile string
+		if r.syslogFile != "" {
+			syslogFile = r.syslogFile
+			if len(targets) > 1 {
+				syslogFile = getIndexedFilename(r.syslogFile, i)
+			}
+		}
+		if r.serialLogFile != "" {
+			serialLogFile = r.serialLogFile
+			if len(targets) > 1 {
+				serialLogFile = getIndexedFilename(r.serialLogFile, i)
+			}
+		}
+
+		wg.Add(1)
+		go func(t Target, syslogFile string, serialLogFile string) {
+			defer wg.Done()
+			var syslog io.Writer
+			if syslogFile != "" {
+				syslog, err := os.Create(syslogFile)
+				if err != nil {
+					errs <- err
+					return
+				}
+				defer syslog.Close()
+			}
+
+			zirconArgs := r.zirconArgs
+			if t.Serial() != nil {
+				if serialLogFile != "" {
+					serialLog, err := os.Create(serialLogFile)
+					if err != nil {
+						errs <- err
+						return
+					}
+					defer serialLog.Close()
+
+					// Here we invoke the `dlog` command over serial to tail the existing log buffer into the
+					// output file.  This should give us everything since Zedboot boot, and new messages should
+					// be written to directly to the serial port without needing to tail with `dlog -f`.
+					if _, err = io.WriteString(t.Serial(), "\ndlog\n"); err != nil {
+						logger.Errorf(ctx, "failed to tail zedboot dlog: %v", err)
+					}
+
+					go func(t Target) {
+						for {
+							_, err := io.Copy(serialLog, t.Serial())
+							if err != nil && err != io.EOF {
+								logger.Errorf(ctx, "failed to write serial log: %v", err)
+								return
+							}
+						}
+					}(t)
+					zirconArgs = append(zirconArgs, "kernel.bypass-debuglog=true")
+				}
+				// Modify the zirconArgs passed to the kernel on boot to enable serial on x64.
+				// arm64 devices should already be enabling kernel.serial at compile time.
+				zirconArgs = append(zirconArgs, "kernel.serial=legacy")
+			}
+
+			if err := t.Start(ctx, imgs, zirconArgs); err != nil {
+				errs <- err
+				return
+			}
+			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(t.SSHKey())
+				if err != nil {
+					errs <- err
+					return
+				}
+				config, err := sshutil.DefaultSSHConfig(p)
+				if err != nil {
+					errs <- err
+					return
+				}
+				client, err := sshutil.ConnectToNode(ctx, nodename, config)
+				if err != nil {
+					errs <- err
+					return
+				}
+				syslogger, err := botanist.NewSyslogger(client)
+				if err != nil {
+					errs <- err
+					return
+				}
+				go func() {
+					syslogger.Stream(ctx, syslog)
+					syslogger.Close()
+				}()
+			}
+		}(t, syslogFile, serialLogFile)
+	}
+	// Wait for all targets to finish starting.
+	wg.Wait()
+	// We can close the channel on the receiver end since we wait for all target goroutines to finish.
+	close(errs)
+	err, ok := <-errs
+	if ok {
+		return err
+	}
+
+	// Since errs was closed, reset it to reuse it again.
+	errs = make(chan error)
+
+	go func() {
+		// Target doesn't matter for multi-device host tests. Just use first one.
+		errs <- r.runCmd(ctx, args, targets[0])
+	}()
+
+	for _, t := range targets {
+		go func(t Target) {
+			if err := t.Wait(ctx); err != nil && err != target.ErrUnimplemented {
+				errs <- err
+			}
+		}(t)
+	}
+
+	select {
+	case err := <-errs:
+		return err
+	case <-ctx.Done():
+	}
+	return nil
+}
+
+func (r *RunCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
+	args := f.Args()
+	if len(args) == 0 {
+		return subcommands.ExitUsageError
+	}
+	if err := r.execute(ctx, args); err != nil {
+		logger.Errorf(ctx, "%v\n", err)
+		return subcommands.ExitFailure
+	}
+	return subcommands.ExitSuccess
+}
+
+func DeriveTarget(ctx context.Context, 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(ctx, cfg, opts)
+		return t, err
+	default:
+		return nil, fmt.Errorf("unknown type found: %q", x.Type)
+	}
+}
diff --git a/botanist/cmd/zedboot.go b/botanist/cmd/zedboot.go
new file mode 100644
index 0000000..a5bfbae
--- /dev/null
+++ b/botanist/cmd/zedboot.go
@@ -0,0 +1,162 @@
+// Copyright 2018 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 main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"net"
+	"strings"
+	"time"
+
+	"go.fuchsia.dev/tools/botanist/target"
+	"go.fuchsia.dev/tools/build"
+	"go.fuchsia.dev/tools/command"
+	"go.fuchsia.dev/tools/logger"
+	"go.fuchsia.dev/tools/netutil"
+	"go.fuchsia.dev/tools/runtests"
+
+	"github.com/google/subcommands"
+)
+
+// ZedbootCommand is a Command implementation for running the testing workflow on a device
+// that boots with Zedboot.
+type ZedbootCommand struct {
+	// ImageManifests is a list of paths to image manifests (e.g., images.json)
+	imageManifests command.StringsFlag
+
+	// Netboot tells botanist to netboot (and not to pave).
+	netboot bool
+
+	// ConfigFile is the path to a file containing the target config.
+	configFile string
+
+	// TestResultsDir is the directory on target to where test results will be written.
+	testResultsDir string
+
+	// SummaryFilename is the name of the test summary JSON file to be written to
+	// testResultsDir.
+	summaryFilename string
+
+	// FilePollInterval is the duration waited between checking for test summary file
+	// on the target to be written.
+	filePollInterval time.Duration
+
+	// OutputArchive is a path on host to where the tarball containing the test results
+	// will be output.
+	outputArchive string
+
+	// CmdlineFile is the path to a file of additional kernel command-line arguments.
+	cmdlineFile string
+}
+
+func (*ZedbootCommand) Name() string {
+	return "zedboot"
+}
+
+func (*ZedbootCommand) Usage() string {
+	return "zedboot [flags...] [kernel command-line arguments...]\n\nflags:\n"
+}
+
+func (*ZedbootCommand) Synopsis() string {
+	return "boots a Zedboot device and collects test results"
+}
+
+func (cmd *ZedbootCommand) SetFlags(f *flag.FlagSet) {
+	f.Var(&cmd.imageManifests, "images", "paths to image manifests")
+	f.BoolVar(&cmd.netboot, "netboot", false, "if set, botanist will not pave; but will netboot instead")
+	f.StringVar(&cmd.testResultsDir, "results-dir", "/test", "path on target to where test results will be written")
+	f.StringVar(&cmd.outputArchive, "out", "output.tar", "path on host to output tarball of test results")
+	f.StringVar(&cmd.summaryFilename, "summary-name", runtests.TestSummaryFilename, "name of the file in the test directory")
+	f.DurationVar(&cmd.filePollInterval, "poll-interval", 1*time.Minute, "time between checking for summary.json on the target")
+	f.StringVar(&cmd.configFile, "config", "/etc/botanist/config.json", "path to file of device config")
+	f.StringVar(&cmd.cmdlineFile, "cmdline-file", "", "path to a file containing additional kernel command-line arguments")
+}
+
+func (cmd *ZedbootCommand) runTests(ctx context.Context, imgs build.Images, addr *net.UDPAddr, cmdlineArgs []string) error {
+	logger.Debugf(ctx, "waiting for %q\n", cmd.summaryFilename)
+	return runtests.PollForSummary(ctx, addr, cmd.summaryFilename, cmd.testResultsDir, cmd.outputArchive, cmd.filePollInterval)
+}
+
+func (cmd *ZedbootCommand) execute(ctx context.Context, cmdlineArgs []string) error {
+	configs, err := target.LoadDeviceConfigs(cmd.configFile)
+
+	if err != nil {
+		return fmt.Errorf("failed to load target config file %q", cmd.configFile)
+	}
+	opts := target.Options{
+		Netboot: cmd.netboot,
+	}
+
+	var devices []*target.DeviceTarget
+	for _, config := range configs {
+		device, err := target.NewDeviceTarget(ctx, 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...)
+	if err != nil {
+		return err
+	}
+
+	ctx, cancel := context.WithCancel(ctx)
+	defer cancel()
+	errs := make(chan error)
+
+	for _, device := range devices {
+		go func(device *target.DeviceTarget) {
+			if err := device.Start(ctx, imgs, cmdlineArgs); err != nil {
+				errs <- err
+			}
+		}(device)
+	}
+	go func() {
+		addr, err := netutil.GetNodeAddress(ctx, devices[0].Nodename(), false)
+		if err != nil {
+			errs <- err
+			return
+		}
+		errs <- cmd.runTests(ctx, imgs, addr, cmdlineArgs)
+	}()
+
+	select {
+	case err := <-errs:
+		return err
+	case <-ctx.Done():
+	}
+
+	return nil
+}
+
+func (cmd *ZedbootCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
+	configFlag := f.Lookup("config")
+	logger.Debugf(ctx, "config flag: %v\n", configFlag.Value)
+
+	// Aggregate command-line arguments.
+	cmdlineArgs := f.Args()
+	if cmd.cmdlineFile != "" {
+		args, err := ioutil.ReadFile(cmd.cmdlineFile)
+		if err != nil {
+			logger.Errorf(ctx, "failed to read command-line args file %q: %v\n", cmd.cmdlineFile, err)
+			return subcommands.ExitFailure
+		}
+		cmdlineArgs = append(cmdlineArgs, strings.Split(string(args), "\n")...)
+	}
+
+	if err := cmd.execute(ctx, cmdlineArgs); err != nil {
+		logger.Errorf(ctx, "%v\n", err)
+		return subcommands.ExitFailure
+	}
+
+	return subcommands.ExitSuccess
+}
diff --git a/botanist/power/amt/amt.go b/botanist/power/amt/amt.go
new file mode 100644
index 0000000..016fe2f
--- /dev/null
+++ b/botanist/power/amt/amt.go
@@ -0,0 +1,98 @@
+// Copyright 2018 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 amt
+
+import (
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/google/uuid"
+	"go.fuchsia.dev/tools/digest"
+)
+
+const (
+	// https://software.intel.com/en-us/node/645995
+	PowerStateOn             = 2
+	PowerStateLightSleep     = 3
+	PowerStateDeepSleep      = 4
+	PowerStatePowerCycleSoft = 5
+	PowerStateOffHard        = 6
+	PowerStateHibernate      = 7
+	PowerStateOffSoft        = 8
+	PowerStatePowerCycleHard = 9
+	PowerStateMasterBusReset = 10
+)
+
+// Printf string with placeholders for destination uri, message uuid
+const payloadTmpl = `
+<?xml version="1.0" encoding="UTF-8"?>
+<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:wsman="http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd" xmlns:pms="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_PowerManagementService">
+<s:Header>
+  <wsa:Action s:mustUnderstand="true">http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_PowerManagementService/RequestPowerStateChange</wsa:Action>
+  <wsa:To s:mustUnderstand="true">%s</wsa:To>
+  <wsman:ResourceURI s:mustUnderstand="true">http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_PowerManagementService</wsman:ResourceURI>
+  <wsa:MessageID s:mustUnderstand="true">uuid:%s</wsa:MessageID>
+  <wsa:ReplyTo><wsa:Address>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address></wsa:ReplyTo>
+  <wsman:SelectorSet>
+    <wsman:Selector Name="Name">Intel(r) AMT Power Management Service</wsman:Selector>
+    <wsman:Selector Name="SystemName">Intel(r) AMT</wsman:Selector>
+    <wsman:Selector Name="CreationClassName">CIM_PowerManagementService</wsman:Selector>
+    <wsman:Selector Name="SystemCreationClassName">CIM_ComputerSystem</wsman:Selector>
+  </wsman:SelectorSet>
+</s:Header>
+<s:Body>
+  <pms:RequestPowerStateChange_INPUT>
+    <pms:PowerState>%d</pms:PowerState>
+    <pms:ManagedElement>
+      <wsa:Address>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address>
+      <wsa:ReferenceParameters>
+        <wsman:ResourceURI>http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ComputerSystem</wsman:ResourceURI>
+        <wsman:SelectorSet>
+          <wsman:Selector Name="Name">ManagedSystem</wsman:Selector>
+          <wsman:Selector Name="CreationClassName">CIM_ComputerSystem</wsman:Selector>
+        </wsman:SelectorSet>
+      </wsa:ReferenceParameters>
+    </pms:ManagedElement>
+  </pms:RequestPowerStateChange_INPUT>
+</s:Body>
+</s:Envelope>
+`
+
+// Reboot sends a Master Bus Reset to an AMT compatible device at host:port.
+func Reboot(host, username, password string) error {
+	// AMT over http always uses port 16992
+	uri, err := url.Parse(fmt.Sprintf("http://%s:16992/wsman", host))
+	if err != nil {
+		return err
+	}
+	// Generate MessageID
+	uuid := uuid.New()
+	payload := fmt.Sprintf(payloadTmpl, uri.String(), uuid, PowerStatePowerCycleSoft)
+
+	t := digest.NewTransport(username, password)
+	req, err := http.NewRequest("POST", uri.String(), strings.NewReader(payload))
+	if err != nil {
+		return err
+	}
+	res, err := t.RoundTrip(req)
+	if err != nil {
+		return err
+	}
+	defer res.Body.Close()
+
+	body, err := ioutil.ReadAll(res.Body)
+	if err != nil {
+		return err
+	}
+	returnValue := string(strings.Split(string(body), "ReturnValue>")[1][0])
+	if returnValue != "0" {
+		return fmt.Errorf("amt reboot ReturnValue=%s", returnValue)
+	}
+
+	return nil
+}
diff --git a/botanist/power/power.go b/botanist/power/power.go
new file mode 100644
index 0000000..2f2fc89
--- /dev/null
+++ b/botanist/power/power.go
@@ -0,0 +1,143 @@
+// Copyright 2018 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 power
+
+import (
+	"context"
+	"io"
+
+	"go.fuchsia.dev/tools/botanist/power/amt"
+	"go.fuchsia.dev/tools/botanist/power/wol"
+	"go.fuchsia.dev/tools/logger"
+	"go.fuchsia.dev/tools/sshutil"
+
+	"golang.org/x/crypto/ssh"
+)
+
+// TODO(IN-977) Clean this up per suggestions in go/fxr/251550
+
+const (
+	// Controller machines use 192.168.42.1/24 for swarming bots
+	// This will broadcast to that entire subnet.
+	botBroadcastAddr = "192.168.42.255:9"
+
+	// Controller machines have multiple interfaces, currently
+	// 'eno2' is used for swarming bots.
+	botInterface = "eno2"
+)
+
+// Client represents a power management configuration for a particular device.
+type Client struct {
+	// Type is the type of manager to use.
+	Type string `json:"type"`
+
+	// Host is the network hostname of the manager, e.g. fuchsia-tests-pdu-001.
+	Host string `json:"host"`
+
+	// HostHwAddr is the ethernet MAC address of the manager,  e.g. 10:10:10:10:10:10
+	HostMACAddr string `json:"host_mac_addr"`
+
+	// Username is the username used to log in to the manager.
+	Username string `json:"username"`
+
+	// Password is the password used to log in to the manager..
+	Password string `json:"password"`
+}
+
+type Rebooter interface {
+	reboot() error
+}
+
+type SshRebooter struct {
+	nodename string
+	signers  []ssh.Signer
+}
+
+type SerialRebooter struct {
+	serial io.ReadWriter
+}
+
+// RebootDevice attempts to reboot the specified device into recovery, and
+// additionally uses the given configuration to reboot the device if specified.
+func (c Client) RebootDevice(signers []ssh.Signer, nodename string, serial io.ReadWriter) error {
+	var rebooter Rebooter
+	if serial != nil {
+		rebooter = NewSerialRebooter(serial)
+	} else {
+		rebooter = NewSSHRebooter(nodename, signers)
+	}
+	// Always attempt to soft reboot the device to recovery.
+	err := rebooter.reboot()
+	if err != nil {
+		logger.Warningf(context.Background(), "soft reboot failed: %v", err)
+	}
+
+	// Hard reboot the device if specified in the config.
+	switch c.Type {
+	case "amt":
+		return amt.Reboot(c.Host, c.Username, c.Password)
+	case "wol":
+		return wol.Reboot(botBroadcastAddr, botInterface, c.HostMACAddr)
+	default:
+		return err
+	}
+}
+
+func NewSerialRebooter(serial io.ReadWriter) *SerialRebooter {
+	return &SerialRebooter{
+		serial: serial,
+	}
+}
+
+func NewSSHRebooter(nodename string, signers []ssh.Signer) *SshRebooter {
+	return &SshRebooter{
+		nodename: nodename,
+		signers:  signers,
+	}
+}
+
+func (s *SerialRebooter) reboot() error {
+	_, err := io.WriteString(s.serial, "\ndm reboot-recovery\n")
+	return err
+}
+
+func (s *SshRebooter) reboot() error {
+	config, err := sshutil.DefaultSSHConfigFromSigners(s.signers...)
+	if err != nil {
+		return err
+	}
+
+	ctx := context.Background()
+	client, err := sshutil.ConnectToNode(ctx, s.nodename, config)
+	if err != nil {
+		return err
+	}
+
+	defer client.Close()
+
+	session, err := client.NewSession()
+	if err != nil {
+		return err
+	}
+
+	defer session.Close()
+
+	// Invoke `dm reboot-recovery` with a 2 second delay in the background, then exit the SSH shell.
+	// This prevents the SSH connection from hanging waiting for `dm reboot-recovery` to return.
+	err = session.Start("{ sleep 2; dm reboot-recovery; } >/dev/null & exit")
+	if err != nil {
+		return err
+	}
+
+	done := make(chan error)
+	go func() {
+		done <- session.Wait()
+	}()
+
+	select {
+	case err := <-done:
+		return err
+	}
+}
diff --git a/botanist/power/wol/wol.go b/botanist/power/wol/wol.go
new file mode 100644
index 0000000..f05d503
--- /dev/null
+++ b/botanist/power/wol/wol.go
@@ -0,0 +1,107 @@
+// Copyright 2018 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 wol
+
+import (
+	"errors"
+	"fmt"
+	"net"
+	"regexp"
+	"time"
+)
+
+var (
+	macAddrRegex = regexp.MustCompile(`(?i)^([0-9A-F]{2}[:-]){5}([0-9A-F]{2})$`)
+	// Magic Packet header is 0xFF repeated 6 times.
+	magicPacketHeader = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}
+)
+
+const (
+	magicPacketLength = 102
+)
+
+// Reboot sends a WakeOnLAN magic packet {magicPacketHeader + macAddr x 16}
+// using the specified network interface to the broadcast address
+func Reboot(broadcastAddr, interfaceName, macAddr string) error {
+	if !macAddrRegex.Match([]byte(macAddr)) {
+		return fmt.Errorf("Invalid MAC: %s", macAddr)
+	}
+
+	remoteHwAddr, err := net.ParseMAC(macAddr)
+	if err != nil {
+		return err
+	}
+
+	localAddr, err := getUDPAddrFromIFace(interfaceName)
+	if err != nil {
+		return err
+	}
+	remoteAddr, err := net.ResolveUDPAddr("udp", broadcastAddr)
+	if err != nil {
+		return err
+	}
+
+	return sendMagicPacket(localAddr, remoteAddr, remoteHwAddr)
+}
+
+func getUDPAddrFromIFace(ifaceName string) (*net.UDPAddr, error) {
+	iface, err := net.InterfaceByName(ifaceName)
+	if err != nil {
+		return nil, err
+	}
+
+	addrs, err := iface.Addrs()
+	if err != nil {
+		return nil, err
+	}
+
+	for _, addr := range addrs {
+		if ipAddr, ok := addr.(*net.IPNet); ok {
+			// Need an IPv4, non-loopback address to send on
+			if !ipAddr.IP.IsLoopback() && ipAddr.IP.To4() != nil {
+				return &net.UDPAddr{
+					IP: ipAddr.IP,
+				}, nil
+			}
+		}
+	}
+
+	return nil, errors.New("No UDPAddr found on interface")
+}
+
+func sendMagicPacket(localAddr, remoteAddr *net.UDPAddr, remoteHwAddr net.HardwareAddr) error {
+	packet := magicPacketHeader
+	for i := 0; i < 16; i++ {
+		packet = append(packet, remoteHwAddr...)
+	}
+
+	if len(packet) != magicPacketLength {
+		return fmt.Errorf("Wake-On-LAN packet incorrect length: %d", len(packet))
+	}
+
+	conn, err := net.DialUDP("udp", localAddr, remoteAddr)
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+
+	// Attempt to send the Magic Packet TEN times in a row.  The UDP packet sometimes
+	// does not make it to the DUT and this is the simplest way to increase the chance
+	// the device reboots.
+	for i := 0; i < 10; i++ {
+		n, err := conn.Write(packet)
+
+		if n != magicPacketLength {
+			return errors.New("Failed to send correct Wake-On-LAN packet length")
+		}
+
+		if err != nil {
+			return err
+		}
+		time.Sleep(1 * time.Second)
+	}
+
+	return nil
+}
diff --git a/botanist/target/device.go b/botanist/target/device.go
new file mode 100644
index 0000000..2246503
--- /dev/null
+++ b/botanist/target/device.go
@@ -0,0 +1,222 @@
+// 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 (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net"
+	"time"
+
+	"go.fuchsia.dev/tools/botanist"
+	"go.fuchsia.dev/tools/botanist/power"
+	"go.fuchsia.dev/tools/build"
+	"go.fuchsia.dev/tools/logger"
+	"go.fuchsia.dev/tools/netboot"
+	"go.fuchsia.dev/tools/netutil"
+	"go.fuchsia.dev/tools/serial"
+
+	"golang.org/x/crypto/ssh"
+)
+
+const (
+	// The duration we allow for the netstack to come up when booting.
+	netstackTimeout = 90 * time.Second
+)
+
+// DeviceConfig contains the static properties of a target device.
+type DeviceConfig struct {
+	// Network is the network properties of the target.
+	Network NetworkProperties `json:"network"`
+
+	// Power is the attached power management configuration.
+	Power *power.Client `json:"power,omitempty"`
+
+	// SSHKeys are the default system keys to be used with the device.
+	SSHKeys []string `json:"keys,omitempty"`
+
+	// Serial is the path to the device file for serial i/o.
+	Serial string `json:"serial,omitempty"`
+}
+
+// NetworkProperties are the static network properties of a target.
+type NetworkProperties struct {
+	// Nodename is the hostname of the device that we want to boot on.
+	Nodename string `json:"nodename"`
+
+	// IPv4Addr is the IPv4 address, if statically given. If not provided, it may be
+	// resolved via the netstack's MDNS server.
+	IPv4Addr string `json:"ipv4"`
+}
+
+// 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 {
+		return nil, fmt.Errorf("failed to read device properties file %q", path)
+	}
+
+	var configs []DeviceConfig
+	if err := json.Unmarshal(data, &configs); err != nil {
+		return nil, fmt.Errorf("failed to unmarshal configs: %v", err)
+	}
+	return configs, nil
+}
+
+// DeviceTarget represents a target device.
+type DeviceTarget struct {
+	config  DeviceConfig
+	opts    Options
+	signers []ssh.Signer
+	serial  io.ReadWriteCloser
+}
+
+// NewDeviceTarget returns a new device target with a given configuration.
+func NewDeviceTarget(ctx context.Context, 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 != "" {
+		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)
+	}
+	var s io.ReadWriteCloser
+	if config.Serial != "" {
+		s, err = serial.Open(config.Serial)
+		if err != nil {
+			// TODO(IN-????): This should be returned as an error, but we don't want to fail any
+			// test runs for misconfigured serial until it is actually required to complete certain
+			// tasks.
+			logger.Errorf(ctx, "unable to open %s: %v", config.Serial, err)
+		}
+	}
+	return &DeviceTarget{
+		config:  config,
+		opts:    opts,
+		signers: signers,
+		serial:  s,
+	}, nil
+}
+
+// Nodename returns the name of the node.
+func (t *DeviceTarget) Nodename() string {
+	return t.config.Network.Nodename
+}
+
+// IPv4Addr returns the IPv4 address of the node. If not provided in the config, then it
+// will be resolved against the target-side MDNS server.
+func (t *DeviceTarget) IPv4Addr() (net.IP, error) {
+	if t.config.Network.IPv4Addr != "" {
+		return net.ParseIP(t.config.Network.IPv4Addr), nil
+	}
+	return botanist.ResolveIPv4(context.Background(), t.Nodename(), netstackTimeout)
+}
+
+// Serial returns the serial device associated with the target for serial i/o.
+func (t *DeviceTarget) Serial() io.ReadWriteCloser {
+	return t.serial
+}
+
+// SSHKey returns the private SSH key path associated with the authorized key to be paved.
+func (t *DeviceTarget) SSHKey() string {
+	return t.config.SSHKeys[0]
+}
+
+// Start starts the device target.
+func (t *DeviceTarget) Start(ctx context.Context, images build.Images, args []string) error {
+	// 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 := netutil.GetNodeAddress(ctx, t.Nodename(), false)
+	if err != nil {
+		return err
+	}
+
+	// Mexec Zedboot
+	err = botanist.BootZedbootShim(ctx, addr, images)
+	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.serial != nil {
+		defer t.serial.Close()
+	}
+	if t.config.Power != nil {
+		if err := t.config.Power.RebootDevice(t.signers, t.Nodename(), t.serial); err != nil {
+			return fmt.Errorf("failed to reboot the device: %v", err)
+		}
+	}
+	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")
+	}
+	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
new file mode 100644
index 0000000..30687ab
--- /dev/null
+++ b/botanist/target/device_test.go
@@ -0,0 +1,54 @@
+// 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 (
+	"io/ioutil"
+	"os"
+	"testing"
+)
+
+func TestLoadConfigs(t *testing.T) {
+	tests := []struct {
+		name        string
+		jsonStr     string
+		expectedLen int
+		expectErr   bool
+	}{
+		// Valid configs.
+		{"ValidConfig", `[{"nodename":"upper-drank-wick-creek"},{"nodename":"siren-swoop-wick-hasty"}]`, 2, false},
+		// Invalid configs.
+		{"InvalidConfig", `{{"nodename":"upper-drank-wick-creek"},{"nodename":"siren-swoop-wick-hasty"}}`, 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)
+		}
+
+		configs, err := LoadDeviceConfigs(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(configs) != test.expectedLen {
+			t.Errorf("Test%v: Expected %d nodes; found %d", test.name, test.expectedLen, len(configs))
+		}
+
+		if err := tmpfile.Close(); err != nil {
+			t.Fatal(err)
+		}
+	}
+}
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..8440c0d
--- /dev/null
+++ b/botanist/target/options.go
@@ -0,0 +1,18 @@
+// 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
+}
diff --git a/botanist/target/qemu.go b/botanist/target/qemu.go
new file mode 100644
index 0000000..f4ddaad
--- /dev/null
+++ b/botanist/target/qemu.go
@@ -0,0 +1,285 @@
+// 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 (
+	"context"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"log"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+
+	"go.fuchsia.dev/tools/build"
+	"go.fuchsia.dev/tools/qemu"
+)
+
+const (
+	// 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.
+var qemuTargetMapping = map[string]string{
+	"x64":   qemu.TargetX86_64,
+	"arm64": qemu.TargetAArch64,
+}
+
+// MinFS is the configuration for the MinFS filesystem image.
+type MinFS struct {
+	// Image is the path to the filesystem image.
+	Image string `json:"image"`
+
+	// PCIAddress is the PCI address to map the device at.
+	PCIAddress string `json:"pci_address"`
+}
+
+// QEMUConfig is a QEMU configuration.
+type QEMUConfig struct {
+	// Path is a path to a directory that contains QEMU system binary.
+	Path string `json:"path"`
+
+	// Target is the QEMU target to emulate.
+	Target string `json:"target"`
+
+	// CPU is the number of processors to emulate.
+	CPU int `json:"cpu"`
+
+	// Memory is the amount of memory (in MB) to provide.
+	Memory int `json:"memory"`
+
+	// KVM specifies whether to enable hardware virtualization acceleration.
+	KVM bool `json:"kvm"`
+
+	// Whether User networking is enabled; if false, a Tap interface will be used.
+	UserNetworking bool `json:"user_networking"`
+
+	// MinFS is the filesystem to mount as a device.
+	MinFS *MinFS `json:"minfs,omitempty"`
+}
+
+// QEMUTarget is a QEMU target.
+type QEMUTarget struct {
+	config QEMUConfig
+	opts   Options
+
+	c chan error
+
+	cmd    *exec.Cmd
+	status error
+}
+
+// NewQEMUTarget returns a new QEMU target with a given configuration.
+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
+}
+
+// Serial returns the serial device associated with the target for serial i/o.
+func (t *QEMUTarget) Serial() io.ReadWriteCloser {
+	return 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 (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", t.config.Target)
+	}
+
+	if t.config.Path == "" {
+		return fmt.Errorf("directory must be set")
+	}
+	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)
+	}
+
+	qemuKernel := images.Get("qemu-kernel")
+	if qemuKernel == nil {
+		return fmt.Errorf("could not find qemu-kernel")
+	}
+	zirconA := images.Get("zircon-a")
+	if zirconA == nil {
+		return fmt.Errorf("could not find zircon-a")
+	}
+
+	var drives []qemu.Drive
+	if storageFull := images.Get("storage-full"); storageFull != nil {
+		drives = append(drives, qemu.Drive{
+			ID:   "maindisk",
+			File: storageFull.Path,
+		})
+	}
+	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(t.config.MinFS.Image)
+		if err != nil {
+			return err
+		}
+		// Swarming hard-links Isolate downloads with a cache and the very same
+		// cached minfs image will be used across multiple tasks. To ensure
+		// that it remains blank, we must break its link.
+		if err := overwriteFileWithCopy(file); err != nil {
+			return err
+		}
+		drives = append(drives, qemu.Drive{
+			ID:   "testdisk",
+			File: file,
+			Addr: t.config.MinFS.PCIAddress,
+		})
+	}
+
+	netdev := qemu.Netdev{
+		ID:  "net0",
+		MAC: defaultMACAddr,
+	}
+	if t.config.UserNetworking {
+		netdev.User = &qemu.NetdevUser{}
+	} else {
+		netdev.Tap = &qemu.NetdevTap{
+			Name: defaultInterfaceName,
+		}
+	}
+	networks := []qemu.Netdev{netdev}
+
+	config := qemu.Config{
+		Binary:   qemuSystem,
+		Target:   qemuTarget,
+		CPU:      t.config.CPU,
+		Memory:   t.config.Memory,
+		KVM:      t.config.KVM,
+		Kernel:   qemuKernel.Path,
+		Initrd:   zirconA.Path,
+		Drives:   drives,
+		Networks: networks,
+	}
+
+	// The system will halt on a kernel panic instead of rebooting.
+	args = append(args, "kernel.halt-on-panic=true")
+	// Print a message if `dm poweroff` times out.
+	args = append(args, "devmgr.suspend-timeout-debug=true")
+	// Do not print colors.
+	args = append(args, "TERM=dumb")
+	if t.config.Target == "x64" {
+		// Necessary to redirect to stdout.
+		args = append(args, "kernel.serial=legacy")
+	}
+
+	invocation, err := qemu.CreateInvocation(config, args)
+	if err != nil {
+		return err
+	}
+
+	// The QEMU command needs to be invoked within an empty directory, as QEMU
+	// will attempt to pick up files from its working directory, one notable
+	// culprit being multiboot.bin.  This can result in strange behavior.
+	workdir, err := ioutil.TempDir("", "qemu-working-dir")
+	if err != nil {
+		return err
+	}
+
+	t.cmd = &exec.Cmd{
+		Path:   invocation[0],
+		Args:   invocation,
+		Dir:    workdir,
+		Stdout: os.Stdout,
+		Stderr: os.Stderr,
+	}
+	log.Printf("QEMU invocation:\n%s", invocation)
+
+	if err := t.cmd.Start(); err != nil {
+		os.RemoveAll(workdir)
+		return fmt.Errorf("failed to start: %v", err)
+	}
+
+	// Ensure that the working directory when QEMU finishes whether the Wait
+	// method is invoked or not.
+	go func() {
+		defer os.RemoveAll(workdir)
+		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 (t *QEMUTarget) Stop(ctx context.Context) error {
+	return t.cmd.Process.Kill()
+}
+
+// Wait waits for the QEMU target to stop.
+func (t *QEMUTarget) Wait(ctx context.Context) error {
+	return <-t.c
+}
+
+func overwriteFileWithCopy(path string) error {
+	tmpfile, err := ioutil.TempFile(filepath.Dir(path), "botanist")
+	if err != nil {
+		return err
+	}
+	defer tmpfile.Close()
+	if err := copyFile(path, tmpfile.Name()); err != nil {
+		return err
+	}
+	return os.Rename(tmpfile.Name(), path)
+}
+
+func copyFile(src, dest string) error {
+	in, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+	info, err := in.Stat()
+	if err != nil {
+		return err
+	}
+	out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE, info.Mode().Perm())
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+	_, err = io.Copy(out, in)
+	return err
+}
diff --git a/digest/digest.go b/digest/digest.go
new file mode 100644
index 0000000..607135e
--- /dev/null
+++ b/digest/digest.go
@@ -0,0 +1,234 @@
+// Copyright 2013 M-Lab
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package digest
+
+import (
+	"crypto/md5"
+	"crypto/rand"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+)
+
+var (
+	ErrNilTransport      = errors.New("transport is nil")
+	ErrBadChallenge      = errors.New("challenge is bad")
+	ErrAlgNotImplemented = errors.New("algorithm not implemented")
+)
+
+// Transport is an implementation of http.RoundTripper that supports HTTP
+// digest authentication.
+type Transport struct {
+	Username  string
+	Password  string
+	Transport http.RoundTripper
+}
+
+// NewTransport creates a new digest transport using http.DefaultTransport.
+func NewTransport(username, password string) *Transport {
+	return &Transport{
+		Username:  username,
+		Password:  password,
+		Transport: http.DefaultTransport,
+	}
+}
+
+// RoundTrip makes a request expecting a 401 response that will require digest
+// authentication. It creates the credentials it needs and makes a follow-up
+// request.
+func (t *Transport) RoundTrip(r *http.Request) (*http.Response, error) {
+	if t.Transport == nil {
+		return nil, ErrNilTransport
+	}
+
+	body, err := r.GetBody()
+	if err != nil {
+		return nil, err
+	}
+	req, err := http.NewRequest(r.Method, r.URL.String(), body)
+	if err != nil {
+		return nil, err
+	}
+
+	req.Header = make(http.Header)
+	for k, s := range r.Header {
+		req.Header[k] = s
+	}
+
+	// Make a request to get the 401 that contains the challenge.
+	res, err := t.Transport.RoundTrip(r)
+	if err != nil || res.StatusCode != 401 {
+		return res, err
+	}
+	defer res.Body.Close()
+
+	chal := res.Header.Get("WWW-Authenticate")
+	c, err := parseChallenge(chal)
+	if err != nil {
+		return res, err
+	}
+
+	// Generate credentials based on the challenge.
+	cr := t.authenticate(req, c)
+	auth, err := cr.authorize()
+	if err != nil {
+		return res, err
+	}
+
+	// Make authenticated request.
+	req.Header.Set("Authorization", auth)
+	return t.Transport.RoundTrip(req)
+}
+
+type challenge struct {
+	realm     string
+	domain    string
+	nonce     string
+	opaque    string
+	stale     string
+	algorithm string
+	qop       string
+}
+
+func parseChallenge(input string) (*challenge, error) {
+	const ws = " \n\r\t"
+	const qs = `"`
+	s := strings.Trim(input, ws)
+	if !strings.HasPrefix(s, "Digest ") {
+		return nil, ErrBadChallenge
+	}
+	s = strings.Trim(s[7:], ws)
+	sl := strings.Split(s, ",")
+	c := &challenge{
+		algorithm: "MD5",
+	}
+	var r []string
+	for i := range sl {
+		r = strings.SplitN(strings.Trim(sl[i], ws), "=", 2)
+		switch r[0] {
+		case "realm":
+			c.realm = strings.Trim(r[1], qs)
+		case "domain":
+			c.domain = strings.Trim(r[1], qs)
+		case "nonce":
+			c.nonce = strings.Trim(r[1], qs)
+		case "opaque":
+			c.opaque = strings.Trim(r[1], qs)
+		case "stale":
+			c.stale = strings.Trim(r[1], qs)
+		case "algorithm":
+			c.algorithm = strings.Trim(r[1], qs)
+		case "qop":
+			c.qop = strings.Trim(r[1], qs)
+		default:
+			return nil, ErrBadChallenge
+		}
+	}
+	return c, nil
+}
+
+type credentials struct {
+	userhash  bool
+	username  string
+	realm     string
+	nonce     string
+	uri       string
+	algorithm string
+	cnonce    string
+	opaque    string
+	qop       string
+	nc        int
+	method    string
+	password  string
+}
+
+func h(data string) string {
+	return fmt.Sprintf("%x", md5.Sum([]byte(data)))
+}
+
+func (c *credentials) ha1() string {
+	return h(fmt.Sprintf("%s:%s:%s", c.username, c.realm, c.password))
+}
+
+func (c *credentials) ha2() string {
+	return h(fmt.Sprintf("%s:%s", c.method, c.uri))
+}
+
+func (c *credentials) response(cnonce string) (string, error) {
+	c.nc++
+	if c.qop == "auth" {
+		if cnonce != "" {
+			c.cnonce = cnonce
+		} else {
+			b := make([]byte, 8)
+			io.ReadFull(rand.Reader, b)
+			c.cnonce = fmt.Sprintf("%x", b)[:16]
+		}
+		return h(fmt.Sprintf("%s:%s:%08x:%s:%s:%s",
+			c.ha1(), c.nonce, c.nc, c.cnonce, c.qop, c.ha2())), nil
+	} else if c.qop == "" {
+		return h(fmt.Sprintf("%s:%s:%s", c.ha1(), c.nonce, c.ha2())), nil
+	}
+	return "", ErrAlgNotImplemented
+}
+
+func (c *credentials) authorize() (string, error) {
+	if c.algorithm != "MD5" {
+		return "", ErrAlgNotImplemented
+	}
+	if c.qop != "auth" && c.qop != "" {
+		return "", ErrAlgNotImplemented
+	}
+	response, err := c.response("")
+	if err != nil {
+		return "", err
+	}
+	sl := []string{}
+	sl = append(sl, fmt.Sprintf(`username="%s"`, c.username))
+	sl = append(sl, fmt.Sprintf(`realm="%s"`, c.realm))
+	sl = append(sl, fmt.Sprintf(`nonce="%s"`, c.nonce))
+	sl = append(sl, fmt.Sprintf(`uri="%s"`, c.uri))
+	sl = append(sl, fmt.Sprintf(`response="%s"`, response))
+	sl = append(sl, fmt.Sprintf(`algorithm="%s"`, c.algorithm))
+	sl = append(sl, fmt.Sprintf(`cnonce="%s"`, c.cnonce))
+	if c.opaque != "" {
+		sl = append(sl, fmt.Sprintf(`opaque="%s"`, c.opaque))
+	}
+	if c.qop != "" {
+		sl = append(sl, fmt.Sprintf(`qop=%s`, c.qop))
+	}
+	sl = append(sl, fmt.Sprintf("nc=%08x", c.nc))
+	if c.userhash {
+		sl = append(sl, `userhash="true"`)
+	}
+	return fmt.Sprintf("Digest %s", strings.Join(sl, ", ")), nil
+}
+
+func (t *Transport) authenticate(req *http.Request, c *challenge) *credentials {
+	return &credentials{
+		username:  t.Username,
+		realm:     c.realm,
+		nonce:     c.nonce,
+		uri:       req.URL.RequestURI(),
+		algorithm: c.algorithm,
+		opaque:    c.opaque,
+		qop:       c.qop,
+		nc:        0,
+		method:    req.Method,
+		password:  t.Password,
+	}
+}
diff --git a/digest/digest_test.go b/digest/digest_test.go
new file mode 100644
index 0000000..8607a48
--- /dev/null
+++ b/digest/digest_test.go
@@ -0,0 +1,46 @@
+// Copyright 2018 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 digest
+
+import (
+	"testing"
+)
+
+var c = &credentials{
+	username:  "admin",
+	realm:     "Digest:4C1F0000000000000000000000000000",
+	nonce:     "GZHoABAHAAAAAAAAtejSfCEQLbW+c/fM",
+	uri:       "/index",
+	algorithm: "MD5",
+	qop:       "auth",
+	method:    "POST",
+	password:  "password",
+}
+
+var cnonce = "0a4f113b"
+
+func TestHa1(t *testing.T) {
+	r := c.ha1()
+	if r != "e00fd2f74e4bb1ccd5c3f359e13822ce" {
+		t.Fail()
+	}
+}
+
+func TestHa2(t *testing.T) {
+	r := c.ha2()
+	if r != "f272ccec928f9de4e8e0bc6319ab2c66" {
+		t.Fail()
+	}
+}
+
+func TestResponse(t *testing.T) {
+	r, err := c.response(cnonce)
+	if err != nil {
+		t.Fail()
+	}
+	if r != "ce25c065de2d1c900b21ed6d6fbe886b" {
+		t.Fail()
+	}
+}
diff --git a/go.mod b/go.mod
index 850392a..537d9af 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@
 	cloud.google.com/go v0.44.3
 	github.com/google/go-cmp v0.3.1
 	github.com/google/subcommands v1.0.1
+	github.com/google/uuid v1.1.1
 	golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586
 	golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7
 	golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a
diff --git a/go.sum b/go.sum
index c3854f4..b42d0ed 100644
--- a/go.sum
+++ b/go.sum
@@ -29,6 +29,8 @@
 github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
 github.com/google/subcommands v1.0.1 h1:/eqq+otEXm5vhfBrbREPCSVQbvofip6kIz+mX5TUH7k=
 github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
+github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=