[serial][reboot] Add serial rebooting.

This adds support for rebooting over serial and gives preference to it
if available.

Change-Id: Ib5c51afce89aaafcb794348edc380341037ec7b9
diff --git a/botanist/power/power.go b/botanist/power/power.go
index 4884420..dc35b0e 100644
--- a/botanist/power/power.go
+++ b/botanist/power/power.go
@@ -6,6 +6,7 @@
 
 import (
 	"context"
+	"io"
 
 	"fuchsia.googlesource.com/tools/botanist/power/amt"
 	"fuchsia.googlesource.com/tools/botanist/power/wol"
@@ -45,11 +46,30 @@
 	Password string `json:"password"`
 }
 
+type Rebooter interface {
+	reboot() error
+}
+
+type SshRebooter struct {
+	nodename string
+	signers  []ssh.Signer
+}
+
+type SerialRebooter struct {
+	serial io.ReadWriter
+}
+
 // RebootDevice attempts to reboot the specified device into recovery, and
 // additionally uses the given configuration to reboot the device if specified.
-func (c Client) RebootDevice(signers []ssh.Signer, nodename string) error {
+func (c Client) RebootDevice(signers []ssh.Signer, nodename string, serial io.ReadWriter) error {
+	var rebooter Rebooter
+	if serial != nil {
+		rebooter = NewSerialRebooter(serial)
+	} else {
+		rebooter = NewSSHRebooter(nodename, signers)
+	}
 	// Always attempt to soft reboot the device to recovery.
-	err := rebootRecovery(nodename, signers)
+	err := rebooter.reboot()
 	if err != nil {
 		logger.Warningf(context.Background(), "soft reboot failed: %v", err)
 	}
@@ -65,20 +85,32 @@
 	}
 }
 
-func rebootRecovery(nodeName string, signers []ssh.Signer) error {
-	// Invoke `dm reboot-recovery` with a 2 second delay in the background, then exit the SSH shell.
-	// This prevents the SSH connection hanging waiting for `dm reboot-recovery to return.`
-	return sendCommand(nodeName, "{ sleep 2; dm reboot-recovery; } >/dev/null & exit", signers)
+func NewSerialRebooter(serial io.ReadWriter) *SerialRebooter {
+	return &SerialRebooter{
+		serial: serial,
+	}
 }
 
-func sendCommand(nodeName, command string, signers []ssh.Signer) error {
-	config, err := sshutil.DefaultSSHConfigFromSigners(signers...)
+func NewSSHRebooter(nodename string, signers []ssh.Signer) *SshRebooter {
+	return &SshRebooter{
+		nodename: nodename,
+		signers:  signers,
+	}
+}
+
+func (s *SerialRebooter) reboot() error {
+	_, err := io.WriteString(s.serial, "\ndm reboot-recovery\n")
+	return err
+}
+
+func (s *SshRebooter) reboot() error {
+	config, err := sshutil.DefaultSSHConfigFromSigners(s.signers...)
 	if err != nil {
 		return err
 	}
 
 	ctx := context.Background()
-	client, err := sshutil.ConnectToNode(ctx, nodeName, config)
+	client, err := sshutil.ConnectToNode(ctx, s.nodename, config)
 	if err != nil {
 		return err
 	}
@@ -92,7 +124,9 @@
 
 	defer session.Close()
 
-	err = session.Start(command)
+	// 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
 	}
diff --git a/botanist/target/device.go b/botanist/target/device.go
index 5840da2..6c14c9b 100644
--- a/botanist/target/device.go
+++ b/botanist/target/device.go
@@ -9,6 +9,7 @@
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"io/ioutil"
 	"net"
 	"time"
@@ -16,8 +17,10 @@
 	"fuchsia.googlesource.com/tools/botanist"
 	"fuchsia.googlesource.com/tools/botanist/power"
 	"fuchsia.googlesource.com/tools/build"
+	"fuchsia.googlesource.com/tools/logger"
 	"fuchsia.googlesource.com/tools/netboot"
 	"fuchsia.googlesource.com/tools/netutil"
+	"fuchsia.googlesource.com/tools/serial"
 
 	"golang.org/x/crypto/ssh"
 )
@@ -71,10 +74,11 @@
 	config  DeviceConfig
 	opts    Options
 	signers []ssh.Signer
+	serial  io.ReadWriteCloser
 }
 
 // NewDeviceTarget returns a new device target with a given configuration.
