Revert "[devices] Model devices as FSMs"

This reverts commit c9f435d4de72ab4cb8e01ac70621a0e04dc14647.

Reason for revert: This code is moving to google3.

Original change's description:
> [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

TBR=nmulcahey@google.com,rudymathu@google.com

# Not skipping CQ checks because original CL landed > 1 day ago.

Change-Id: I4452c72d71cd420427537db2460caa210dcebf73
diff --git a/cmd/catalyst/catalyst_test.go b/cmd/catalyst/catalyst_test.go
index 5e887d0..2f5f115 100644
--- a/cmd/catalyst/catalyst_test.go
+++ b/cmd/catalyst/catalyst_test.go
@@ -7,7 +7,6 @@
 import (
 	"context"
 	"fmt"
-	"log"
 	"math/rand"
 	"net"
 	"net/http"
@@ -84,29 +83,43 @@
 	checkExitCode(t, exitCode, successExitCode)
 }
 
-func createMockDevice(name string, mac string, cmd []string) devicePkg.Device {
+func createMockDevice(name string, mac string, cmd []string) *devicePkg.DeviceTarget {
+	target := &devicePkg.DeviceTarget{BootserverCmd: cmd}
 	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, err := devicePkg.NewNuc(config, cmd)
-	if err != nil {
-		log.Fatalf("creating mock device failed: %v", err)
-	}
+	target.SetConfig(config)
 	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.Device
+	var devices []*devicePkg.DeviceTarget
 	srv, err := runNUCServer(context.Background(), devices, false)
 	if srv != nil {
 		t.Errorf("NUC server was created without port set.")
@@ -117,7 +130,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.Device
+	var devices []*devicePkg.DeviceTarget
 	os.Setenv(portEnvVar, "8000")
 	srv, err := runNUCServer(context.Background(), devices, true)
 	if srv != nil {
@@ -130,7 +143,7 @@
 
 // Test that NUC server is started up when port environment variable is set.
 func TestNUCServerExistence(t *testing.T) {
-	var devices []devicePkg.Device
+	var devices []*devicePkg.DeviceTarget
 	os.Setenv(portEnvVar, "8000")
 	srv, err := runNUCServer(context.Background(), devices, false)
 	os.Setenv(portEnvVar, "")
@@ -147,7 +160,7 @@
 func TestNUCServerEndpoints(t *testing.T) {
 	// Create mock devices
 	cmd := constructDummyCmd(false)
-	devices := []devicePkg.Device{
+	devices := []*devicePkg.DeviceTarget{
 		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 5ecd219..6b3541c 100644
--- a/cmd/catalyst/main.go
+++ b/cmd/catalyst/main.go
@@ -79,9 +79,7 @@
 	return cmd.ProcessState.ExitCode()
 }
 
-// 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) {
+func runNUCServer(ctx context.Context, devices []*devicePkg.DeviceTarget, disabled bool) (*http.Server, error) {
 	// Parse the port from the environment variable.
 	portStr := os.Getenv(portEnvVar)
 	if portStr == "" || disabled {
@@ -126,36 +124,48 @@
 	return srv, nil
 }
 
-// prepDevices puts all devices into TaskState.
-func prepDevices(ctx context.Context, devices []devicePkg.Device) error {
-	// Put each device into TaskState.
-	errorChannel := make(chan error)
+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
 	for _, device := range devices {
-		go func(device devicePkg.Device) {
-			errorChannel <- device.ToTaskState(ctx)
-		}(device)
-	}
-	for i := 0; i < len(devices); i++ {
-		if err := <-errorChannel; err != nil {
-			return err
+		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)
 		}
 	}
-	return nil
+
+	// 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
 }
 
-// hasNUC returns true iff this testbed contains a NUC.
-func hasNUC(devices []devicePkg.Device) bool {
+func hasNUC(devices []*devicePkg.DeviceTarget) bool {
 	for _, device := range devices {
-		if _, ok := device.(*devicePkg.Nuc); ok {
+		if device.Type() == "nuc" {
 			return true
 		}
 	}
 	return false
 }
 
-// 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 {
+func downloadZedboot(ctx context.Context, imageURL *url.URL, devices []*devicePkg.DeviceTarget) error {
 	// Ensure that we have a NUC that needs a local zedboot.zbi.
 	if !hasNUC(devices) {
 		return nil
@@ -211,12 +221,8 @@
 		"-n",
 	}
 
-	// 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)
+	// Create devicePkg.DeviceTargets for each of the devices in the config file
+	devices, err := devicePkg.CreateDeviceTargets(ctx, deviceConfigPath, bootserverCmdStub)
 	if err != nil {
 		return failureExitCode, err
 	}
@@ -251,8 +257,8 @@
 		}
 	}()
 
-	if err := prepDevices(ctx, devices); err != nil {
-		return failureExitCode, err
+	if exitCode := runBootservers(ctx, devices); exitCode != 0 {
+		return exitCode, nil
 	}
 
 	time.Sleep(rebootDuration)
diff --git a/cmd/health_checker/main.go b/cmd/health_checker/main.go
index b560a36..61cca5b 100644
--- a/cmd/health_checker/main.go
+++ b/cmd/health_checker/main.go
@@ -8,6 +8,7 @@
 	"encoding/json"
 	"flag"
 	"fmt"
+	"io"
 	"log"
 	"net"
 	"os"
@@ -34,9 +35,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.
@@ -91,17 +92,20 @@
 // 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.Device) error {
-	if _, ok := device.(*devicePkg.Nuc); !ok {
+func checkSerial(device *devicePkg.DeviceTarget) error {
+	if device.Type() != "nuc" {
 		return nil
 	}
-	cmdString := "echo hello"
+	if device.Serial() == nil {
+		return nil
+	}
+	cmdString := "\necho hello\n"
 	resultString := "\r\n$ echo hello\r\nhello"
-	if err := device.SendSerialCommand(context.Background(), cmdString); err != nil {
+	if _, err := io.WriteString(device.Serial(), cmdString); err != nil {
 		return err
 	}
 	buffer := make([]byte, len(resultString))
-	if err := device.ReadSerialData(context.Background(), buffer); err != nil {
+	if _, err := io.ReadAtLeast(device.Serial(), buffer, len(resultString)); err != nil {
 		return err
 	}
 	if string(buffer) != resultString {
@@ -113,8 +117,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.Device) error {
-	if _, ok := device.(*devicePkg.ArmCDCether); !ok {
+func checkBroadcasting(n *netboot.Client, device *devicePkg.DeviceTarget) error {
+	if device.Type() == "nuc" {
 		return nil
 	}
 	if _, err := n.Beacon(); err != nil {
@@ -123,7 +127,7 @@
 	return nil
 }
 
-func checkHealth(n *netboot.Client, device devicePkg.Device) HealthCheckResult {
+func checkHealth(n *netboot.Client, device *devicePkg.DeviceTarget) HealthCheckResult {
 	nodename := device.Nodename()
 	log.Printf("Checking health for %s", nodename)
 	// Check the device is in zedboot.
@@ -169,11 +173,7 @@
 	flag.Parse()
 	client := netboot.NewClient(timeout)
 	ctx := context.Background()
-	configs, err := devicePkg.LoadDeviceConfigs(configFile)
-	if err != nil {
-		log.Fatal(err)
-	}
-	devices, err := devicePkg.CreateDevices(ctx, configs, nil)
+	devices, err := devicePkg.CreateDeviceTargets(ctx, configFile, nil)
 	if err != nil {
 		log.Fatal(err)
 	}
@@ -188,18 +188,15 @@
 	if forceReboot {
 		for _, device := range devices {
 			log.Printf("attempting forced device restart for: %s", device.Nodename())
-			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)
+			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())
 				}
 			} else {
-				if err := device.SoftReboot(ctx, "R", "ssh"); err != nil {
-					log.Printf("ssh reboot failed: %v", err)
+				log.Printf("device has serial, restarting")
+				if err := device.Restart(ctx); err != nil {
+					log.Printf("forced restart failed with error: %s", err.Error())
 				}
 			}
 			log.Printf("forced restart for device %s is complete", device.Nodename())
@@ -212,19 +209,16 @@
 		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 {
-				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)
+			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"
 				}
 			} else {
-				if err := device.SoftReboot(ctx, "R", "ssh"); err != nil {
-					log.Printf("ssh reboot failed: %v", err)
-				}
+				log.Printf("powercycle call succeeded for %s", device.Nodename())
 			}
 		}
 		checkResultSlice = append(checkResultSlice, checkResult)
diff --git a/devices/amt.go b/devices/amt.go
new file mode 100644
index 0000000..40cc5b4
--- /dev/null
+++ b/devices/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 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
deleted file mode 100644
index ac45ed9..0000000
--- a/devices/arm_cdcether.go
+++ /dev/null
@@ -1,137 +0,0 @@
-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
deleted file mode 100644
index 81624da..0000000
--- a/devices/config.go
+++ /dev/null
@@ -1,150 +0,0 @@
-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 1069375..1c3b651 100644
--- a/devices/device.go
+++ b/devices/device.go
@@ -2,279 +2,217 @@
 // 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 (
-	// 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
+	// The duration we allow for the netstack to come up when booting.
+	netstackTimeout = 90 * time.Second
 )
 
-// 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
+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"`
 }
 
-// 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
+// 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
 	signers       []ssh.Signer
 	serial        io.ReadWriteCloser
 	BootserverCmd []string
-	power         powerManager
 }
 
-// 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)
+// 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)
 		if err != nil {
-			return fmt.Errorf("could not parse out signers from private keys: %v", err)
+			return nil, 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 {
-			return fmt.Errorf("could not open serial line: %s", config.Serial)
+			log.Printf("unable to open %s: %v", config.Serial, err)
 		}
 	}
-	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
+	deviceType, err := ConvertConfigType(config)
+	if err != nil {
+		return nil, err
+	}
+	return &DeviceTarget{
+		deviceType:    deviceType,
+		config:        config,
+		signers:       signers,
+		serial:        s,
+		BootserverCmd: append(bootserverCmdStub, config.Network.Nodename),
+	}, nil
 }
 
-// Mac returns the mac address of this device.
-func (f *fuchsiaDevice) Mac() string {
-	return f.mac
+// 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
+	}
+	return "", fmt.Errorf("Invalid device type in configs: %s", config.Type)
 }
 
-// Interface returns the network interface of this device.
-func (f *fuchsiaDevice) Interface() string {
-	return f.networkIf
+// SetConfig sets the config field of the given DeviceTarget
+func (t *DeviceTarget) SetConfig(config DeviceConfig) {
+	t.config = config
 }
 
-// HasSerial returns true if this device has a serial line.
-func (f *fuchsiaDevice) HasSerial() bool {
-	return f.serial != nil
+// Nodename returns the name of the node.
+func (t *DeviceTarget) Nodename() string {
+	return t.config.Network.Nodename
 }
 
-// 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)
+// 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()
+	}
+	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
 }
 
-// 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 err
-	}
-
-	client, err := sshutil.ConnectToNode(ctx, f.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(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)
+// 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)
 	}
 	return nil
 }
 
-// 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)
+func parseOutSigners(keyPaths []string) ([]ssh.Signer, error) {
+	if len(keyPaths) == 0 {
+		return nil, errors.New("must supply SSH keys in the config")
 	}
-	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.")
+	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)
 	}
-	return nil
-}
 
-// 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 signers []ssh.Signer
+	for _, p := range keys {
+		signer, err := ssh.ParsePrivateKey(p)
+		if err != nil {
+			return nil, err
+		}
+		signers = append(signers, signer)
 	}
-	if err := ensureNotFuchsia(n, nodename); err != nil {
-		return err
-	}
-	return nil
+	return signers, nil
 }
diff --git a/devices/devices_test.go b/devices/devices_test.go
deleted file mode 100644
index 1cfd2ef..0000000
--- a/devices/devices_test.go
+++ /dev/null
@@ -1,119 +0,0 @@
-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
deleted file mode 100644
index ad62411..0000000
--- a/devices/nuc.go
+++ /dev/null
@@ -1,94 +0,0 @@
-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
new file mode 100644
index 0000000..e7e4096
--- /dev/null
+++ b/devices/pdu.go
@@ -0,0 +1,206 @@
+// 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
new file mode 100644
index 0000000..72bffd3
--- /dev/null
+++ b/devices/power.go
@@ -0,0 +1,176 @@
+// 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 d6268c7..7f85b6f 100644
--- a/devices/power/amt.go
+++ b/devices/power/amt.go
@@ -5,7 +5,6 @@
 package power
 
 import (
-	"context"
 	"fmt"
 	"io/ioutil"
 	"net/http"
@@ -112,6 +111,6 @@
 	}
 }
 
-func (a *AMTPowerManager) Powercycle(ctx context.Context) error {
+func (a *AMTPowerManager) Powercycle() error {
 	return AMTReboot(a.host, a.user, a.pwd)
 }
diff --git a/devices/power/pdu.go b/devices/power/pdu.go
index 35f32ed..c03d5fd 100644
--- a/devices/power/pdu.go
+++ b/devices/power/pdu.go
@@ -6,7 +6,6 @@
 
 import (
 	"bytes"
-	"context"
 	"crypto/tls"
 	"encoding/json"
 	"fmt"
@@ -222,6 +221,6 @@
 	}
 }
 
-func (p *PDUPowerManager) Powercycle(ctx context.Context) error {
+func (p *PDUPowerManager) Powercycle() error {
 	return PDUReboot(p.devicePort, p.pduIp, p.pduUser, p.pduPwd)
 }
diff --git a/devices/power/wol.go b/devices/power/wol.go
index fe0320c..f6333fd 100644
--- a/devices/power/wol.go
+++ b/devices/power/wol.go
@@ -5,7 +5,6 @@
 package power
 
 import (
-	"context"
 	"errors"
 	"fmt"
 	"net"
@@ -129,6 +128,6 @@
 	}
 }
 
-func (w *WOLPowerManager) Powercycle(ctx context.Context) error {
+func (w *WOLPowerManager) Powercycle() error {
 	return WOLReboot(w.botBroadcastAddr, w.botInterface, w.mac)
 }
diff --git a/devices/test_data/config.json b/devices/test_data/config.json
deleted file mode 100644
index 94793c9..0000000
--- a/devices/test_data/config.json
+++ /dev/null
@@ -1,44 +0,0 @@
-[
-    {
-        "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
deleted file mode 100644
index d232445..0000000
--- a/devices/test_data/invalid.json
+++ /dev/null
@@ -1,25 +0,0 @@
-[
-    {
-        "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
deleted file mode 100644
index 5076f94..0000000
--- a/devices/vim.go
+++ /dev/null
@@ -1,96 +0,0 @@
-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
new file mode 100644
index 0000000..cb3a861
--- /dev/null
+++ b/devices/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 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 b68159f..817aa63 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,6 @@
 	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 ce78550..f92df4b 100644
--- a/go.sum
+++ b/go.sum
@@ -40,8 +40,6 @@
 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=
@@ -175,7 +173,6 @@
 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=