[devices] Add SSH and separate powercycle

This change does two things:
1) I removed SSH reboot to avoid some catalyst errors. I realize
now that these are errors we should surface, so I added it back.
2) I separated out of band reboot functions into their own powercycle
function.

Change-Id: I1cdb7abea56fed765680656072e3b193badf71a8
diff --git a/devices/device.go b/devices/device.go
index ec73983..a760618 100644
--- a/devices/device.go
+++ b/devices/device.go
@@ -144,13 +144,21 @@
 		defer t.serial.Close()
 	}
 	if t.config.Power != nil {
-		if err := t.config.Power.RebootDevice(t.Nodename(), t.serial); err != 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
 }
 
+// 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())
+	}
+	return nil
+}
+
 func parseOutSigners(keyPaths []string) ([]ssh.Signer, error) {
 	if len(keyPaths) == 0 {
 		return nil, errors.New("must supply SSH keys in the config")
diff --git a/devices/power.go b/devices/power.go
index db4bd34..e6006e0 100644
--- a/devices/power.go
+++ b/devices/power.go
@@ -5,9 +5,13 @@
 package devices
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"io"
+
+	"go.fuchsia.dev/fuchsia/tools/net/sshutil"
+	"golang.org/x/crypto/ssh"
 )
 
 // TODO(IN-977) Clean this up per suggestions in go/fxr/251550
@@ -47,19 +51,29 @@
 	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(nodename string, serial io.ReadWriter) error {
+func (c PowerClient) RebootDevice(signers []ssh.Signer, nodename string, serial io.ReadWriter) error {
 	var rebooter Rebooter
 	if serial != nil {
 		rebooter = NewSerialRebooter(serial)
-		return rebooter.reboot()
+	} else {
+		rebooter = NewSSHRebooter(nodename, signers)
 	}
+	return rebooter.reboot()
+}
 
+// Powercycle the device using an out of band method
+func (c PowerClient) Powercycle(nodename string) error {
 	switch c.Type {
 		case "amt":
 			return AMTReboot(c.Host, c.Username, c.Password)
@@ -68,7 +82,7 @@
 		case "pdu":
 			return PDUReboot(c.PDUPort, c.Host, c.Username, c.Password)
 		default:
-			return errors.New(fmt.Sprintf("%v does not have serial, AMT, or WOL support. Cannot reboot.", nodename))
+			return errors.New(fmt.Sprintf("%v does not AMT, WOL, or PDU support. Cannot powercycle.", nodename))
 	}
 }
 
@@ -83,3 +97,47 @@
 	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` with a 2 second delay in the background, then exit the SSH shell.
+	// This prevents the SSH connection from hanging waiting for `dm reboot-recovery` to return.
+	err = session.Start("{ sleep 2; dm reboot-recovery; } >/dev/null & exit")
+	if err != nil {
+		return err
+	}
+
+	done := make(chan error)
+	go func() {
+		done <- session.Wait()
+	}()
+
+	select {
+	case err := <-done:
+		return err
+	}
+}