[botanist] Introduce device target abstraction
This is similar to the target abstraction for QEMU.
Change-Id: I415080428259092d43ea8f50228912ff1047b2cf
diff --git a/botanist/target/device.go b/botanist/target/device.go
index 0ea5b11..5df0ae3 100644
--- a/botanist/target/device.go
+++ b/botanist/target/device.go
@@ -5,11 +5,19 @@
package target
import (
+ "context"
"encoding/json"
+ "errors"
"fmt"
"io/ioutil"
+ "net"
+ "time"
+ "fuchsia.googlesource.com/tools/botanist"
"fuchsia.googlesource.com/tools/botanist/power"
+ "fuchsia.googlesource.com/tools/build"
+ "fuchsia.googlesource.com/tools/netboot"
+ "fuchsia.googlesource.com/tools/netutil"
"golang.org/x/crypto/ssh"
)
@@ -26,7 +34,24 @@
SSHKeys []string `json:"keys,omitempty"`
}
-// LoadDeviceConfigs unmarshalls a slice of DeviceConfigs from a given file.
+// DeviceOptions represents lifecycle options for a target.
+type DeviceOptions struct {
+ // Netboot gives whether to netboot or pave. Netboot here is being used in the
+ // colloquial sense of only sending netsvc a kernel to mexec. If false, the target
+ // will be paved.
+ Netboot bool
+
+ // SSHKey is a private SSH key file. If provided, the corresponding authorized key
+ // will be paved.
+ SSHKey string
+
+ // Fastboot is a path to the fastboot binary. If provided, it will be assumed that
+ // the device is waiting in fastboot mode, and it will be attempted to 'continue'
+ // it into zedboot.
+ Fastboot string
+}
+
+// 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 {
@@ -40,25 +65,126 @@
return configs, nil
}
-// Returns the SSH signers associated with the key paths in the botanist config file if present.
-func SSHSignersFromConfigs(configs []DeviceConfig) ([]ssh.Signer, error) {
- processedKeys := make(map[string]bool)
- var signers []ssh.Signer
- for _, config := range configs {
- for _, keyPath := range config.SSHKeys {
- if !processedKeys[keyPath] {
- processedKeys[keyPath] = true
- p, err := ioutil.ReadFile(keyPath)
- if err != nil {
- return nil, err
- }
- s, err := ssh.ParsePrivateKey(p)
- if err != nil {
- return nil, err
- }
- signers = append(signers, s)
+// DeviceTarget represents a target device.
+type DeviceTarget struct {
+ config *DeviceConfig
+ opts *DeviceOptions
+ signers []ssh.Signer
+}
+
+// NewDeviceTarget returns a new device target with a given configuration.
+func NewDeviceTarget(config DeviceConfig, opts DeviceOptions) (*DeviceTarget, error) {
+ // If an SSH key is specified in the options, prepend it the configs list so that it
+ // corresponds to the authorized key that would be paved.
+ if opts.SSHKey != "" {
+ config.SSHKeys = append([]string{opts.SSHKey}, config.SSHKeys...)
+ }
+ signers, err := parseOutSigners(config.SSHKeys)
+ if err != nil {
+ return nil, fmt.Errorf("could not parse out signers from private keys: %v", err)
+ }
+ return &DeviceTarget{
+ config: &config,
+ opts: &opts,
+ signers: signers,
+ }, nil
+}
+
+// Nodename returns the name of the node.
+func (t *DeviceTarget) Nodename() string {
+ return t.config.Nodename
+}
+
+// IPv6 returns the link-local IPv6 address of the node.
+func (t *DeviceTarget) IPv6Addr() (*net.UDPAddr, error) {
+ addr, err := netutil.GetNodeAddress(context.Background(), t.Nodename(), false)
+ return addr, err
+}
+
+// Start starts the device target.
+func (t *DeviceTarget) Start(ctx context.Context, images build.Images, args []string) error {
+ if t.opts.Fastboot != "" {
+ zirconR := images.Get("zircon-r")
+ if zirconR == nil {
+ return fmt.Errorf("zircon-r not provided")
+ }
+ // If it can't find any fastboot device, the fastboot tool will hang
+ // waiting, so we add a timeout. All fastboot operations take less
+ // than a second on a developer workstation, so two minutes to flash
+ // and continue is very generous.
+ ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
+ defer cancel()
+ if err := botanist.FastbootToZedboot(ctx, t.opts.Fastboot, zirconR.Path); err != nil {
+ return fmt.Errorf("failed to fastboot to zedboot: %v", err)
+ }
+ }
+
+ // Set up log listener and dump kernel output to stdout.
+ l, err := netboot.NewLogListener(t.Nodename())
+ if err != nil {
+ return fmt.Errorf("cannot listen: %v", err)
+ }
+ go func() {
+ defer l.Close()
+ for {
+ data, err := l.Listen()
+ if err != nil {
+ continue
+ }
+ fmt.Print(data)
+ select {
+ case <-ctx.Done():
+ return
+ default:
}
}
+ }()
+
+ addr, err := t.IPv6Addr()
+ if err != nil {
+ return err
+ }
+
+ // Boot Fuchsia.
+ var bootMode int
+ if t.opts.Netboot {
+ bootMode = botanist.ModeNetboot
+ } else {
+ bootMode = botanist.ModePave
+ }
+ return botanist.Boot(ctx, addr, bootMode, images, args, t.signers)
+}
+
+// Restart restarts the target.
+func (t *DeviceTarget) Restart(ctx context.Context) error {
+ if t.config.Power != nil {
+ if err := t.config.Power.RebootDevice(t.signers, t.Nodename()); err != nil {
+ return fmt.Errorf("failed to reboot the device: %v\n", err)
+ }
+ }
+ return nil
+}
+
+func parseOutSigners(keyPaths []string) ([]ssh.Signer, error) {
+ if len(keyPaths) == 0 {
+ return nil, errors.New("must supply SSH keys in the config")
+ }
+ var keys [][]byte
+ for _, keyPath := range keyPaths {
+ p, err := ioutil.ReadFile(keyPath)
+ if err != nil {
+ return nil, fmt.Errorf("could not read SSH key file %q: %v", keyPath, err)
+ }
+ keys = append(keys, p)
+ }
+
+ var signers []ssh.Signer
+ for _, p := range keys {
+ signer, err := ssh.ParsePrivateKey(p)
+ if err != nil {
+ return nil, err
+ }
+ signers = append(signers, signer)
}
return signers, nil
}
diff --git a/botanist/target/device_test.go b/botanist/target/device_test.go
index 1626a2f..30687ab 100644
--- a/botanist/target/device_test.go
+++ b/botanist/target/device_test.go
@@ -7,9 +7,6 @@
"io/ioutil"
"os"
"testing"
-
- "fuchsia.googlesource.com/tools/botanist/power"
- "fuchsia.googlesource.com/tools/sshutil"
)
func TestLoadConfigs(t *testing.T) {
@@ -55,81 +52,3 @@
}
}
}
-
-func TestSSHSignersFromConfigs(t *testing.T) {
- tests := []struct {
- name string
- device1Keys []string
- device2Keys []string
- expectedLen int
- expectErr bool
- }{
- // Valid configs.
- {"ValidSameKeyConfig", []string{"valid1"}, []string{"valid1"}, 1, false},
- {"ValidDiffKeysWithDuplicateConfig", []string{"valid1", "valid2"}, []string{"valid1"}, 2, false},
- {"ValidDiffKeysConfig", []string{"valid1"}, []string{"valid2"}, 2, false},
- {"ValidEmptyKeysConfig", []string{}, []string{}, 0, false},
- // Invalid configs.
- {"InvalidKeyFileConfig", []string{"valid1"}, []string{"invalid"}, 0, true},
- {"MissingKeyFileConfig", []string{"missing"}, []string{}, 0, true},
- }
-
- validKey1, err := sshutil.GeneratePrivateKey()
- if err != nil {
- t.Fatalf("Failed to generate private key: %s", err)
- }
- validKey2, err := sshutil.GeneratePrivateKey()
- if err != nil {
- t.Fatalf("Failed to generate private key: %s", err)
- }
- invalidKey := []byte("invalidKey")
-
- keys := []struct {
- name string
- keyContents []byte
- }{
- {"valid1", validKey1}, {"valid2", validKey2}, {"invalid", invalidKey},
- }
-
- keyNameToPath := make(map[string]string)
- keyNameToPath["missing"] = "/path/to/nonexistent/key"
- for _, key := range keys {
- tmpfile, err := ioutil.TempFile(os.TempDir(), key.name)
- if err != nil {
- t.Fatalf("Failed to create test device properties file: %s", err)
- }
- defer os.Remove(tmpfile.Name())
- if _, err := tmpfile.Write(key.keyContents); err != nil {
- t.Fatalf("Failed to write to test device properties file: %s", err)
- }
- if err := tmpfile.Close(); err != nil {
- t.Fatal(err)
- }
- keyNameToPath[key.name] = tmpfile.Name()
- }
-
- for _, test := range tests {
- var keyPaths1 []string
- for _, keyName := range test.device1Keys {
- keyPaths1 = append(keyPaths1, keyNameToPath[keyName])
- }
- var keyPaths2 []string
- for _, keyName := range test.device2Keys {
- keyPaths2 = append(keyPaths2, keyNameToPath[keyName])
- }
- configs := []DeviceConfig{
- {"device1", &power.Client{}, keyPaths1},
- {"device2", &power.Client{}, keyPaths2},
- }
- signers, err := SSHSignersFromConfigs(configs)
- if test.expectErr && err == nil {
- t.Errorf("Test%v: Expected errors; no errors found", test.name)
- }
- if !test.expectErr && err != nil {
- t.Errorf("Test%v: Expected no errors; found error - %v", test.name, err)
- }
- if len(signers) != test.expectedLen {
- t.Errorf("Test%v: Expected %d signers; found %d", test.name, test.expectedLen, len(signers))
- }
- }
-}
diff --git a/cmd/botanist/zedboot.go b/cmd/botanist/zedboot.go
index 57a001c..748fb87 100644
--- a/cmd/botanist/zedboot.go
+++ b/cmd/botanist/zedboot.go
@@ -22,13 +22,10 @@
"fuchsia.googlesource.com/tools/build"
"fuchsia.googlesource.com/tools/command"
"fuchsia.googlesource.com/tools/logger"
- "fuchsia.googlesource.com/tools/netboot"
- "fuchsia.googlesource.com/tools/netutil"
"fuchsia.googlesource.com/tools/runner"
"fuchsia.googlesource.com/tools/runtests"
"github.com/google/subcommands"
- "golang.org/x/crypto/ssh"
)
// ZedbootCommand is a Command implementation for running the testing workflow on a device
@@ -184,66 +181,7 @@
return cmd.tarHostCmdArtifacts(summaryBuffer.Bytes(), stdoutBuf.Bytes(), tmpDir)
}
-func (cmd *ZedbootCommand) runTests(ctx context.Context, imgs build.Images, nodes []target.DeviceConfig, cmdlineArgs []string, signers []ssh.Signer) error {
- var err error
-
- // Set up log listener and dump kernel output to stdout.
- for _, node := range nodes {
- l, err := netboot.NewLogListener(node.Nodename)
- if err != nil {
- return fmt.Errorf("cannot listen: %v\n", err)
- }
- go func(nodename string) {
- defer l.Close()
- logger.Debugf(ctx, "starting log listener for <<%s>>\n", nodename)
- for {
- data, err := l.Listen()
- if err != nil {
- continue
- }
- if len(nodes) == 1 {
- fmt.Print(data)
- } else {
- // Print each line with nodename prepended when there are multiple nodes
- lines := strings.Split(data, "\n")
- for _, line := range lines {
- if len(line) > 0 {
- fmt.Printf("<<%s>> %s\n", nodename, line)
- }
- }
- }
-
- select {
- case <-ctx.Done():
- return
- default:
- }
- }
- }(node.Nodename)
- }
-
- var addrs []*net.UDPAddr
- for _, node := range nodes {
- addr, err := netutil.GetNodeAddress(ctx, node.Nodename, false)
- if err != nil {
- return err
- }
- addrs = append(addrs, addr)
- }
-
- // Boot fuchsia.
- var bootMode int
- if cmd.netboot {
- bootMode = botanist.ModeNetboot
- } else {
- bootMode = botanist.ModePave
- }
- for _, addr := range addrs {
- if err = botanist.Boot(ctx, addr, bootMode, imgs, cmdlineArgs, signers); err != nil {
- return err
- }
- }
-
+func (cmd *ZedbootCommand) runTests(ctx context.Context, imgs build.Images, addr *net.UDPAddr, cmdlineArgs []string) error {
// Handle host commands
// TODO(IN-831): Remove when host-target-interaction infra is ready
if cmd.hostCmd != "" {
@@ -251,11 +189,6 @@
}
logger.Debugf(ctx, "waiting for %q\n", cmd.summaryFilename)
- if len(addrs) != 1 {
- return fmt.Errorf("Non-host tests should have exactly 1 node defined in config, found %v", len(addrs))
- }
-
- addr := addrs[0]
return runtests.PollForSummary(ctx, addr, cmd.summaryFilename, cmd.testResultsDir, cmd.outputArchive, cmd.filePollInterval)
}
@@ -265,22 +198,22 @@
if err != nil {
return fmt.Errorf("failed to load target config file %q", cmd.configFile)
}
-
- signers, err := target.SSHSignersFromConfigs(configs)
- if err != nil {
- return err
+ opts := target.DeviceOptions{
+ Netboot: cmd.netboot,
+ Fastboot: cmd.fastboot,
}
+ var devices []*target.DeviceTarget
for _, config := range configs {
- if config.Power != nil {
- defer func(cfg *target.DeviceConfig) {
- logger.Debugf(ctx, "rebooting the node %q\n", cfg.Nodename)
-
- if err := cfg.Power.RebootDevice(signers, cfg.Nodename); err != nil {
- logger.Errorf(ctx, "failed to reboot the device: %v", err)
- }
- }(&config)
+ device, err := target.NewDeviceTarget(config, opts)
+ if err != nil {
+ return err
}
+ devices = append(devices, device)
+ }
+
+ for _, device := range devices {
+ defer device.Restart(ctx)
}
imgs, err := build.LoadImages(cmd.imageManifests...)
@@ -291,27 +224,21 @@
ctx, cancel := context.WithCancel(ctx)
defer cancel()
errs := make(chan error)
- go func() {
- if cmd.fastboot != "" {
- zirconR := imgs.Get("zircon-r")
- if zirconR == nil {
- errs <- fmt.Errorf("zircon-r not provided")
- return
- }
- // If it can't find any fastboot device, the fastboot
- // tool will hang waiting, so we add a timeout.
- // All fastboot operations take less than a second on
- // a developer workstation, so two minutes to flash and
- // continue is very generous.
- ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
- defer cancel()
- logger.Debugf(ctx, "flashing to zedboot with fastboot\n")
- if err := botanist.FastbootToZedboot(ctx, cmd.fastboot, zirconR.Path); err != nil {
+
+ for _, device := range devices {
+ go func() {
+ if err := device.Start(ctx, imgs, cmdlineArgs); err != nil {
errs <- err
- return
}
+ }()
+ }
+ go func() {
+ addr, err := devices[0].IPv6Addr()
+ if err != nil {
+ errs <- err
+ return
}
- errs <- cmd.runTests(ctx, imgs, configs, cmdlineArgs, signers)
+ errs <- cmd.runTests(ctx, imgs, addr, cmdlineArgs)
}()
select {
diff --git a/cmd/health_checker/main.go b/cmd/health_checker/main.go
index 5fba855..48190fe 100644
--- a/cmd/health_checker/main.go
+++ b/cmd/health_checker/main.go
@@ -4,6 +4,7 @@
package main
import (
+ "context"
"encoding/json"
"flag"
"fmt"
@@ -75,21 +76,6 @@
return HealthCheckResult{nodename, healthyState, ""}
}
-func reboot(config target.DeviceConfig) error {
- if config.Power == nil {
- return fmt.Errorf("Failed to reboot the device: missing power management info in botanist config file.")
- }
- signers, err := target.SSHSignersFromConfigs([]target.DeviceConfig{config})
- if err != nil {
- return fmt.Errorf("Failed to reboot the device: %v.", err)
- }
-
- if err = config.Power.RebootDevice(signers, config.Nodename); err != nil {
- return fmt.Errorf("Failed to reboot the device: %v.", err)
- }
- return nil
-}
-
func printHealthCheckResults(checkResults []HealthCheckResult) error {
output, err := json.Marshal(checkResults)
if err != nil {
@@ -120,15 +106,25 @@
if err != nil {
log.Fatal(err)
}
- var checkResultSlice []HealthCheckResult
+
+ var devices []*target.DeviceTarget
for _, config := range configs {
- nodename := config.Nodename
+ device, err := target.NewDeviceTarget(config, target.DeviceOptions{})
+ if err != nil {
+ log.Fatal(err)
+ }
+ devices = append(devices, device)
+ }
+
+ var checkResultSlice []HealthCheckResult
+ for _, device := range devices {
+ nodename := device.Nodename()
if nodename == "" {
- log.Fatal("Failed to retrieve nodename from config file")
+ log.Fatal("no nodename in config")
}
checkResult := checkHealth(client, nodename)
if checkResult.State == unhealthyState && rebootIfUnhealthy {
- if rebootErr := reboot(config); rebootErr != nil {
+ if rebootErr := device.Restart(context.Background()); rebootErr != nil {
checkResult.ErrorMsg += "; " + rebootErr.Error()
}
}