-func NewDeviceTarget(config DeviceConfig, opts Options) (*DeviceTarget, error) {
+func NewDeviceTarget(ctx context.Context, config DeviceConfig, opts Options) (*DeviceTarget, error) {
 	// If an SSH key is specified in the options, prepend it the configs list so that it
 	// corresponds to the authorized key that would be paved.
 	if opts.SSHKey != "" {
@@ -84,10 +88,21 @@
 	if err != nil {
 		return nil, fmt.Errorf("could not parse out signers from private keys: %v", err)
 	}
+	var s io.ReadWriteCloser
+	if config.Serial != "" {
+		s, err = serial.Open(config.Serial)
+		if err != nil {
+			// TODO(IN-????): This should be returned as an error, but we don't want to fail any
+			// test runs for misconfigured serial until it is actually required to complete certain
+			// tasks.
+			logger.Errorf(ctx, "unable to open %s: %v", config.Serial, err)
+		}
+	}
 	return &DeviceTarget{
 		config:  config,
 		opts:    opts,
 		signers: signers,
+		serial:  s,
 	}, nil
 }
 
@@ -106,8 +121,8 @@
 }
 
 // Serial returns the serial device associated with the target for serial i/o.
-func (t *DeviceTarget) Serial() string {
-	return t.config.Serial
+func (t *DeviceTarget) Serial() io.ReadWriteCloser {
+	return t.serial
 }
 
 // SSHKey returns the private SSH key path associated with the authorized key to be paved.
@@ -155,8 +170,11 @@
 
 // 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()); 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)
 		}
 	}
diff --git a/botanist/target/qemu.go b/botanist/target/qemu.go
index 77f368a..4ce32e7 100644
--- a/botanist/target/qemu.go
+++ b/botanist/target/qemu.go
@@ -101,8 +101,8 @@
 }
 
 // Serial returns the serial device associated with the target for serial i/o.
