[devices] Model devices as FSMs

This change does several things:
1) Model Fuchsia devices as state machines
2) Modify health checker and catalyst to use the new device interface.

Change-Id: Ic3725be39d007223dd76325bd3de1e3532da7a2c
diff --git a/cmd/catalyst/catalyst_test.go b/cmd/catalyst/catalyst_test.go
index 2f5f115..5e887d0 100644
--- a/cmd/catalyst/catalyst_test.go
+++ b/cmd/catalyst/catalyst_test.go
@@ -7,6 +7,7 @@
 import (
 	"context"
 	"fmt"
+	"log"
 	"math/rand"
 	"net"
 	"net/http"
@@ -83,43 +84,29 @@
 	checkExitCode(t, exitCode, successExitCode)
 }
 
-func createMockDevice(name string, mac string, cmd []string) *devicePkg.DeviceTarget {
-	target := &devicePkg.DeviceTarget{BootserverCmd: cmd}
+func createMockDevice(name string, mac string, cmd []string) devicePkg.Device {
 	config := devicePkg.DeviceConfig{
-		Network: devicePkg.NetworkProperties{
+		Network: &devicePkg.NetworkProperties{
 			Nodename: name,
 			IPv4Addr: "000.000.00.0",
 			Mac:      mac,
 		},
+		Power: &devicePkg.PowerClient{
+			Host:     "000.000.00.0",
+			Username: "user",
+			Password: "Pwd",
+		},
 	}
-	target.SetConfig(config)
+	target, err := devicePkg.NewNuc(config, cmd)
+	if err != nil {
+		log.Fatalf("creating mock device failed: %v", err)
+	}
 	return target
 }
 
-// Test running runBootservers with successful results using mock devices.
-func TestBootserverSuccess(t *testing.T) {
-	cmd := constructDummyCmd(false)
-	devices := []*devicePkg.DeviceTarget{
-		createMockDevice("dummy1", "00:00:00:00:00", cmd),
-		createMockDevice("dummy2", "00:00:00:00:01", cmd),
-	}
-	exitCode := runBootservers(context.Background(), devices)
-	checkExitCode(t, exitCode, successExitCode)
-}
-
-// Test running runBootservers with failed results using mock devices.
-func TestBootserverFailure(t *testing.T) {
-	devices := []*devicePkg.DeviceTarget{
-		createMockDevice("dummy1", "00:00:00:00:00", constructDummyCmd(false)),
-		createMockDevice("dummy2", "00:00:00:00:01", constructDummyCmd(true)),
-	}
-	exitCode := runBootservers(context.Background(), devices)
-	checkExitCode(t, exitCode, failureExitCode)
-}
-
 // Test that NUC server does not start up when port environment variable isn't set.
 func TestNUCServerNonexistence(t *testing.T) {
-	var devices []*devicePkg.DeviceTarget
+	var devices []devicePkg.Device
 	srv, err := runNUCServer(context.Background(), devices, false)
 	if srv != nil {
 		t.Errorf("NUC server was created without port set.")
@@ -130,7 +117,7 @@
 
 // Test that NUC server is disabled when flag is set and port environment variable is set.
 func TestNUCServerDisabled(t *testing.T) {
-	var devices []*devicePkg.DeviceTarget
+	var devices []devicePkg.Device
 	os.Setenv(portEnvVar, "8000")
 	srv, err := runNUCServer(context.Background(), devices, true)
 	if srv != nil {
@@ -143,7 +130,7 @@
 
 // Test that NUC server is started up when port environment variable is set.
 func TestNUCServerExistence(t *testing.T) {
-	var devices []*devicePkg.DeviceTarget
+	var devices []devicePkg.Device
 	os.Setenv(portEnvVar, "8000")
 	srv, err := runNUCServer(context.Background(), devices, false)
 	os.Setenv(portEnvVar, "")
@@ -160,7 +147,7 @@
 func TestNUCServerEndpoints(t *testing.T) {
 	// Create mock devices
 	cmd := constructDummyCmd(false)
-	devices := []*devicePkg.DeviceTarget{
+	devices := []devicePkg.Device{
 		createMockDevice("dummy1", "00:00:00:00:00", cmd),
 		createMockDevice("dummy2", "00:00:00:00:01", cmd),
 	}
diff --git a/cmd/catalyst/main.go b/cmd/catalyst/main.go
index 6b3541c..5ecd219 100644
--- a/cmd/catalyst/main.go
+++ b/cmd/catalyst/main.go
@@ -79,7 +79,9 @@
 	return cmd.ProcessState.ExitCode()
 }
 
-func runNUCServer(ctx context.Context, devices []*devicePkg.DeviceTarget, disabled bool) (*http.Server, error) {
+// runNUCServer runs an http server that delivers chainloaders/images to NUCs based on their mac.
+// These chainloaders deliver a build's version of zedboot and an exit chainloader (to allow reboot from disk).
+func runNUCServer(ctx context.Context, devices []devicePkg.Device, disabled bool) (*http.Server, error) {
 	// Parse the port from the environment variable.
 	portStr := os.Getenv(portEnvVar)
 	if portStr == "" || disabled {
@@ -124,48 +126,36 @@
 	return srv, nil
 }
 
-func runBootservers(ctx context.Context, devices []*devicePkg.DeviceTarget) int {
-	// Execute bootserver for each node that isn't a NUC
-	exitCodes := make(chan int)
-	numSubprocesses := 0
+// prepDevices puts all devices into TaskState.
+func prepDevices(ctx context.Context, devices []devicePkg.Device) error {
+	// Put each device into TaskState.
+	errorChannel := make(chan error)
 	for _, device := range devices {
-		if device.Type() != "nuc" {
-			go func(device *devicePkg.DeviceTarget) {
-				exitCodes <- runSubprocess(ctx, device.BootserverCmd)
-			}(device)
-			numSubprocesses += 1
-		} else {
-			// If this is a NUC, rebooting will allow iPXE to retrieve the
-			// new version of zedboot.
-			device.Powercycle(ctx)
+		go func(device devicePkg.Device) {
+			errorChannel <- device.ToTaskState(ctx)
+		}(device)
+	}
+	for i := 0; i < len(devices); i++ {
+		if err := <-errorChannel; err != nil {
+			return err
 		}
 	}
-
-	// Wait for all of the bootservers to finish running and ensure success
-	numErrs := 0
-	for i := 0; i < numSubprocesses; i++ {
-		if exitCode := <-exitCodes; exitCode != 0 {
-			log.Printf("bootserver exited with exit code: %d\n", exitCode)
-			numErrs += 1
-		}
-	}
-
-	if numErrs > 0 {
-		return failureExitCode
-	}
-	return 0
+	return nil
 }
 
-func hasNUC(devices []*devicePkg.DeviceTarget) bool {
+// hasNUC returns true iff this testbed contains a NUC.
+func hasNUC(devices []devicePkg.Device) bool {
 	for _, device := range devices {
-		if device.Type() == "nuc" {
+		if _, ok := device.(*devicePkg.Nuc); ok {
 			return true
 		}
 	}
 	return false
 }
 
-func downloadZedboot(ctx context.Context, imageURL *url.URL, devices []*devicePkg.DeviceTarget) error {
+// downloadZedboot retrieves zedboot.zbi from GCS so that we can use the proper image
+// in NUCs.
+func downloadZedboot(ctx context.Context, imageURL *url.URL, devices []devicePkg.Device) error {
 	// Ensure that we have a NUC that needs a local zedboot.zbi.
 	if !hasNUC(devices) {
 		return nil
@@ -221,8 +211,12 @@
 		"-n",
 	}
 
-	// Create devicePkg.DeviceTargets for each of the devices in the config file
-	devices, err := devicePkg.CreateDeviceTargets(ctx, deviceConfigPath, bootserverCmdStub)
+	// Create devicePkg.Devices for each of the devices in the config file
+	configs, err := devicePkg.LoadDeviceConfigs(deviceConfigPath)
+	if err != nil {
+		return failureExitCode, err
+	}
+	devices, err := devicePkg.CreateDevices(ctx, configs, bootserverCmdStub)
 	if err != nil {
 		return failureExitCode, err
 	}
@@ -257,8 +251,8 @@
 		}
 	}()
 
-	if exitCode := runBootservers(ctx, devices); exitCode != 0 {
-		return exitCode, nil
+	if err := prepDevices(ctx, devices); err != nil {
+		return failureExitCode, err
 	}
 
 	time.Sleep(rebootDuration)
diff --git a/cmd/health_checker/main.go b/cmd/health_checker/main.go
index 61cca5b..b560a36 100644
--- a/cmd/health_checker/main.go
+++ b/cmd/health_checker/main.go
@@ -8,7 +8,6 @@
 	"encoding/json"
 	"flag"
 	"fmt"
-	"io"
 	"log"
 	"net"
 	"os"
@@ -35,9 +34,9 @@
 )
 
 const (
-	healthyState         = "healthy"
-	unhealthyState       = "unhealthy"
-	logFile              = "/tmp/health_checker.log"
+	healthyState   = "healthy"
+	unhealthyState = "unhealthy"
+	logFile        = "/tmp/health_checker.log"
 )
 
 // DeviceHealthProperties contains health properties of a hardware device.
@@ -92,20 +91,17 @@
 // response is received - is a no-op if the device doesn't have serial
 // this is also a no-op for everything other than NUC, as the check seems flaky
 // on astros/sherlocks
-func checkSerial(device *devicePkg.DeviceTarget) error {
-	if device.Type() != "nuc" {
+func checkSerial(device devicePkg.Device) error {
+	if _, ok := device.(*devicePkg.Nuc); !ok {
 		return nil
 	}
-	if device.Serial() == nil {
-		return nil
-	}
-	cmdString := "\necho hello\n"
+	cmdString := "echo hello"
 	resultString := "\r\n$ echo hello\r\nhello"
-	if _, err := io.WriteString(device.Serial(), cmdString); err != nil {
+	if err := device.SendSerialCommand(context.Background(), cmdString); err != nil {
 		return err
 	}
 	buffer := make([]byte, len(resultString))
-	if _, err := io.ReadAtLeast(device.Serial(), buffer, len(resultString)); err != nil {
+	if err := device.ReadSerialData(context.Background(), buffer); err != nil {
 		return err
 	}
 	if string(buffer) != resultString {
@@ -117,8 +113,8 @@
 
 // checkBroadcasting ensures that broadcast packets are being sent by the device
 // is a no-op on NUCs
-func checkBroadcasting(n *netboot.Client, device *devicePkg.DeviceTarget) error {
-	if device.Type() == "nuc" {
+func checkBroadcasting(n *netboot.Client, device devicePkg.Device) error {
+	if _, ok := device.(*devicePkg.ArmCDCether); !ok {
 		return nil
 	}
 	if _, err := n.Beacon(); err != nil {
@@ -127,7 +123,7 @@
 	return nil
 }
 
-func checkHealth(n *netboot.Client, device *devicePkg.DeviceTarget) HealthCheckResult {
+func checkHealth(n *netboot.Client, device devicePkg.Device) HealthCheckResult {
 	nodename := device.Nodename()
 	log.Printf("Checking health for %s", nodename)
 	// Check the device is in zedboot.
@@ -173,7 +169,11 @@
 	flag.Parse()
 	client := netboot.NewClient(timeout)
 	ctx := context.Background()
-	devices, err := devicePkg.CreateDeviceTargets(ctx, configFile, nil)
+	configs, err := devicePkg.LoadDeviceConfigs(configFile)
+	if err != nil {
+		log.Fatal(err)
+	}
+	devices, err := devicePkg.CreateDevices(ctx, configs, nil)
 	if err != nil {
 		log.Fatal(err)
 	}
@@ -188,15 +188,18 @@
 	if forceReboot {
 		for _, device := range devices {
 			log.Printf("attempting forced device restart for: %s", device.Nodename())
-			if device.Serial() == nil {
-				log.Printf("device does not have serial, powercycling")
-				if err := device.Powercycle(ctx); err != nil {
-					log.Printf("powercycle failed: %s", err.Error())
+			if err := device.Powercycle(ctx); err == nil {
+				time.Sleep(1 * time.Minute)
+			} else {
+				log.Printf("powercycle failed: %v", err)
+			}
+			if device.HasSerial() {
+				if err := device.SoftReboot(ctx, "R", "serial"); err != nil {
+					log.Printf("serial reboot failed: %v", err)
 				}
 			} else {
-				log.Printf("device has serial, restarting")
-				if err := device.Restart(ctx); err != nil {
-					log.Printf("forced restart failed with error: %s", err.Error())
+				if err := device.SoftReboot(ctx, "R", "ssh"); err != nil {
+					log.Printf("ssh reboot failed: %v", err)
 				}
 			}
 			log.Printf("forced restart for device %s is complete", device.Nodename())
@@ -209,16 +212,19 @@
 		checkResult := checkHealth(client, device)
 		log.Printf("state=%s, error_msg=%s", checkResult.State, checkResult.ErrorMsg)
 		if checkResult.State == unhealthyState && rebootIfUnhealthy {
-			if err := device.Powercycle(ctx); err != nil {
-				log.Printf("powercycle call failed with error: %s", err.Error())
-				if err := device.Restart(ctx); err != nil {
-					log.Printf("restart call failed with error: %s", err.Error())
-					checkResult.ErrorMsg += "; Failed to perform powercycle and restart"
-				} else {
-					checkResult.ErrorMsg += "; Failed to perform powercycle; restart succeeded"
+			if err := device.Powercycle(ctx); err == nil {
+				time.Sleep(1 * time.Minute)
+			} else {
+				log.Printf("powercycle failed: %v", err)
+			}
+			if device.HasSerial() {
+				if err := device.SoftReboot(ctx, "R", "serial"); err != nil {
+					log.Printf("serial reboot failed: %v", err)
 				}
 			} else {
-				log.Printf("powercycle call succeeded for %s", device.Nodename())
+				if err := device.SoftReboot(ctx, "R", "ssh"); err != nil {
+					log.Printf("ssh reboot failed: %v", err)
+				}
 			}
 		}
 		checkResultSlice = append(checkResultSlice, checkResult)
diff --git a/devices/amt.go b/devices/amt.go
deleted file mode 100644
index 40cc5b4..0000000
--- a/devices/amt.go
+++ /dev/null
@@ -1,98 +0,0 @@
-// 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 devices
-
-import (
-	"fmt"
-	"io/ioutil"
-	"net/http"
-	"net/url"
-	"strings"
-
-	"github.com/google/uuid"
-	"go.fuchsia.dev/fuchsia/tools/net/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 AMTReboot(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/devices/arm_cdcether.go b/devices/arm_cdcether.go
new file mode 100644
index 0000000..ac45ed9
--- /dev/null
+++ b/devices/arm_cdcether.go
@@ -0,0 +1,137 @@
+package devices
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"strconv"
+	"syscall"
+	"time"
+
+	"go.fuchsia.dev/infra/devices/power"
+	"go.fuchsia.dev/tools/netboot"
+)
+
+// ArmCDCether represents a generic arm cdcether  product. It currently encompasses
+// 1) Sherlock
+// 2) Astro
+// 3) Nelson
+type ArmCDCether struct {
+	fuchsiaDevice
+}
+
+// NewArmCDCether initializes Astros, Sherlocks, and Nelsons as Arm CDCether  objects.
+func NewArmCDCether(config DeviceConfig, bootserverCmdStub []string) (*ArmCDCether, error) {
+	var powerManager powerManager = nil
+	if config.Power != nil {
+		port, err := strconv.Atoi(config.Power.PDUPort)
+		if err != nil {
+			return nil, err
+		}
+		powerManager = power.NewPDUPowerManager(
+			config.Power.PDUIp,
+			config.Power.Username,
+			config.Power.Password,
+			port,
+		)
+	}
+
+	sd := &ArmCDCether{
+		fuchsiaDevice{
+			power: powerManager,
+		},
+	}
+	if err := initFuchsiaDevice(&sd.fuchsiaDevice, config, bootserverCmdStub); err != nil {
+		return nil, err
+	}
+
+	// All transitions in a ArmCDCether attempt to get it into zedboot.
+	transitionMap := map[DeviceState]*Transition{
+		Initial: &Transition{
+			PerformAction: sd.sshRebootRecovery,
+			Validate:      sd.checkTransitionSuccess,
+			SuccessState:  Healthy,
+			FailureState:  "ssh_failed",
+		},
+		"ssh_failed": &Transition{
+			PerformAction: sd.serialRebootRecovery,
+			Validate:      sd.checkTransitionSuccess,
+			SuccessState:  Healthy,
+			FailureState:  "serial_failed",
+		},
+		"serial_failed": &Transition{
+			PerformAction: sd.PowercycleIntoRecovery,
+			Validate:      sd.checkTransitionSuccess,
+			SuccessState:  Healthy,
+			FailureState:  Unrecoverable,
+		},
+	}
+	sd.transitionMap = transitionMap
+	return sd, nil
+}
+
+// PowercycleIntoRecovery powercycles the device and then sends a serial command
+// to boot into the recovery partition.
+func (s *ArmCDCether) PowercycleIntoRecovery(ctx context.Context) error {
+	if err := s.Powercycle(ctx); err != nil {
+		return err
+	}
+	time.Sleep(powercycleWait)
+	return s.SoftReboot(ctx, "R", "serial")
+}
+
+// checkTransitionSuccess is the validation function used by all transitions
+// performed by a ArmCDCether. It verfies that the devices is in zedboot and
+// broadcasting.
+func (s *ArmCDCether) checkTransitionSuccess(ctx context.Context) error {
+	// Give the device time to transition.
+	time.Sleep(1 * time.Minute)
+	client := netboot.NewClient(netbootTimeout)
+	if err := checkBroadcasting(client); err != nil {
+		return err
+	}
+	return deviceInZedboot(client, s.nodename)
+}
+
+// checkBroadcasting ensures that the ArmCDCether is sending out advertising
+// packets.
+func checkBroadcasting(n *netboot.Client) error {
+	if _, err := n.Beacon(); err != nil {
+		return err
+	}
+	return nil
+}
+
+// ToTaskState puts the ArmCDCether into a state in which it can run tasks.
+func (s *ArmCDCether) ToTaskState(ctx context.Context) error {
+	cmd := exec.Command(s.BootserverCmd[0], s.BootserverCmd[1:]...)
+
+	// Spin up handler to exit subprocess cleanly on sigterm.
+	processDone := make(chan bool, 1)
+	go func() {
+		select {
+		case <-processDone:
+		case <-ctx.Done():
+			if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
+				log.Printf("exited cmd with error %v", err)
+			}
+		}
+	}()
+
+	// Ensure the context still exists before running the subprocess.
+	if ctx.Err() != nil {
+		return nil
+	}
+
+	// Run the bootserver.
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	cmd.Run()
+	processDone <- true
+	if !cmd.ProcessState.Success() {
+		return fmt.Errorf("bootserver for %s exited with exit code %d", s.nodename, cmd.ProcessState.ExitCode())
+	}
+	return nil
+}
diff --git a/devices/config.go b/devices/config.go
new file mode 100644
index 0000000..81624da
--- /dev/null
+++ b/devices/config.go
@@ -0,0 +1,150 @@
+package devices
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+
+	"golang.org/x/crypto/ssh"
+)
+
+// DeviceConfig contains the static properties of a target device.
+type DeviceConfig struct {
+	// Type tells us what kind of machine this is.
+	Type string `json:"type"`
+
+	// Network is the network properties of the target.
+	Network *NetworkProperties `json:"network"`
+
+	// Power is the attached power management configuration.
+	Power *PowerClient `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"`
+
+	// Mac is the mac address of the device
+	Mac string `json:"mac"`
+
+	// The network interface used to communicate with this device.
+	Interface string `json:"interface"`
+}
+
+// PowerClient represents a power management configuration for a particular device.
+type PowerClient struct {
+	// Type is the type of manager to use.
+	Type string `json:"type"`
+
+	// Host is the network hostname of the manager
+	Host string `json:"host"`
+
+	// 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"`
+
+	// PDUIp is the IP address of the pdu
+	PDUIp string `json:"pduIp"`
+
+	// PDUPort is the port the PDU uses to connect to this device.
+	PDUPort string `json:"pduPort"`
+}
+
+// Device represents a generic device in our infrastructure.
+// TODO(rudymathu) This interface should eventually be pared down to:
+// State()
+// Nodename()
+// Transition()
+type Device interface {
+	State() DeviceState
+	Nodename() string
+	Mac() string
+	Interface() string
+	HasSerial() bool
+	Transition(context.Context) error
+	ToTaskState(context.Context) error
+	Powercycle(context.Context) error
+	SoftReboot(context.Context, string, string) error
+	SendSerialCommand(context.Context, string) error
+	ReadSerialData(context.Context, []byte) error
+}
+
+// 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
+}
+
+// CreateDevices loads configs from the given file and creates an appropriate Device for each one.
+func CreateDevices(ctx context.Context, deviceConfigs []DeviceConfig, bootserverCmdStub []string) ([]Device, error) {
+	var devices []Device
+	for _, deviceConfig := range deviceConfigs {
+		var device Device
+		var err error
+		switch deviceConfig.Type {
+		case "Intel NUC Kit NUC7i5DNHE":
+			device, err = NewNuc(deviceConfig, bootserverCmdStub)
+		case "Khadas Vim2 Max":
+			device, err = NewVim(deviceConfig, bootserverCmdStub)
+		case "Astro":
+			fallthrough
+		case "Sherlock":
+			fallthrough
+		case "Nelson":
+			device, err = NewArmCDCether(deviceConfig, bootserverCmdStub)
+		}
+		if err != nil {
+			return nil, err
+		}
+		devices = append(devices, device)
+	}
+	return devices, nil
+}
+
+// parseOutSigners parses out ssh signers from the given key files.
+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/devices/device.go b/devices/device.go
index 1c3b651..1069375 100644
--- a/devices/device.go
+++ b/devices/device.go
@@ -2,217 +2,279 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+// Package devices models devices as state machines, and exposes capabilities for
+// other utilites to work with.
 package devices
 
 import (
 	"context"
-	"encoding/json"
-	"errors"
 	"fmt"
 	"io"
-	"io/ioutil"
-	"log"
 	"net"
+	"os/exec"
 	"time"
 
 	"go.fuchsia.dev/fuchsia/tools/net/serial"
-
+	"go.fuchsia.dev/fuchsia/tools/net/sshutil"
+	"go.fuchsia.dev/tools/netboot"
 	"golang.org/x/crypto/ssh"
 )
 
+// DeviceState represents a physical state the device can be in.
+// It is used when performing device recovery.
+type DeviceState string
+
+// Transition specifies an action to perform, a validation function to
+// determine if the action succeeded or failed, and the appropriate
+// states to put the device in.
+type Transition struct {
+	PerformAction func(context.Context) error
+	Validate      func(context.Context) error
+	SuccessState  DeviceState
+	FailureState  DeviceState
+}
+
 const (
-	// The duration we allow for the netstack to come up when booting.
-	netstackTimeout = 90 * time.Second
+	// Healthy refers to the state in which the device is booted into zedboot.
+	Healthy DeviceState = "zedboot"
+	// Devices are marked Unrecoverable if all recovery attempts fail.
+	Unrecoverable DeviceState = "unrecoverable"
+	// All devices are created in the Inital state. This is the entrypoint into the
+	// state machine.
+	Initial DeviceState = "unknown"
+	// Duration to wait before sending dm reboot-recovery after powercycle.
+	powercycleWait = 1 * time.Minute
+	// netbootTimeout is the duration to wait when creating a new netboot client.
+	netbootTimeout = 10 * time.Second
 )
 
-type DeviceType string
-
-// DeviceConfig contains the static properties of a target device.
-type DeviceConfig struct {
-	// Type tells us what kind of machine this is.
-	Type string `json:"type"`
-
-	// Network is the network properties of the target.
-	Network NetworkProperties `json:"network"`
-
-	// Power is the attached power management configuration.
-	Power *PowerClient `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"`
+// powerManager describes any object that gives a device powercycle functionality.
+// Different devices use different power management systems, so each device is expected
+// to provide a concrete implementation of this.
+type powerManager interface {
+	Powercycle(context.Context) error
 }
 
-// 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"`
-
-	// Mac is the mac address of the device
-	Mac string `json:"mac"`
-}
-
-// 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 {
-	deviceType    DeviceType
-	config        DeviceConfig
+// fuchsiaDevice represents an abstract fuchsia device. It should not be
+// directly instantiated.
+type fuchsiaDevice struct {
+	mac           string
+	nodename      string
+	networkIf     string
+	state         DeviceState
+	transitionMap map[DeviceState]*Transition
 	signers       []ssh.Signer
 	serial        io.ReadWriteCloser
 	BootserverCmd []string
+	power         powerManager
 }
 
-// CreateDeviceTargets loads configs from the given file and creates DeviceTargets for each one
-func CreateDeviceTargets(ctx context.Context, path string, bootserverCmdStub []string) ([]*DeviceTarget, error) {
-	deviceConfigs, err := LoadDeviceConfigs(path)
-	if err != nil {
-		return nil, err
-	}
-	var devices []*DeviceTarget
-	for _, deviceConfig := range deviceConfigs {
-		device, err := NewDeviceTarget(ctx, deviceConfig, bootserverCmdStub)
+// initFuchsiaDevice initializes the fields in fuchsiaDevice using the given config.
+// Concrete implementations of fuchsiaDevice can use this to avoid code duplication.
+func initFuchsiaDevice(device *fuchsiaDevice, config DeviceConfig, bootserverCmdStub []string) error {
+	var signers []ssh.Signer
+	var err error
+	if config.SSHKeys != nil {
+		signers, err = parseOutSigners(config.SSHKeys)
 		if err != nil {
-			return nil, err
+			return fmt.Errorf("could not parse out signers from private keys: %v", err)
 		}
-		devices = append(devices, device)
-	}
-	return devices, nil
-}
-
-// NewDeviceTarget returns a new device target with a given configuration.
-func NewDeviceTarget(ctx context.Context, config DeviceConfig, bootserverCmdStub []string) (*DeviceTarget, error) {
-	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 {
-			log.Printf("unable to open %s: %v", config.Serial, err)
+			return fmt.Errorf("could not open serial line: %s", config.Serial)
 		}
 	}
-	deviceType, err := ConvertConfigType(config)
+	device.nodename = config.Network.Nodename
+	device.networkIf = config.Network.Interface
+	device.state = Initial
+	device.signers = signers
+	device.serial = s
+	device.mac = config.Network.Mac
+	device.BootserverCmd = append(bootserverCmdStub, device.nodename)
+	return nil
+}
+
+// Mac returns the mac address of this device.
+func (f *fuchsiaDevice) Mac() string {
+	return f.mac
+}
+
+// Interface returns the network interface of this device.
+func (f *fuchsiaDevice) Interface() string {
+	return f.networkIf
+}
+
+// HasSerial returns true if this device has a serial line.
+func (f *fuchsiaDevice) HasSerial() bool {
+	return f.serial != nil
+}
+
+// Powercycle uses an out of band method to reboot a fuchsia device.
+// Is a no-op if no out of band is possible.
+// TODO(rudymathu): remove the no-op as soon as all devices have an out of band
+// reboot method.
+func (f *fuchsiaDevice) Powercycle(ctx context.Context) error {
+	if f.power != nil {
+		return f.power.Powercycle(ctx)
+	}
+	return nil
+}
+
+// SendSSHCommand lets us send a command via SSH to the fuchsia device.
+func (f *fuchsiaDevice) SendSSHCommand(ctx context.Context, command string) error {
+	if f.signers == nil {
+		return fmt.Errorf("device %s does not support SSH", f.nodename)
+	}
+	config, err := sshutil.DefaultSSHConfigFromSigners(f.signers...)
 	if err != nil {
-		return nil, err
+		return err
 	}
-	return &DeviceTarget{
-		deviceType:    deviceType,
-		config:        config,
-		signers:       signers,
-		serial:        s,
-		BootserverCmd: append(bootserverCmdStub, config.Network.Nodename),
-	}, nil
-}
 
-// ConvertConfigType converts a device type from the configs into a more
-// manageable representation.
-func ConvertConfigType(config DeviceConfig) (DeviceType, error) {
-	switch config.Type {
-	case "Intel NUC Kit NUC7i5DNHE":
-		return "nuc", nil
-	case "Khadas Vim2 Max":
-		return "vim2", nil
-	case "Astro":
-		return "astro", nil
-	case "Sherlock":
-		return "sherlock", nil
-	case "Nelson":
-		return "nelson", nil
+	client, err := sshutil.ConnectToNode(ctx, f.nodename, config)
+	if err != nil {
+		return err
 	}
-	return "", fmt.Errorf("Invalid device type in configs: %s", config.Type)
-}
 
-// SetConfig sets the config field of the given DeviceTarget
-func (t *DeviceTarget) SetConfig(config DeviceConfig) {
-	t.config = config
-}
+	defer client.Close()
 
-// 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 from the config
-func (t *DeviceTarget) IPv4Addr() (net.IP, error) {
-	return net.ParseIP(t.config.Network.IPv4Addr), nil
-}
-
-// Mac returns the Mac address of the node from the config
-func (t *DeviceTarget) Mac() string {
-	return t.config.Network.Mac
-}
-
-// Serial returns the serial device associated with the target for serial i/o.
-func (t *DeviceTarget) Serial() io.ReadWriteCloser {
-	return t.serial
-}
-
-// Type returns the type of the device.
-func (t *DeviceTarget) Type() DeviceType {
-	return t.deviceType
-}
-
-// Restart restarts the target.
-func (t *DeviceTarget) Restart(ctx context.Context) error {
-	if t.serial != nil {
-		defer t.serial.Close()
+	session, err := client.NewSession()
+	if err != nil {
+		return err
 	}
-	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)
-		}
+
+	defer session.Close()
+
+	// Invoke `dm reboot-recovery`
+	err = session.Start(command)
+	if err != nil {
+		return err
+	}
+
+	done := make(chan error)
+	go func() {
+		done <- session.Wait()
+	}()
+
+	select {
+	case err := <-done:
+		return err
+	case <-time.After(10 * time.Second):
+		return nil
+	}
+}
+
+// SendSerialCommand lets us send a command via serial to the fuchsia device.
+func (f *fuchsiaDevice) SendSerialCommand(ctx context.Context, command string) error {
+	if f.serial == nil {
+		return fmt.Errorf("device %s does not support serial", f.nodename)
+	}
+	_, err := io.WriteString(f.serial, fmt.Sprintf("\n%s\n", command))
+	return err
+}
+
+// ReadSerialData fills the given buffer with bytes from the serial line.
+func (f *fuchsiaDevice) ReadSerialData(ctx context.Context, buf []byte) error {
+	_, err := io.ReadAtLeast(f.serial, buf, len(buf))
+	return err
+}
+
+// SoftReboot is a convenience function that reboots into the specified partition
+// using the specified method.
+func (f *fuchsiaDevice) SoftReboot(ctx context.Context, partition string, method string) error {
+	command := ""
+	if partition == "A" {
+		command = "dm reboot"
+	} else if partition == "R" {
+		command = "dm reboot-recovery"
+	} else {
+		return fmt.Errorf("rebooting into partition %s is not supported", partition)
+	}
+
+	if method == "ssh" {
+		return f.SendSSHCommand(ctx, command)
+	} else if method == "serial" {
+		return f.SendSerialCommand(ctx, command)
+	}
+	return fmt.Errorf("reboot method %s is not supported", method)
+}
+
+// State returns the current state of the fuchsia device.
+func (f *fuchsiaDevice) State() DeviceState {
+	return f.state
+}
+
+// Nodename returns the nodename of the fuchsia device.
+func (f *fuchsiaDevice) Nodename() string {
+	return f.nodename
+}
+
+// Transition tries to move the device into the next state. It does so by:
+// 1) Getting the transition associated with the current state.
+// 2) Performing the action specified by the transition.
+// 3) Validating if the action worked, and moving the device into the corresponding
+//    failure or success state.
+func (f *fuchsiaDevice) Transition(ctx context.Context) error {
+	transition := f.transitionMap[f.state]
+	if err := transition.PerformAction(ctx); err != nil {
+		f.state = transition.FailureState
+		return err
+	}
+	if err := transition.Validate(ctx); err != nil {
+		f.state = transition.FailureState
+		return err
+	}
+	f.state = transition.SuccessState
+	return nil
+}
+
+func (f *fuchsiaDevice) serialRebootRecovery(ctx context.Context) error {
+	return f.SoftReboot(ctx, "R", "serial")
+}
+
+func (f *fuchsiaDevice) sshRebootRecovery(ctx context.Context) error {
+	return f.SoftReboot(ctx, "R", "ssh")
+}
+
+// pingZedboot creates a netboot client and attempts to ping the zedboot ipv6
+// address of the device with the given nodename.
+func pingZedboot(n *netboot.Client, nodename string) error {
+	netsvcAddr, err := n.Discover(nodename, false)
+	if err != nil {
+		return fmt.Errorf("Failed to discover netsvc addr: %v.", err)
+	}
+	netsvcIpAddr := &net.IPAddr{IP: netsvcAddr.IP, Zone: netsvcAddr.Zone}
+	cmd := exec.Command("ping", "-6", netsvcIpAddr.String(), "-c", "1")
+	if _, err = cmd.Output(); err != nil {
+		return fmt.Errorf("Failed to ping netsvc addr %s: %v.", netsvcIpAddr, err)
 	}
 	return nil
 }
 
-// Powercycle uses an out of band method to powercycle the target.
-func (t *DeviceTarget) Powercycle(ctx context.Context) error {
-	if t.config.Power != nil {
-		return t.config.Power.Powercycle(t.Nodename(), t.Mac(), t.serial)
+// ensureNotFuchsia creates a netboot client and ensures it cannot ping the
+// fuchsia ipv6 address of the device with the given nodename.
+func ensureNotFuchsia(n *netboot.Client, nodename string) error {
+	fuchsiaAddr, err := n.Discover(nodename, true)
+	if err != nil {
+		return fmt.Errorf("Failed to discover fuchsia addr: %v.", err)
+	}
+	fuchsiaIpAddr := &net.IPAddr{IP: fuchsiaAddr.IP, Zone: fuchsiaAddr.Zone}
+	cmd := exec.Command("ping", "-6", fuchsiaIpAddr.String(), "-c", "1")
+	if _, err = cmd.Output(); err == nil {
+		return fmt.Errorf("Device is in Fuchsia, should be in Zedboot.")
 	}
 	return nil
 }
 
-func parseOutSigners(keyPaths []string) ([]ssh.Signer, error) {
-	if len(keyPaths) == 0 {
-		return nil, errors.New("must supply SSH keys in the config")
+// deviceInZedboot ensures that the device with the given nodename is in zedboot.
+func deviceInZedboot(n *netboot.Client, nodename string) error {
+	if err := pingZedboot(n, nodename); err != nil {
+		return err
 	}
-	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)
+	if err := ensureNotFuchsia(n, nodename); err != nil {
+		return err
 	}
-
-	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
+	return nil
 }
diff --git a/devices/devices_test.go b/devices/devices_test.go
new file mode 100644
index 0000000..1cfd2ef
--- /dev/null
+++ b/devices/devices_test.go
@@ -0,0 +1,119 @@
+package devices
+
+import (
+	"context"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+var (
+	testDeviceConfigs = []DeviceConfig{
+		DeviceConfig{
+			Type: "Astro",
+			Network: &NetworkProperties{
+				Nodename: "astro-test-device",
+				IPv4Addr: "255.255.255.255",
+				Mac:      "00:00:00:00:00:00",
+			},
+			Power: &PowerClient{
+				Type:     "PDU",
+				Host:     "255.255.255.255",
+				Username: "username",
+				Password: "password",
+				PDUIp:    "255.255.255.255",
+				PDUPort:  "1",
+			},
+		},
+		DeviceConfig{
+			Type: "Intel NUC Kit NUC7i5DNHE",
+			Network: &NetworkProperties{
+				Nodename: "nuc-test-device",
+				IPv4Addr: "255.255.255.255",
+				Mac:      "00:00:00:00:00:00",
+			},
+			Power: &PowerClient{
+				Type:     "AMT",
+				Host:     "255.255.255.255",
+				Username: "username",
+				Password: "password",
+			},
+		},
+		DeviceConfig{
+			Type: "Khadas Vim2 Max",
+			Network: &NetworkProperties{
+				Nodename: "vim-test-device",
+				IPv4Addr: "255.255.255.255",
+				Mac:      "00:00:00:00:00:00",
+			},
+			Power: &PowerClient{
+				Type: "WOL",
+				Host: "255.255.255.255",
+			},
+		},
+	}
+)
+
+// deviceSanityCheck identifies basic differences between a Device and its source DeviceConfig.
+// This function does not check every property; it is meant to be a sanity check.
+func deviceSanityCheck(t *testing.T, actual Device, expected DeviceConfig) {
+	if actual.Nodename() != expected.Network.Nodename {
+		t.Errorf("nodenames not equal; expected: %s, actual: %s", expected.Network.Nodename, actual.Nodename())
+	}
+	if actual.Mac() != expected.Network.Mac {
+		t.Errorf("macs not equal; expected: %s, actual: %s", expected.Network.Mac, actual.Mac())
+	}
+	if actual.HasSerial() {
+		t.Errorf("test device should not have serial")
+	}
+}
+
+func TestLoadDeviceConfigs(t *testing.T) {
+	// Test that loading a nonexistent config fails.
+	if _, err := LoadDeviceConfigs("./test_data/nonexistent.json"); err == nil {
+		t.Error("loading nonexistent config did not fail")
+	}
+
+	// Test that loading misformatted json files fails.
+	if _, err := LoadDeviceConfigs("./test_data/invalid.json"); err == nil {
+		t.Error("loading invalid json did not produce error")
+	}
+
+	// Test that loading correctly formatted config files succeeds
+	configs, err := LoadDeviceConfigs("./test_data/config.json")
+	if err != nil {
+		t.Fatalf("loading device configs failed: %v", err)
+	}
+	if diff := cmp.Diff(configs, testDeviceConfigs); diff != "" {
+		t.Errorf("parsed configs do not match expected: (-want +got):\n%s", diff)
+	}
+
+}
+
+func TestCreateDevices(t *testing.T) {
+	ctx := context.Background()
+	devices, err := CreateDevices(ctx, testDeviceConfigs, []string{"test"})
+	if err != nil {
+		t.Fatalf("creating devices from configs failed: %v", err)
+	}
+
+	if len(devices) != 3 {
+		t.Fatalf("expected three devices to be created, got %d", len(devices))
+	}
+
+	// Check the types of each device.
+	if _, ok := devices[0].(*ArmCDCether); !ok {
+		t.Errorf("device one is not an arm cdcether device: %v", devices[0])
+	}
+	if _, ok := devices[1].(*Nuc); !ok {
+		t.Errorf("device one is not a nuc: %v", devices[1])
+	}
+	if _, ok := devices[2].(*Vim); !ok {
+		t.Errorf("device one is not a vim: %v", devices[2])
+	}
+
+	// Perform a quick sanity check on the devices.
+	for i, device := range devices {
+		deviceSanityCheck(t, device, testDeviceConfigs[i])
+	}
+}
diff --git a/devices/nuc.go b/devices/nuc.go
new file mode 100644
index 0000000..ad62411
--- /dev/null
+++ b/devices/nuc.go
@@ -0,0 +1,94 @@
+package devices
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"time"
+
+	"go.fuchsia.dev/infra/devices/power"
+	"go.fuchsia.dev/tools/netboot"
+)
+
+// Nuc represents an Intel NUC
+type Nuc struct {
+	fuchsiaDevice
+}
+
+// NewNuc initializes a NUC for use by other infra utilites.
+func NewNuc(config DeviceConfig, bootserverCmdStub []string) (*Nuc, error) {
+	n := &Nuc{
+		fuchsiaDevice{
+			power: power.NewAMTPowerManager(
+				config.Power.Host,
+				config.Power.Username,
+				config.Power.Password,
+			),
+		},
+	}
+	if err := initFuchsiaDevice(&n.fuchsiaDevice, config, bootserverCmdStub); err != nil {
+		return nil, err
+	}
+
+	// All transitions in a NUC attempt to get it into Zedboot.
+	transitionMap := map[DeviceState]*Transition{
+		Initial: &Transition{
+			PerformAction: n.sshRebootRecovery,
+			Validate:      n.checkTransitionSuccess,
+			SuccessState:  Healthy,
+			FailureState:  "ssh_failed",
+		},
+		"ssh_failed": &Transition{
+			PerformAction: n.serialRebootRecovery,
+			Validate:      n.checkTransitionSuccess,
+			SuccessState:  Healthy,
+			FailureState:  "serial_failed",
+		},
+		"serial_failed": &Transition{
+			PerformAction: n.Powercycle,
+			Validate:      n.checkTransitionSuccess,
+			SuccessState:  Healthy,
+			FailureState:  Unrecoverable,
+		},
+	}
+	n.transitionMap = transitionMap
+	return n, nil
+}
+
+// checkSerial performs an echo test over serial.
+func (n *Nuc) checkSerial(ctx context.Context) error {
+	if n.serial == nil {
+		return nil
+	}
+	cmdString := "echo hello"
+	resultString := "\r\n$ echo hello\r\nhello"
+
+	n.SendSerialCommand(ctx, cmdString)
+	buffer := make([]byte, len(resultString))
+	if _, err := io.ReadAtLeast(n.serial, buffer, len(resultString)); err != nil {
+		return err
+	}
+	if string(buffer) != resultString {
+		return fmt.Errorf("serial test got unexpected output: %s", string(buffer))
+	}
+	return nil
+}
+
+// checkTransitionSuccess is the validation function used by all transitions
+// on a NUC. It checks that serial works and the NUC is in Zedboot.
+func (n *Nuc) checkTransitionSuccess(ctx context.Context) error {
+	// Give the device time to transition.
+	time.Sleep(1 * time.Minute)
+	client := netboot.NewClient(netbootTimeout)
+	if err := n.checkSerial(ctx); err != nil {
+		return err
+	}
+	return deviceInZedboot(client, n.nodename)
+}
+
+// ToTaskState moves a NUC into a state in which it can run a test task.
+func (n *Nuc) ToTaskState(ctx context.Context) error {
+	// This assumes that there is a NUC reboot server running to
+	// deliver the build's version of zedboot.
+	return n.Powercycle(ctx)
+}
diff --git a/devices/pdu.go b/devices/pdu.go
deleted file mode 100644
index e7e4096..0000000
--- a/devices/pdu.go
+++ /dev/null
@@ -1,206 +0,0 @@
-// 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 devices
-
-import (
-	"bytes"
-	"crypto/tls"
-	"encoding/json"
-	"fmt"
-	"net/http"
-	"net/url"
-)
-
-// PDUConn encapsultes PDU connection settings.
-type PDUConn struct {
-	// Client is the client used to perform all requests.
-	Client *http.Client
-
-	// Host is the network hostname of the PDU.
-	Host string
-
-	// Username is the username by which we can log in to the PDU.
-	Username string
-
-	// Password is the password by which we can log in to the PDU.
-	Password string
-
-	// token is the authentication token obtained by Login.
-	token string
-}
-
-type message struct {
-	ID    int             `json:"msgid,omitempty"`
-	Reply string          `json:"reply,omitempty"`
-	Data  json.RawMessage `json:"data,omitempty"`
-	Error struct {
-		Name    string `json:"name"`
-		Message string `json:"message"`
-		Status  int    `json:"status"`
-	} `json:"error,omitempty"`
-}
-
-// NewPDUConn initializes and returns a new PDUConn struct.
-func NewPDUConn(host string, uname string, pwd string) *PDUConn {
-	return &PDUConn{
-		Client: &http.Client{
-			Transport: &http.Transport{
-				TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
-			},
-		},
-		Host:     host,
-		Username: uname,
-		Password: pwd,
-	}
-}
-
-// Login performs a login and obtains authorization token used for other calls.
-func (c *PDUConn) Login() error {
-	q := make(url.Values)
-	q.Set("username", c.Username)
-	q.Set("password", c.Password)
-
-	u := url.URL{
-		Scheme:   "https",
-		Host:     c.Host,
-		Path:     "/api/AuthenticationControllers/login",
-		RawQuery: q.Encode(),
-	}
-
-	l := struct {
-		Username string `json:"username"`
-		Password string `json:"password"`
-	}{
-		Username: c.Username,
-		Password: c.Password,
-	}
-
-	b := new(bytes.Buffer)
-	if err := json.NewEncoder(b).Encode(l); err != nil {
-		return err
-	}
-
-	req, err := http.NewRequest("POST", u.String(), b)
-	req.Header.Add("Accept", "application/json")
-	req.Header.Set("Content-Type", "application/json")
-	if err != nil {
-		return err
-	}
-	resp, err := c.Client.Do(req)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	var msg message
-	err = json.NewDecoder(resp.Body).Decode(&msg)
-
-	if resp.StatusCode != http.StatusCreated {
-		if err != nil {
-			return fmt.Errorf("request failed: %d", resp.StatusCode)
-		} else {
-			return fmt.Errorf("request failed: %s", msg.Error.Message)
-		}
-	}
-
-	if token, ok := resp.Header["Authorization"]; ok {
-		c.token = token[0]
-	}
-
-	return nil
-}
-
-// PDUReboot powercycles the given outlet.
-func PDUReboot(outlet int, host string, uname string, pwd string) error {
-	conn := NewPDUConn(host, uname, pwd)
-	if err := conn.Login(); err != nil {
-		return err
-	}
-	defer conn.Logout()
-	return conn.Loads(outlet, "Cycle")
-}
-
-// Loads changes the outlet state.
-func (c *PDUConn) Loads(outlet int, state string) error {
-	u := url.URL{
-		Scheme: "https",
-		Host:   c.Host,
-		Path:   fmt.Sprintf("/api/device/loads/%d", outlet),
-	}
-
-	s := struct {
-		LoadFireState string `json:"loadFireState"`
-	}{
-		LoadFireState: state,
-	}
-
-	b := new(bytes.Buffer)
-	if err := json.NewEncoder(b).Encode(s); err != nil {
-		return err
-	}
-
-	req, err := http.NewRequest("PUT", u.String(), b)
-	if err != nil {
-		return err
-	}
-	req.Header.Add("Accept", "application/json")
-	req.Header.Add("Authorization", c.token)
-	req.Header.Set("Content-Type", "application/json")
-	resp, err := c.Client.Do(req)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	var msg message
-	err = json.NewDecoder(resp.Body).Decode(&msg)
-
-	if resp.StatusCode != http.StatusOK {
-		if err != nil {
-			return fmt.Errorf("request failed: %d", resp.StatusCode)
-		} else {
-			return fmt.Errorf("request failed: %s", msg.Error.Message)
-		}
-	}
-
-	return nil
-}
-
-// Logout performs a logout and discards the authorization token.
-func (c *PDUConn) Logout() error {
-	u := url.URL{
-		Scheme: "https",
-		Host:   c.Host,
-		Path:   "/api/AuthenticationControllers/logout",
-	}
-
-	req, err := http.NewRequest("POST", u.String(), nil)
-	req.Header.Add("Accept", "application/json")
-	req.Header.Add("Authorization", c.token)
-	req.Header.Set("Content-Type", "application/json")
-	if err != nil {
-		return err
-	}
-	resp, err := c.Client.Do(req)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	var msg message
-	err = json.NewDecoder(resp.Body).Decode(&msg)
-
-	if resp.StatusCode != http.StatusOK {
-		if err != nil {
-			return fmt.Errorf("request failed: %d", resp.StatusCode)
-		} else {
-			return fmt.Errorf("request failed: %s", msg.Error.Message)
-		}
-	}
-
-	c.token = ""
-
-	return nil
-}
diff --git a/devices/power.go b/devices/power.go
deleted file mode 100644
index 72bffd3..0000000
--- a/devices/power.go
+++ /dev/null
@@ -1,176 +0,0 @@
-// 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 devices
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"io"
-	"log"
-	"strconv"
-	"time"
-
-	"go.fuchsia.dev/fuchsia/tools/net/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"
-
-	// Duration to wait before sending dm reboot-recovery after powercycle
-	powercycleWait = 1 * time.Minute
-)
-
-// PowerClient represents a power management configuration for a particular device.
-type PowerClient struct {
-	// Type is the type of manager to use.
-	Type string `json:"type"`
-
-	// Host is the network hostname of the manager
-	Host string `json:"host"`
-
-	// 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"`
-
-	// PDUIp is the IP address of the pdu
-	PDUIp string `json:"pduIp"`
-
-	// PDUPort is the port the PDU uses to connect to this device.
-	PDUPort string `json:"pduPort"`
-}
-
-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 PowerClient) RebootDevice(signers []ssh.Signer, nodename string, serial io.ReadWriter) error {
-	var rebooter Rebooter
-	log.Printf("Attempting to soft reboot device %s", nodename)
-	if serial == nil {
-		log.Printf("Using SSH to reboot.")
-		rebooter = NewSSHRebooter(nodename, signers)
-	} else {
-		log.Printf("Using serial to reboot.")
-		rebooter = NewSerialRebooter(serial)
-	}
-	return rebooter.reboot()
-}
-
-// Powercycle the device using an out of band method
-func (c PowerClient) Powercycle(nodename string, mac string, serial io.ReadWriter) error {
-	log.Printf("Attempting to powercycle device %s", nodename)
-	switch c.Type {
-	case "AMT":
-		log.Printf("Using AMT to powercycle.")
-		return AMTReboot(c.Host, c.Username, c.Password)
-	case "WOL":
-		if serial == nil {
-			return errors.New(fmt.Sprintf("WOL Reboot requires serial connection."))
-		}
-		log.Printf("Using WOL to powercycle")
-		if err := WOLReboot(botBroadcastAddr, botInterface, mac); err != nil {
-			return err
-		}
-	case "PDU":
-		if serial == nil {
-			return errors.New(fmt.Sprintf("PDU Reboot requires serial connection."))
-		}
-		log.Printf("Using PDU to powercycle")
-		port, err := strconv.Atoi(c.PDUPort)
-		if err != nil {
-			return err
-		}
-		if err := PDUReboot(port, c.PDUIp, c.Username, c.Password); err != nil {
-			return err
-		}
-	default:
-		return errors.New(fmt.Sprintf("%v does not have AMT, WOL, or PDU support. Cannot powercycle.", nodename))
-	}
-	// Send dm reboot-recovery to the device so that it always boots into zedboot.
-	log.Printf("Powercycle complete; using serial to send dm reboot recovery.")
-	time.Sleep(powercycleWait)
-	rebooter := NewSerialRebooter(serial)
-	return rebooter.reboot()
-}
-
-func NewSerialRebooter(serial io.ReadWriter) *SerialRebooter {
-	return &SerialRebooter{
-		serial: serial,
-	}
-}
-
-func (s *SerialRebooter) reboot() error {
-	_, err := io.WriteString(s.serial, "\ndm reboot-recovery\n")
-	return err
-}
-
-func NewSSHRebooter(nodename string, signers []ssh.Signer) *SshRebooter {
-	return &SshRebooter{
-		nodename: nodename,
-		signers:  signers,
-	}
-}
-
-func (s *SshRebooter) reboot() error {
-	config, err := sshutil.DefaultSSHConfigFromSigners(s.signers...)
-	if err != nil {
-		return err
-	}
-
-	client, err := sshutil.ConnectToNode(context.Background(), 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`
-	err = session.Start("dm reboot-recovery")
-	if err != nil {
-		return err
-	}
-
-	done := make(chan error)
-	go func() {
-		done <- session.Wait()
-	}()
-
-	select {
-	case err := <-done:
-		return err
-	case <-time.After(10*time.Second):
-		return nil
-	}
-}
diff --git a/devices/power/amt.go b/devices/power/amt.go
index 7f85b6f..d6268c7 100644
--- a/devices/power/amt.go
+++ b/devices/power/amt.go
@@ -5,6 +5,7 @@
 package power
 
 import (
+	"context"
 	"fmt"
 	"io/ioutil"
 	"net/http"
@@ -111,6 +112,6 @@
 	}
 }
 
-func (a *AMTPowerManager) Powercycle() error {
+func (a *AMTPowerManager) Powercycle(ctx context.Context) error {
 	return AMTReboot(a.host, a.user, a.pwd)
 }
diff --git a/devices/power/pdu.go b/devices/power/pdu.go
index c03d5fd..35f32ed 100644
--- a/devices/power/pdu.go
+++ b/devices/power/pdu.go
@@ -6,6 +6,7 @@
 
 import (
 	"bytes"
+	"context"
 	"crypto/tls"
 	"encoding/json"
 	"fmt"
@@ -221,6 +222,6 @@
 	}
 }
 
-func (p *PDUPowerManager) Powercycle() error {
+func (p *PDUPowerManager) Powercycle(ctx context.Context) error {
 	return PDUReboot(p.devicePort, p.pduIp, p.pduUser, p.pduPwd)
 }
diff --git a/devices/power/wol.go b/devices/power/wol.go
index f6333fd..fe0320c 100644
--- a/devices/power/wol.go
+++ b/devices/power/wol.go
@@ -5,6 +5,7 @@
 package power
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net"
@@ -128,6 +129,6 @@
 	}
 }
 
-func (w *WOLPowerManager) Powercycle() error {
+func (w *WOLPowerManager) Powercycle(ctx context.Context) error {
 	return WOLReboot(w.botBroadcastAddr, w.botInterface, w.mac)
 }
diff --git a/devices/test_data/config.json b/devices/test_data/config.json
new file mode 100644
index 0000000..94793c9
--- /dev/null
+++ b/devices/test_data/config.json
@@ -0,0 +1,44 @@
+[
+    {
+        "type": "Astro",
+        "network": {
+          "nodename": "astro-test-device",
+          "ipv4": "255.255.255.255",
+          "mac": "00:00:00:00:00:00"
+        },
+        "power": {
+            "host": "255.255.255.255",
+            "password": "password",
+            "pduIp": "255.255.255.255",
+            "pduPort": "1",
+            "type": "PDU",
+            "username": "username"
+        }
+    },
+    {
+        "type": "Intel NUC Kit NUC7i5DNHE",
+        "network": {
+          "nodename": "nuc-test-device",
+          "ipv4": "255.255.255.255",
+          "mac": "00:00:00:00:00:00"
+        },
+        "power": {
+            "username": "username",
+            "host": "255.255.255.255",
+            "password": "password",
+            "type": "AMT"
+        }
+    },
+    {
+        "type": "Khadas Vim2 Max",
+        "network": {
+          "nodename": "vim-test-device",
+          "ipv4": "255.255.255.255",
+          "mac": "00:00:00:00:00:00"
+        },
+        "power": {
+            "host": "255.255.255.255",
+            "type": "WOL"
+        }
+    }
+]
diff --git a/devices/test_data/invalid.json b/devices/test_data/invalid.json
new file mode 100644
index 0000000..d232445
--- /dev/null
+++ b/devices/test_data/invalid.json
@@ -0,0 +1,25 @@
+[
+    {
+        "type": "Astro",
+        "network": {
+          "nodename": "astro-test-device",
+          "ipv4": "255.255.255.255",
+          "mac": "00:00:00:00:00:00"
+        },
+        "power": 
+            "host": "255.255.255.255",
+            "password": "password",
+            "pduPort": "1",
+            "type": "PDU",
+            "username": "username"
+        },
+        ]
+    },
+    {
+        "type": "Intel NUC Kit NUC7i5DNHE",
+        "network": {
+          "nodename": "nuc-test-device",
+          "ipv4": "255.255.255.255",
+        ]
+    }
+]
diff --git a/devices/vim.go b/devices/vim.go
new file mode 100644
index 0000000..5076f94
--- /dev/null
+++ b/devices/vim.go
@@ -0,0 +1,96 @@
+package devices
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"syscall"
+	"time"
+
+	"go.fuchsia.dev/infra/devices/power"
+	"go.fuchsia.dev/tools/netboot"
+)
+
+// Vim represents a VIM2 (and eventually a VIM3)
+type Vim struct {
+	fuchsiaDevice
+}
+
+// NewVim initializes a Vim for use by other infra utilities.
+func NewVim(config DeviceConfig, bootserverCmdStub []string) (*Vim, error) {
+	v := &Vim{
+		fuchsiaDevice{
+			power: power.NewWOLPowerManager(config.Network.Mac),
+		},
+	}
+	if err := initFuchsiaDevice(&v.fuchsiaDevice, config, bootserverCmdStub); err != nil {
+		return nil, err
+	}
+
+	// All transitions in Vim attempt to get the device into zedboot.
+	transitionMap := map[DeviceState]*Transition{
+		Initial: &Transition{
+			PerformAction: v.sshRebootRecovery,
+			Validate:      v.checkTransitionSuccess,
+			SuccessState:  Healthy,
+			FailureState:  "ssh_failed",
+		},
+		"ssh_failed": &Transition{
+			PerformAction: v.serialRebootRecovery,
+			Validate:      v.checkTransitionSuccess,
+			SuccessState:  Healthy,
+			FailureState:  "serial_failed",
+		},
+		"serial_failed": &Transition{
+			PerformAction: v.Powercycle,
+			Validate:      v.checkTransitionSuccess,
+			SuccessState:  Healthy,
+			FailureState:  Unrecoverable,
+		},
+	}
+	v.transitionMap = transitionMap
+	return v, nil
+}
+
+// checkTransitionSuccess is the validation function used by all transitions
+// performed by Vim. It checks that the device is in zedboot.
+func (v *Vim) checkTransitionSuccess(ctx context.Context) error {
+	// Give the device time to transition.
+	time.Sleep(1 * time.Minute)
+	client := netboot.NewClient(netbootTimeout)
+	return deviceInZedboot(client, v.nodename)
+}
+
+// ToTaskState puts the Vim into a state in which it can run a test task.
+func (v *Vim) ToTaskState(ctx context.Context) error {
+	cmd := exec.Command(v.BootserverCmd[0], v.BootserverCmd[1:]...)
+
+	// Spin up handler to exit subprocess cleanly on sigterm.
+	processDone := make(chan bool, 1)
+	go func() {
+		select {
+		case <-processDone:
+		case <-ctx.Done():
+			if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
+				log.Printf("exited cmd with error %v", err)
+			}
+		}
+	}()
+
+	// Ensure the context still exists before running the subprocess.
+	if ctx.Err() != nil {
+		return nil
+	}
+
+	// Run the bootserver.
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	cmd.Run()
+	processDone <- true
+	if !cmd.ProcessState.Success() {
+		return fmt.Errorf("bootserver for %s exited with exit code %d", v.nodename, cmd.ProcessState.ExitCode())
+	}
+	return nil
+}
diff --git a/devices/wol.go b/devices/wol.go
deleted file mode 100644
index cb3a861..0000000
--- a/devices/wol.go
+++ /dev/null
@@ -1,107 +0,0 @@
-// 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 devices
-
-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 WOLReboot(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/go.mod b/go.mod
index 817aa63..b68159f 100644
--- a/go.mod
+++ b/go.mod
@@ -10,6 +10,7 @@
 	github.com/docker/go-units v0.3.3 // indirect
 	github.com/gogo/protobuf v1.1.1 // indirect
 	github.com/golang/protobuf v1.3.2
+	github.com/google/go-cmp v0.4.0
 	github.com/google/subcommands v1.0.1
 	github.com/google/uuid v1.1.1
 	github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
diff --git a/go.sum b/go.sum
index f92df4b..ce78550 100644
--- a/go.sum
+++ b/go.sum
@@ -40,6 +40,8 @@
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@@ -173,6 +175,7 @@
 golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
 golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
 golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
 google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
 google.golang.org/api v0.8.0 h1:VGGbLNyPF7dvYHhcUGYBBGCRDDK0RRJAI6KCvo0CL+E=