-func (t *QEMUTarget) Serial() string {
-	return ""
+func (t *QEMUTarget) Serial() io.ReadWriteCloser {
+	return nil
 }
 
 // SSHKey returns the private SSH key path associated with the authorized key to be pavet.
diff --git a/cmd/botanist/run.go b/cmd/botanist/run.go
index bb6cb46..60a425d 100644
--- a/cmd/botanist/run.go
+++ b/cmd/botanist/run.go
@@ -20,7 +20,6 @@
 	"fuchsia.googlesource.com/tools/command"
 	"fuchsia.googlesource.com/tools/logger"
 	"fuchsia.googlesource.com/tools/runner"
-	"fuchsia.googlesource.com/tools/serial"
 	"fuchsia.googlesource.com/tools/sshutil"
 
 	"github.com/google/subcommands"
@@ -39,7 +38,7 @@
 	IPv4Addr() (net.IP, error)
 
 	// Serial returns the serial device associated with the target for serial i/o.
-	Serial() string
+	Serial() io.ReadWriteCloser
 
 	// SSHKey returns the private key corresponding an authorized SSH key of the target.
 	SSHKey() string
@@ -216,7 +215,7 @@
 
 	var targets []Target
 	for _, obj := range objs {
-		t, err := DeriveTarget(obj, opts)
+		t, err := DeriveTarget(ctx, obj, opts)
 		if err != nil {
 			return err
 		}
@@ -237,36 +236,32 @@
 		defer syslog.Close()
 	}
 
-	if t.Serial() != "" && r.serialLogFile != "" {
-		serialLog, err := os.Create(r.serialLogFile)
-		if err != nil {
-			return err
-		}
-		defer serialLog.Close()
-
-		serialDevice, err := serial.Open(t.Serial())
-		if err != nil {
-			return fmt.Errorf("unable to open %s: %v", t.Serial(), err)
-		}
-		defer serialDevice.Close()
-
-		// Here we invoke the `dlog` command over serial to tail the existing log buffer into the
-		// output file.  This should give us everything since Zedboot boot, and new messages should
-		// be written to directly to the serial port without needing to tail with `dlog -f`.
-		if _, err = io.WriteString(serialDevice, "\ndlog\n"); err != nil {
-			logger.Errorf(ctx, "failed to tail zedboot dlog: %v", err)
-		}
-
-		go func() {
-			_, err := io.Copy(serialLog, serialDevice)
+	if t.Serial() != nil {
+		if r.serialLogFile != "" {
+			serialLog, err := os.Create(r.serialLogFile)
 			if err != nil {
-				logger.Errorf(ctx, "failed to write serial log: %v", err)
+				return err
 			}
-		}()
+			defer serialLog.Close()
 
-		// Modify the zirconArgs passed to the kernel on boot to enable serial logging on x64.
+			// Here we invoke the `dlog` command over serial to tail the existing log buffer into the
+			// output file.  This should give us everything since Zedboot boot, and new messages should
+			// be written to directly to the serial port without needing to tail with `dlog -f`.
+			if _, err = io.WriteString(t.Serial(), "\ndlog\n"); err != nil {
+				logger.Errorf(ctx, "failed to tail zedboot dlog: %v", err)
+			}
+
+			go func() {
+				_, err := io.Copy(serialLog, t.Serial())
+				if err != nil {
+					logger.Errorf(ctx, "failed to write serial log: %v", err)
+				}
+			}()
+			r.zirconArgs = append(r.zirconArgs, "kernel.bypass-debuglog=true")
+		}
+		// Modify the zirconArgs passed to the kernel on boot to enable serial on x64.
 		// arm64 devices should already be enabling kernel.serial at compile time.
-		r.zirconArgs = append(r.zirconArgs, "kernel.bypass-debuglog=true", "kernel.serial=legacy")
+		r.zirconArgs = append(r.zirconArgs, "kernel.serial=legacy")
 	}
 
 	defer func() {
@@ -311,7 +306,7 @@
 	return subcommands.ExitSuccess
 }
 
-func DeriveTarget(obj []byte, opts target.Options) (Target, error) {
+func DeriveTarget(ctx context.Context, obj []byte, opts target.Options) (Target, error) {
 	type typed struct {
 		Type string `json:"type"`
 	}
@@ -332,7 +327,7 @@
 		if err := json.Unmarshal(obj, &cfg); err != nil {
 			return nil, fmt.Errorf("invalid device config found: %v", err)
 		}
-		t, err := target.NewDeviceTarget(cfg, opts)
+		t, err := target.NewDeviceTarget(ctx, cfg, opts)
 		return t, err
 	default:
 		return nil, fmt.Errorf("unknown type found: %q", x.Type)
diff --git a/cmd/botanist/zedboot.go b/cmd/botanist/zedboot.go
index 5caaea1..42d85c1 100644
--- a/cmd/botanist/zedboot.go
+++ b/cmd/botanist/zedboot.go
@@ -200,7 +200,7 @@
 
 	var devices []*target.DeviceTarget
 	for _, config := range configs {
-		device, err := target.NewDeviceTarget(config, opts)
+		device, err := target.NewDeviceTarget(ctx, config, opts)
 		if err != nil {
 			return err
 		}
diff --git a/cmd/health_checker/main.go b/cmd/health_checker/main.go
index add3f95..5ea1a90 100644
--- a/cmd/health_checker/main.go
+++ b/cmd/health_checker/main.go
@@ -109,7 +109,7 @@
 
 	var devices []*target.DeviceTarget
 	for _, config := range configs {
-		device, err := target.NewDeviceTarget(config, target.Options{})
+		device, err := target.NewDeviceTarget(context.Background(), config, target.Options{})
 		if err != nil {
 			log.Fatal(err)
 		}