[botanist] Move all botanist code here from infra/infra.
This is in preparation to move all of tools.git to fuchsia.git.
Bug: 10321
Change-Id: Ic6b0ddbfc6836619b69712b7d0775a18e9983ae0
diff --git a/botanist/cmd/main.go b/botanist/cmd/main.go
new file mode 100644
index 0000000..c2fc2d5
--- /dev/null
+++ b/botanist/cmd/main.go
@@ -0,0 +1,46 @@
+// Copyright 2017 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 main
+
+import (
+ "context"
+ "flag"
+ "os"
+ "syscall"
+
+ "github.com/google/subcommands"
+
+ "go.fuchsia.dev/tools/color"
+ "go.fuchsia.dev/tools/command"
+ "go.fuchsia.dev/tools/logger"
+)
+
+var (
+ colors color.EnableColor
+ level logger.LogLevel
+)
+
+func init() {
+ colors = color.ColorAuto
+ level = logger.InfoLevel
+
+ flag.Var(&colors, "color", "use color in output, can be never, auto, always")
+ flag.Var(&level, "level", "output verbosity, can be fatal, error, warning, info, debug or trace")
+}
+
+func main() {
+ subcommands.Register(subcommands.HelpCommand(), "")
+ subcommands.Register(subcommands.CommandsCommand(), "")
+ subcommands.Register(subcommands.FlagsCommand(), "")
+ subcommands.Register(&ZedbootCommand{}, "")
+ subcommands.Register(&QEMUCommand{}, "")
+ subcommands.Register(&RunCommand{}, "")
+
+ flag.Parse()
+
+ log := logger.NewLogger(level, color.NewColor(colors), os.Stdout, os.Stderr, "botanist ")
+ ctx := logger.WithLogger(context.Background(), log)
+ ctx = command.CancelOnSignals(ctx, syscall.SIGTERM)
+ os.Exit(int(subcommands.Execute(ctx)))
+}
diff --git a/botanist/cmd/qemu.go b/botanist/cmd/qemu.go
new file mode 100644
index 0000000..320fcdf
--- /dev/null
+++ b/botanist/cmd/qemu.go
@@ -0,0 +1,115 @@
+// 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 main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+
+ "github.com/google/subcommands"
+ "go.fuchsia.dev/tools/botanist/target"
+ "go.fuchsia.dev/tools/build"
+ "go.fuchsia.dev/tools/logger"
+)
+
+// QEMUBinPrefix is the prefix of the QEMU binary name, which is of the form
+// qemu-system-<QEMU arch suffix>.
+const qemuBinPrefix = "qemu-system"
+
+// QEMUCommand is a Command implementation for running the testing workflow on an emulated
+// target within QEMU.
+type QEMUCommand struct {
+ // ImageManifest is the path an image manifest specifying a QEMU kernel, a zircon
+ // kernel, and block image to back QEMU storage.
+ imageManifest string
+
+ // QEMUBinDir is a path to a directory of QEMU binaries.
+ qemuBinDir string
+
+ // MinFSImage is a path to a minFS image to be mounted on target, and to where test
+ // results will be written.
+ minFSImage string
+
+ // MinFSBlkDevPCIAddr is a minFS block device PCI address.
+ minFSBlkDevPCIAddr string
+
+ // TargetArch is the target architecture to be emulated within QEMU
+ targetArch string
+
+ // EnableKVM dictates whether to enable KVM.
+ enableKVM bool
+
+ // CPU is the number of processors to emulate.
+ cpu int
+
+ // Memory is the amount of memory (in MB) to provide.
+ memory int
+}
+
+func (*QEMUCommand) Name() string {
+ return "qemu"
+}
+
+func (*QEMUCommand) Usage() string {
+ return "qemu [flags...] [kernel command-line arguments...]\n\nflags:\n"
+}
+
+func (*QEMUCommand) Synopsis() string {
+ return "boots a QEMU device with a MinFS image as a block device."
+}
+
+func (cmd *QEMUCommand) SetFlags(f *flag.FlagSet) {
+ f.StringVar(&cmd.imageManifest, "images", "", "path to an image manifest")
+ f.StringVar(&cmd.qemuBinDir, "qemu-dir", "", "")
+ f.StringVar(&cmd.minFSImage, "minfs", "", "path to minFS image")
+ f.StringVar(&cmd.minFSBlkDevPCIAddr, "pci-addr", "06.0", "minFS block device PCI address")
+ f.StringVar(&cmd.targetArch, "arch", "", "target architecture (x64 or arm64)")
+ f.BoolVar(&cmd.enableKVM, "use-kvm", false, "whether to enable KVM")
+ f.IntVar(&cmd.cpu, "cpu", 4, "number of processors to emulate")
+ f.IntVar(&cmd.memory, "memory", 4096, "amount of memory (in MB) to provide")
+}
+
+func (cmd *QEMUCommand) execute(ctx context.Context, cmdlineArgs []string) error {
+ if cmd.qemuBinDir == "" {
+ return fmt.Errorf("-qemu-dir must be set")
+ }
+
+ imgs, err := build.LoadImages(cmd.imageManifest)
+ if err != nil {
+ return err
+ }
+
+ // TODO: pass this directly from a file.
+ config := target.QEMUConfig{
+ CPU: cmd.cpu,
+ Memory: cmd.memory,
+ Path: cmd.qemuBinDir,
+ Target: cmd.targetArch,
+ KVM: cmd.enableKVM,
+ UserNetworking: true,
+ }
+ if cmd.minFSImage != "" {
+ config.MinFS = &target.MinFS{
+ Image: cmd.minFSImage,
+ PCIAddress: cmd.minFSBlkDevPCIAddr,
+ }
+ }
+
+ t := target.NewQEMUTarget(config, target.Options{})
+ if err := t.Start(ctx, imgs, cmdlineArgs); err != nil {
+ return err
+ }
+
+ return t.Wait(ctx)
+}
+
+func (cmd *QEMUCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
+ if err := cmd.execute(ctx, f.Args()); err != nil {
+ logger.Errorf(ctx, "%v\n", err)
+ return subcommands.ExitFailure
+ }
+ return subcommands.ExitSuccess
+}
diff --git a/botanist/cmd/run.go b/botanist/cmd/run.go
new file mode 100644
index 0000000..c88134f
--- /dev/null
+++ b/botanist/cmd/run.go
@@ -0,0 +1,387 @@
+// 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 main
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "go.fuchsia.dev/tools/botanist"
+ "go.fuchsia.dev/tools/botanist/target"
+ "go.fuchsia.dev/tools/build"
+ "go.fuchsia.dev/tools/command"
+ "go.fuchsia.dev/tools/logger"
+ "go.fuchsia.dev/tools/runner"
+ "go.fuchsia.dev/tools/sshutil"
+
+ "github.com/google/subcommands"
+)
+
+const (
+ netstackTimeout time.Duration = 1 * time.Minute
+)
+
+// Target represents a fuchsia instance.
+type Target interface {
+ // Nodename returns the name of the target node.
+ Nodename() string
+
+ // IPv4Addr returns the IPv4 address of the target.
+ IPv4Addr() (net.IP, error)
+
+ // Serial returns the serial device associated with the target for serial i/o.
+ Serial() io.ReadWriteCloser
+
+ // SSHKey returns the private key corresponding an authorized SSH key of the target.
+ SSHKey() string
+
+ // Start starts the target.
+ Start(ctx context.Context, images build.Images, args []string) error
+
+ // Restart restarts the target.
+ Restart(ctx context.Context) error
+
+ // Stop stops the target.
+ Stop(ctx context.Context) error
+
+ // Wait waits for the target to finish running.
+ Wait(ctx context.Context) error
+}
+
+// RunCommand is a Command implementation for booting a device and running a
+// given command locally.
+type RunCommand struct {
+ // ConfigFile is the path to the target configurations.
+ configFile string
+
+ // ImageManifests is a list of paths to image manifests (e.g., images.json)
+ imageManifests command.StringsFlag
+
+ // Netboot tells botanist to netboot (and not to pave).
+ netboot bool
+
+ // ZirconArgs are kernel command-line arguments to pass on boot.
+ zirconArgs command.StringsFlag
+
+ // Timeout is the duration allowed for the command to finish execution.
+ timeout time.Duration
+
+ // CmdStdout is the file to which the command's stdout will be redirected.
+ cmdStdout string
+
+ // CmdStderr is the file to which the command's stderr will be redirected.
+ cmdStderr string
+
+ // SysloggerFile, if nonempty, is the file to where the system's logs will be written.
+ syslogFile string
+
+ // SshKey is the path to a private SSH user key.
+ sshKey string
+
+ // SerialLogFile, if nonempty, is the file where the system's serial logs will be written.
+ serialLogFile string
+}
+
+func (*RunCommand) Name() string {
+ return "run"
+}
+
+func (*RunCommand) Usage() string {
+ return `
+botanist run [flags...] [command...]
+
+flags:
+`
+}
+
+func (*RunCommand) Synopsis() string {
+ return "boots a device and runs a local command"
+}
+
+func (r *RunCommand) SetFlags(f *flag.FlagSet) {
+ f.StringVar(&r.configFile, "config", "/etc/botanist/device.json", "path to file of device config")
+ f.Var(&r.imageManifests, "images", "paths to image manifests")
+ f.BoolVar(&r.netboot, "netboot", false, "if set, botanist will not pave; but will netboot instead")
+ f.Var(&r.zirconArgs, "zircon-args", "kernel command-line arguments")
+ f.DurationVar(&r.timeout, "timeout", 10*time.Minute, "duration allowed for the command to finish execution.")
+ f.StringVar(&r.cmdStdout, "stdout", "", "file to redirect the command's stdout into; if unspecified, it will be redirected to the process' stdout")
+ f.StringVar(&r.cmdStderr, "stderr", "", "file to redirect the command's stderr into; if unspecified, it will be redirected to the process' stderr")
+ f.StringVar(&r.syslogFile, "syslog", "", "file to write the systems logs to")
+ f.StringVar(&r.sshKey, "ssh", "", "file containing a private SSH user key; if not provided, a private key will be generated.")
+ f.StringVar(&r.serialLogFile, "serial-log", "", "file to write the serial logs to.")
+}
+
+func (r *RunCommand) runCmd(ctx context.Context, args []string, t Target) error {
+ nodename := t.Nodename()
+ ip, err := t.IPv4Addr()
+ if err == nil {
+ logger.Infof(ctx, "IPv4 address of %s found: %s", nodename, ip)
+ } else {
+ logger.Errorf(ctx, "could not resolve IPv4 address of %s: %v", nodename, err)
+ }
+
+ env := append(
+ os.Environ(),
+ fmt.Sprintf("FUCHSIA_NODENAME=%s", nodename),
+ fmt.Sprintf("FUCHSIA_IPV4_ADDR=%v", ip),
+ fmt.Sprintf("FUCHSIA_SSH_KEY=%s", t.SSHKey()),
+ )
+
+ ctx, cancel := context.WithTimeout(ctx, r.timeout)
+ defer cancel()
+
+ stdout := os.Stdout
+ if r.cmdStdout != "" {
+ f, err := os.Create(r.cmdStdout)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ stdout = f
+ }
+ stderr := os.Stderr
+ if r.cmdStderr != "" {
+ f, err := os.Create(r.cmdStderr)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ stderr = f
+ }
+
+ runner := runner.SubprocessRunner{
+ Env: env,
+ }
+ if err := runner.Run(ctx, args, stdout, stderr); err != nil {
+ if ctx.Err() != nil {
+ return fmt.Errorf("command timed out after %v", r.timeout)
+ }
+ return err
+ }
+ return nil
+}
+
+func getIndexedFilename(filename string, index int) string {
+ ext := filepath.Ext(filename)
+ name := filename[:len(filename)-len(ext)]
+ return fmt.Sprintf("%s-%d%s", name, index, ext)
+}
+
+func (r *RunCommand) execute(ctx context.Context, args []string) error {
+ imgs, err := build.LoadImages(r.imageManifests...)
+ if err != nil {
+ return fmt.Errorf("failed to load images: %v", err)
+ }
+
+ opts := target.Options{
+ Netboot: r.netboot,
+ SSHKey: r.sshKey,
+ }
+
+ data, err := ioutil.ReadFile(r.configFile)
+ if err != nil {
+ return fmt.Errorf("could not open config file: %v", err)
+ }
+ var objs []json.RawMessage
+ if err := json.Unmarshal(data, &objs); err != nil {
+ return fmt.Errorf("could not unmarshal config file as a JSON list: %v", err)
+ }
+
+ var targets []Target
+ for _, obj := range objs {
+ t, err := DeriveTarget(ctx, obj, opts)
+ if err != nil {
+ return err
+ }
+ targets = append(targets, t)
+ }
+
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ errs := make(chan error)
+ var wg sync.WaitGroup
+
+ for i, t := range targets {
+ defer func() {
+ logger.Debugf(ctx, "stopping or rebooting the node %q\n", t.Nodename())
+ if err := t.Stop(ctx); err == target.ErrUnimplemented {
+ t.Restart(ctx)
+ }
+ }()
+
+ var syslogFile, serialLogFile string
+ if r.syslogFile != "" {
+ syslogFile = r.syslogFile
+ if len(targets) > 1 {
+ syslogFile = getIndexedFilename(r.syslogFile, i)
+ }
+ }
+ if r.serialLogFile != "" {
+ serialLogFile = r.serialLogFile
+ if len(targets) > 1 {
+ serialLogFile = getIndexedFilename(r.serialLogFile, i)
+ }
+ }
+
+ wg.Add(1)
+ go func(t Target, syslogFile string, serialLogFile string) {
+ defer wg.Done()
+ var syslog io.Writer
+ if syslogFile != "" {
+ syslog, err := os.Create(syslogFile)
+ if err != nil {
+ errs <- err
+ return
+ }
+ defer syslog.Close()
+ }
+
+ zirconArgs := r.zirconArgs
+ if t.Serial() != nil {
+ if serialLogFile != "" {
+ serialLog, err := os.Create(serialLogFile)
+ if err != nil {
+ errs <- err
+ return
+ }
+ defer serialLog.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(t.Serial(), "\ndlog\n"); err != nil {
+ logger.Errorf(ctx, "failed to tail zedboot dlog: %v", err)
+ }
+
+ go func(t Target) {
+ for {
+ _, err := io.Copy(serialLog, t.Serial())
+ if err != nil && err != io.EOF {
+ logger.Errorf(ctx, "failed to write serial log: %v", err)
+ return
+ }
+ }
+ }(t)
+ zirconArgs = append(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.
+ zirconArgs = append(zirconArgs, "kernel.serial=legacy")
+ }
+
+ if err := t.Start(ctx, imgs, zirconArgs); err != nil {
+ errs <- err
+ return
+ }
+ nodename := t.Nodename()
+ // If having paved, SSH in and stream syslogs back to a file sink.
+ if !r.netboot && syslog != nil {
+ p, err := ioutil.ReadFile(t.SSHKey())
+ if err != nil {
+ errs <- err
+ return
+ }
+ config, err := sshutil.DefaultSSHConfig(p)
+ if err != nil {
+ errs <- err
+ return
+ }
+ client, err := sshutil.ConnectToNode(ctx, nodename, config)
+ if err != nil {
+ errs <- err
+ return
+ }
+ syslogger, err := botanist.NewSyslogger(client)
+ if err != nil {
+ errs <- err
+ return
+ }
+ go func() {
+ syslogger.Stream(ctx, syslog)
+ syslogger.Close()
+ }()
+ }
+ }(t, syslogFile, serialLogFile)
+ }
+ // Wait for all targets to finish starting.
+ wg.Wait()
+ // We can close the channel on the receiver end since we wait for all target goroutines to finish.
+ close(errs)
+ err, ok := <-errs
+ if ok {
+ return err
+ }
+
+ // Since errs was closed, reset it to reuse it again.
+ errs = make(chan error)
+
+ go func() {
+ // Target doesn't matter for multi-device host tests. Just use first one.
+ errs <- r.runCmd(ctx, args, targets[0])
+ }()
+
+ for _, t := range targets {
+ go func(t Target) {
+ if err := t.Wait(ctx); err != nil && err != target.ErrUnimplemented {
+ errs <- err
+ }
+ }(t)
+ }
+
+ select {
+ case err := <-errs:
+ return err
+ case <-ctx.Done():
+ }
+ return nil
+}
+
+func (r *RunCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
+ args := f.Args()
+ if len(args) == 0 {
+ return subcommands.ExitUsageError
+ }
+ if err := r.execute(ctx, args); err != nil {
+ logger.Errorf(ctx, "%v\n", err)
+ return subcommands.ExitFailure
+ }
+ return subcommands.ExitSuccess
+}
+
+func DeriveTarget(ctx context.Context, obj []byte, opts target.Options) (Target, error) {
+ type typed struct {
+ Type string `json:"type"`
+ }
+ var x typed
+
+ if err := json.Unmarshal(obj, &x); err != nil {
+ return nil, fmt.Errorf("object in list has no \"type\" field: %v", err)
+ }
+ switch x.Type {
+ case "qemu":
+ var cfg target.QEMUConfig
+ if err := json.Unmarshal(obj, &cfg); err != nil {
+ return nil, fmt.Errorf("invalid QEMU config found: %v", err)
+ }
+ return target.NewQEMUTarget(cfg, opts), nil
+ case "device":
+ var cfg target.DeviceConfig
+ if err := json.Unmarshal(obj, &cfg); err != nil {
+ return nil, fmt.Errorf("invalid device config found: %v", err)
+ }
+ t, err := target.NewDeviceTarget(ctx, cfg, opts)
+ return t, err
+ default:
+ return nil, fmt.Errorf("unknown type found: %q", x.Type)
+ }
+}
diff --git a/botanist/cmd/zedboot.go b/botanist/cmd/zedboot.go
new file mode 100644
index 0000000..a5bfbae
--- /dev/null
+++ b/botanist/cmd/zedboot.go
@@ -0,0 +1,162 @@
+// 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 main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io/ioutil"
+ "net"
+ "strings"
+ "time"
+
+ "go.fuchsia.dev/tools/botanist/target"
+ "go.fuchsia.dev/tools/build"
+ "go.fuchsia.dev/tools/command"
+ "go.fuchsia.dev/tools/logger"
+ "go.fuchsia.dev/tools/netutil"
+ "go.fuchsia.dev/tools/runtests"
+
+ "github.com/google/subcommands"
+)
+
+// ZedbootCommand is a Command implementation for running the testing workflow on a device
+// that boots with Zedboot.
+type ZedbootCommand struct {
+ // ImageManifests is a list of paths to image manifests (e.g., images.json)
+ imageManifests command.StringsFlag
+
+ // Netboot tells botanist to netboot (and not to pave).
+ netboot bool
+
+ // ConfigFile is the path to a file containing the target config.
+ configFile string
+
+ // TestResultsDir is the directory on target to where test results will be written.
+ testResultsDir string
+
+ // SummaryFilename is the name of the test summary JSON file to be written to
+ // testResultsDir.
+ summaryFilename string
+
+ // FilePollInterval is the duration waited between checking for test summary file
+ // on the target to be written.
+ filePollInterval time.Duration
+
+ // OutputArchive is a path on host to where the tarball containing the test results
+ // will be output.
+ outputArchive string
+
+ // CmdlineFile is the path to a file of additional kernel command-line arguments.
+ cmdlineFile string
+}
+
+func (*ZedbootCommand) Name() string {
+ return "zedboot"
+}
+
+func (*ZedbootCommand) Usage() string {
+ return "zedboot [flags...] [kernel command-line arguments...]\n\nflags:\n"
+}
+
+func (*ZedbootCommand) Synopsis() string {
+ return "boots a Zedboot device and collects test results"
+}
+
+func (cmd *ZedbootCommand) SetFlags(f *flag.FlagSet) {
+ f.Var(&cmd.imageManifests, "images", "paths to image manifests")
+ f.BoolVar(&cmd.netboot, "netboot", false, "if set, botanist will not pave; but will netboot instead")
+ f.StringVar(&cmd.testResultsDir, "results-dir", "/test", "path on target to where test results will be written")
+ f.StringVar(&cmd.outputArchive, "out", "output.tar", "path on host to output tarball of test results")
+ f.StringVar(&cmd.summaryFilename, "summary-name", runtests.TestSummaryFilename, "name of the file in the test directory")
+ f.DurationVar(&cmd.filePollInterval, "poll-interval", 1*time.Minute, "time between checking for summary.json on the target")
+ f.StringVar(&cmd.configFile, "config", "/etc/botanist/config.json", "path to file of device config")
+ f.StringVar(&cmd.cmdlineFile, "cmdline-file", "", "path to a file containing additional kernel command-line arguments")
+}
+
+func (cmd *ZedbootCommand) runTests(ctx context.Context, imgs build.Images, addr *net.UDPAddr, cmdlineArgs []string) error {
+ logger.Debugf(ctx, "waiting for %q\n", cmd.summaryFilename)
+ return runtests.PollForSummary(ctx, addr, cmd.summaryFilename, cmd.testResultsDir, cmd.outputArchive, cmd.filePollInterval)
+}
+
+func (cmd *ZedbootCommand) execute(ctx context.Context, cmdlineArgs []string) error {
+ configs, err := target.LoadDeviceConfigs(cmd.configFile)
+
+ if err != nil {
+ return fmt.Errorf("failed to load target config file %q", cmd.configFile)
+ }
+ opts := target.Options{
+ Netboot: cmd.netboot,
+ }
+
+ var devices []*target.DeviceTarget
+ for _, config := range configs {
+ device, err := target.NewDeviceTarget(ctx, 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...)
+ if err != nil {
+ return err
+ }
+
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ errs := make(chan error)
+
+ for _, device := range devices {
+ go func(device *target.DeviceTarget) {
+ if err := device.Start(ctx, imgs, cmdlineArgs); err != nil {
+ errs <- err
+ }
+ }(device)
+ }
+ go func() {
+ addr, err := netutil.GetNodeAddress(ctx, devices[0].Nodename(), false)
+ if err != nil {
+ errs <- err
+ return
+ }
+ errs <- cmd.runTests(ctx, imgs, addr, cmdlineArgs)
+ }()
+
+ select {
+ case err := <-errs:
+ return err
+ case <-ctx.Done():
+ }
+
+ return nil
+}
+
+func (cmd *ZedbootCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
+ configFlag := f.Lookup("config")
+ logger.Debugf(ctx, "config flag: %v\n", configFlag.Value)
+
+ // Aggregate command-line arguments.
+ cmdlineArgs := f.Args()
+ if cmd.cmdlineFile != "" {
+ args, err := ioutil.ReadFile(cmd.cmdlineFile)
+ if err != nil {
+ logger.Errorf(ctx, "failed to read command-line args file %q: %v\n", cmd.cmdlineFile, err)
+ return subcommands.ExitFailure
+ }
+ cmdlineArgs = append(cmdlineArgs, strings.Split(string(args), "\n")...)
+ }
+
+ if err := cmd.execute(ctx, cmdlineArgs); err != nil {
+ logger.Errorf(ctx, "%v\n", err)
+ return subcommands.ExitFailure
+ }
+
+ return subcommands.ExitSuccess
+}
diff --git a/botanist/power/amt/amt.go b/botanist/power/amt/amt.go
new file mode 100644
index 0000000..016fe2f
--- /dev/null
+++ b/botanist/power/amt/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 amt
+
+import (
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/google/uuid"
+ "go.fuchsia.dev/tools/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 Reboot(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/botanist/power/power.go b/botanist/power/power.go
new file mode 100644
index 0000000..2f2fc89
--- /dev/null
+++ b/botanist/power/power.go
@@ -0,0 +1,143 @@
+// 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 power
+
+import (
+ "context"
+ "io"
+
+ "go.fuchsia.dev/tools/botanist/power/amt"
+ "go.fuchsia.dev/tools/botanist/power/wol"
+ "go.fuchsia.dev/tools/logger"
+ "go.fuchsia.dev/tools/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"
+)
+
+// Client represents a power management configuration for a particular device.
+type Client struct {
+ // Type is the type of manager to use.
+ Type string `json:"type"`
+
+ // Host is the network hostname of the manager, e.g. fuchsia-tests-pdu-001.
+ Host string `json:"host"`
+
+ // HostHwAddr is the ethernet MAC address of the manager, e.g. 10:10:10:10:10:10
+ HostMACAddr string `json:"host_mac_addr"`
+
+ // 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"`
+}
+
+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, 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 := rebooter.reboot()
+ if err != nil {
+ logger.Warningf(context.Background(), "soft reboot failed: %v", err)
+ }
+
+ // Hard reboot the device if specified in the config.
+ switch c.Type {
+ case "amt":
+ return amt.Reboot(c.Host, c.Username, c.Password)
+ case "wol":
+ return wol.Reboot(botBroadcastAddr, botInterface, c.HostMACAddr)
+ default:
+ return err
+ }
+}
+
+func NewSerialRebooter(serial io.ReadWriter) *SerialRebooter {
+ return &SerialRebooter{
+ serial: serial,
+ }
+}
+
+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, 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
+ }
+}
diff --git a/botanist/power/wol/wol.go b/botanist/power/wol/wol.go
new file mode 100644
index 0000000..f05d503
--- /dev/null
+++ b/botanist/power/wol/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 wol
+
+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 Reboot(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/botanist/target/device.go b/botanist/target/device.go
new file mode 100644
index 0000000..2246503
--- /dev/null
+++ b/botanist/target/device.go
@@ -0,0 +1,222 @@
+// 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 target
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net"
+ "time"
+
+ "go.fuchsia.dev/tools/botanist"
+ "go.fuchsia.dev/tools/botanist/power"
+ "go.fuchsia.dev/tools/build"
+ "go.fuchsia.dev/tools/logger"
+ "go.fuchsia.dev/tools/netboot"
+ "go.fuchsia.dev/tools/netutil"
+ "go.fuchsia.dev/tools/serial"
+
+ "golang.org/x/crypto/ssh"
+)
+
+const (
+ // The duration we allow for the netstack to come up when booting.
+ netstackTimeout = 90 * time.Second
+)
+
+// DeviceConfig contains the static properties of a target device.
+type DeviceConfig struct {
+ // Network is the network properties of the target.
+ Network NetworkProperties `json:"network"`
+
+ // Power is the attached power management configuration.
+ Power *power.Client `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"`
+}
+
+// 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 {
+ config DeviceConfig
+ opts Options
+ signers []ssh.Signer
+ serial io.ReadWriteCloser
+}
+
+// NewDeviceTarget returns a new device target with a given configuration.
+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 != "" {
+ 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)
+ }
+ 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
+}
+
+// Nodename returns the name of the node.
+func (t *DeviceTarget) Nodename() string {
+ return t.config.Network.Nodename
+}
+
+// IPv4Addr returns the IPv4 address of the node. If not provided in the config, then it
+// will be resolved against the target-side MDNS server.
+func (t *DeviceTarget) IPv4Addr() (net.IP, error) {
+ if t.config.Network.IPv4Addr != "" {
+ return net.ParseIP(t.config.Network.IPv4Addr), nil
+ }
+ return botanist.ResolveIPv4(context.Background(), t.Nodename(), netstackTimeout)
+}
+
+// Serial returns the serial device associated with the target for serial i/o.
+func (t *DeviceTarget) Serial() io.ReadWriteCloser {
+ return t.serial
+}
+
+// SSHKey returns the private SSH key path associated with the authorized key to be paved.
+func (t *DeviceTarget) SSHKey() string {
+ return t.config.SSHKeys[0]
+}
+
+// Start starts the device target.
+func (t *DeviceTarget) Start(ctx context.Context, images build.Images, args []string) error {
+ // 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 := netutil.GetNodeAddress(ctx, t.Nodename(), false)
+ if err != nil {
+ return err
+ }
+
+ // Mexec Zedboot
+ err = botanist.BootZedbootShim(ctx, addr, images)
+ 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.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
+}
+
+// Stop stops the device.
+func (t *DeviceTarget) Stop(ctx context.Context) error {
+ return ErrUnimplemented
+}
+
+// Wait waits for the device target to stop.
+func (t *DeviceTarget) Wait(ctx context.Context) error {
+ return ErrUnimplemented
+}
+
+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
new file mode 100644
index 0000000..30687ab
--- /dev/null
+++ b/botanist/target/device_test.go
@@ -0,0 +1,54 @@
+// 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 target
+
+import (
+ "io/ioutil"
+ "os"
+ "testing"
+)
+
+func TestLoadConfigs(t *testing.T) {
+ tests := []struct {
+ name string
+ jsonStr string
+ expectedLen int
+ expectErr bool
+ }{
+ // Valid configs.
+ {"ValidConfig", `[{"nodename":"upper-drank-wick-creek"},{"nodename":"siren-swoop-wick-hasty"}]`, 2, false},
+ // Invalid configs.
+ {"InvalidConfig", `{{"nodename":"upper-drank-wick-creek"},{"nodename":"siren-swoop-wick-hasty"}}`, 0, true},
+ }
+ for _, test := range tests {
+ tmpfile, err := ioutil.TempFile(os.TempDir(), "common_test")
+ if err != nil {
+ t.Fatalf("Failed to create test device properties file: %s", err)
+ }
+ defer os.Remove(tmpfile.Name())
+
+ content := []byte(test.jsonStr)
+ if _, err := tmpfile.Write(content); err != nil {
+ t.Fatalf("Failed to write to test device properties file: %s", err)
+ }
+
+ configs, err := LoadDeviceConfigs(tmpfile.Name())
+
+ if test.expectErr && err == nil {
+ t.Errorf("Test%v: Exepected errors; no errors found", test.name)
+ }
+
+ if !test.expectErr && err != nil {
+ t.Errorf("Test%v: Exepected no errors; found error - %v", test.name, err)
+ }
+
+ if len(configs) != test.expectedLen {
+ t.Errorf("Test%v: Expected %d nodes; found %d", test.name, test.expectedLen, len(configs))
+ }
+
+ if err := tmpfile.Close(); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
diff --git a/botanist/target/errors.go b/botanist/target/errors.go
new file mode 100644
index 0000000..bdb49f7
--- /dev/null
+++ b/botanist/target/errors.go
@@ -0,0 +1,14 @@
+// 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 target
+
+import (
+ "errors"
+)
+
+var (
+ // ErrUnimplemented is an error for unimplemented methods.
+ ErrUnimplemented error = errors.New("method unimplemented")
+)
diff --git a/botanist/target/options.go b/botanist/target/options.go
new file mode 100644
index 0000000..8440c0d
--- /dev/null
+++ b/botanist/target/options.go
@@ -0,0 +1,18 @@
+// 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 target
+
+// Options represents lifecycle options for a target. The options will not necessarily make
+// sense for all target types.
+type Options 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. Ignored for QEMUTarget.
+ Netboot bool
+
+ // SSHKey is a private SSH key file, corresponding to an authorized key to be paved or
+ // to one baked into a boot image.
+ SSHKey string
+}
diff --git a/botanist/target/qemu.go b/botanist/target/qemu.go
new file mode 100644
index 0000000..f4ddaad
--- /dev/null
+++ b/botanist/target/qemu.go
@@ -0,0 +1,285 @@
+// 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 target
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "log"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+
+ "go.fuchsia.dev/tools/build"
+ "go.fuchsia.dev/tools/qemu"
+)
+
+const (
+ // qemuSystemPrefix is the prefix of the QEMU binary name, which is of the
+ // form qemu-system-<QEMU arch suffix>.
+ qemuSystemPrefix = "qemu-system"
+
+ // DefaultInterfaceName is the name given to the emulated tap interface.
+ defaultInterfaceName = "qemu"
+
+ // DefaultMACAddr is the default MAC address given to a QEMU target.
+ defaultMACAddr = "52:54:00:63:5e:7a"
+
+ // DefaultNodename is the default nodename given to an target with the default QEMU MAC address.
+ defaultNodename = "step-atom-yard-juicy"
+)
+
+// qemuTargetMapping maps the Fuchsia target name to the name recognized by QEMU.
+var qemuTargetMapping = map[string]string{
+ "x64": qemu.TargetX86_64,
+ "arm64": qemu.TargetAArch64,
+}
+
+// MinFS is the configuration for the MinFS filesystem image.
+type MinFS struct {
+ // Image is the path to the filesystem image.
+ Image string `json:"image"`
+
+ // PCIAddress is the PCI address to map the device at.
+ PCIAddress string `json:"pci_address"`
+}
+
+// QEMUConfig is a QEMU configuration.
+type QEMUConfig struct {
+ // Path is a path to a directory that contains QEMU system binary.
+ Path string `json:"path"`
+
+ // Target is the QEMU target to emulate.
+ Target string `json:"target"`
+
+ // CPU is the number of processors to emulate.
+ CPU int `json:"cpu"`
+
+ // Memory is the amount of memory (in MB) to provide.
+ Memory int `json:"memory"`
+
+ // KVM specifies whether to enable hardware virtualization acceleration.
+ KVM bool `json:"kvm"`
+
+ // Whether User networking is enabled; if false, a Tap interface will be used.
+ UserNetworking bool `json:"user_networking"`
+
+ // MinFS is the filesystem to mount as a device.
+ MinFS *MinFS `json:"minfs,omitempty"`
+}
+
+// QEMUTarget is a QEMU target.
+type QEMUTarget struct {
+ config QEMUConfig
+ opts Options
+
+ c chan error
+
+ cmd *exec.Cmd
+ status error
+}
+
+// NewQEMUTarget returns a new QEMU target with a given configuration.
+func NewQEMUTarget(config QEMUConfig, opts Options) *QEMUTarget {
+ return &QEMUTarget{
+ config: config,
+ opts: opts,
+ c: make(chan error),
+ }
+}
+
+// Nodename returns the name of the target node.
+func (t *QEMUTarget) Nodename() string {
+ return defaultNodename
+}
+
+// IPv4Addr returns a nil address, as DHCP is not currently configured.
+func (t *QEMUTarget) IPv4Addr() (net.IP, error) {
+ return nil, nil
+}
+
+// Serial returns the serial device associated with the target for serial i/o.
+func (t *QEMUTarget) Serial() io.ReadWriteCloser {
+ return nil
+}
+
+// SSHKey returns the private SSH key path associated with the authorized key to be pavet.
+func (t *QEMUTarget) SSHKey() string {
+ return t.opts.SSHKey
+}
+
+// Start starts the QEMU target.
+func (t *QEMUTarget) Start(ctx context.Context, images build.Images, args []string) error {
+ qemuTarget, ok := qemuTargetMapping[t.config.Target]
+ if !ok {
+ return fmt.Errorf("invalid target %q", t.config.Target)
+ }
+
+ if t.config.Path == "" {
+ return fmt.Errorf("directory must be set")
+ }
+ qemuSystem := filepath.Join(t.config.Path, fmt.Sprintf("%s-%s", qemuSystemPrefix, qemuTarget))
+ if _, err := os.Stat(qemuSystem); err != nil {
+ return fmt.Errorf("could not find qemu-system binary %q: %v", qemuSystem, err)
+ }
+
+ qemuKernel := images.Get("qemu-kernel")
+ if qemuKernel == nil {
+ return fmt.Errorf("could not find qemu-kernel")
+ }
+ zirconA := images.Get("zircon-a")
+ if zirconA == nil {
+ return fmt.Errorf("could not find zircon-a")
+ }
+
+ var drives []qemu.Drive
+ if storageFull := images.Get("storage-full"); storageFull != nil {
+ drives = append(drives, qemu.Drive{
+ ID: "maindisk",
+ File: storageFull.Path,
+ })
+ }
+ if t.config.MinFS != nil {
+ if _, err := os.Stat(t.config.MinFS.Image); err != nil {
+ return fmt.Errorf("could not find minfs image %q: %v", t.config.MinFS.Image, err)
+ }
+ file, err := filepath.Abs(t.config.MinFS.Image)
+ if err != nil {
+ return err
+ }
+ // Swarming hard-links Isolate downloads with a cache and the very same
+ // cached minfs image will be used across multiple tasks. To ensure
+ // that it remains blank, we must break its link.
+ if err := overwriteFileWithCopy(file); err != nil {
+ return err
+ }
+ drives = append(drives, qemu.Drive{
+ ID: "testdisk",
+ File: file,
+ Addr: t.config.MinFS.PCIAddress,
+ })
+ }
+
+ netdev := qemu.Netdev{
+ ID: "net0",
+ MAC: defaultMACAddr,
+ }
+ if t.config.UserNetworking {
+ netdev.User = &qemu.NetdevUser{}
+ } else {
+ netdev.Tap = &qemu.NetdevTap{
+ Name: defaultInterfaceName,
+ }
+ }
+ networks := []qemu.Netdev{netdev}
+
+ config := qemu.Config{
+ Binary: qemuSystem,
+ Target: qemuTarget,
+ CPU: t.config.CPU,
+ Memory: t.config.Memory,
+ KVM: t.config.KVM,
+ Kernel: qemuKernel.Path,
+ Initrd: zirconA.Path,
+ Drives: drives,
+ Networks: networks,
+ }
+
+ // The system will halt on a kernel panic instead of rebooting.
+ args = append(args, "kernel.halt-on-panic=true")
+ // Print a message if `dm poweroff` times out.
+ args = append(args, "devmgr.suspend-timeout-debug=true")
+ // Do not print colors.
+ args = append(args, "TERM=dumb")
+ if t.config.Target == "x64" {
+ // Necessary to redirect to stdout.
+ args = append(args, "kernel.serial=legacy")
+ }
+
+ invocation, err := qemu.CreateInvocation(config, args)
+ if err != nil {
+ return err
+ }
+
+ // The QEMU command needs to be invoked within an empty directory, as QEMU
+ // will attempt to pick up files from its working directory, one notable
+ // culprit being multiboot.bin. This can result in strange behavior.
+ workdir, err := ioutil.TempDir("", "qemu-working-dir")
+ if err != nil {
+ return err
+ }
+
+ t.cmd = &exec.Cmd{
+ Path: invocation[0],
+ Args: invocation,
+ Dir: workdir,
+ Stdout: os.Stdout,
+ Stderr: os.Stderr,
+ }
+ log.Printf("QEMU invocation:\n%s", invocation)
+
+ if err := t.cmd.Start(); err != nil {
+ os.RemoveAll(workdir)
+ return fmt.Errorf("failed to start: %v", err)
+ }
+
+ // Ensure that the working directory when QEMU finishes whether the Wait
+ // method is invoked or not.
+ go func() {
+ defer os.RemoveAll(workdir)
+ t.c <- qemu.CheckExitCode(t.cmd.Wait())
+ }()
+
+ return nil
+}
+
+// Wait waits for the QEMU target to stop.
+func (t *QEMUTarget) Restart(ctx context.Context) error {
+ return ErrUnimplemented
+}
+
+// Stop stops the QEMU target.
+func (t *QEMUTarget) Stop(ctx context.Context) error {
+ return t.cmd.Process.Kill()
+}
+
+// Wait waits for the QEMU target to stop.
+func (t *QEMUTarget) Wait(ctx context.Context) error {
+ return <-t.c
+}
+
+func overwriteFileWithCopy(path string) error {
+ tmpfile, err := ioutil.TempFile(filepath.Dir(path), "botanist")
+ if err != nil {
+ return err
+ }
+ defer tmpfile.Close()
+ if err := copyFile(path, tmpfile.Name()); err != nil {
+ return err
+ }
+ return os.Rename(tmpfile.Name(), path)
+}
+
+func copyFile(src, dest string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ info, err := in.Stat()
+ if err != nil {
+ return err
+ }
+ out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE, info.Mode().Perm())
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+ _, err = io.Copy(out, in)
+ return err
+}
diff --git a/digest/digest.go b/digest/digest.go
new file mode 100644
index 0000000..607135e
--- /dev/null
+++ b/digest/digest.go
@@ -0,0 +1,234 @@
+// Copyright 2013 M-Lab
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package digest
+
+import (
+ "crypto/md5"
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+)
+
+var (
+ ErrNilTransport = errors.New("transport is nil")
+ ErrBadChallenge = errors.New("challenge is bad")
+ ErrAlgNotImplemented = errors.New("algorithm not implemented")
+)
+
+// Transport is an implementation of http.RoundTripper that supports HTTP
+// digest authentication.
+type Transport struct {
+ Username string
+ Password string
+ Transport http.RoundTripper
+}
+
+// NewTransport creates a new digest transport using http.DefaultTransport.
+func NewTransport(username, password string) *Transport {
+ return &Transport{
+ Username: username,
+ Password: password,
+ Transport: http.DefaultTransport,
+ }
+}
+
+// RoundTrip makes a request expecting a 401 response that will require digest
+// authentication. It creates the credentials it needs and makes a follow-up
+// request.
+func (t *Transport) RoundTrip(r *http.Request) (*http.Response, error) {
+ if t.Transport == nil {
+ return nil, ErrNilTransport
+ }
+
+ body, err := r.GetBody()
+ if err != nil {
+ return nil, err
+ }
+ req, err := http.NewRequest(r.Method, r.URL.String(), body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header = make(http.Header)
+ for k, s := range r.Header {
+ req.Header[k] = s
+ }
+
+ // Make a request to get the 401 that contains the challenge.
+ res, err := t.Transport.RoundTrip(r)
+ if err != nil || res.StatusCode != 401 {
+ return res, err
+ }
+ defer res.Body.Close()
+
+ chal := res.Header.Get("WWW-Authenticate")
+ c, err := parseChallenge(chal)
+ if err != nil {
+ return res, err
+ }
+
+ // Generate credentials based on the challenge.
+ cr := t.authenticate(req, c)
+ auth, err := cr.authorize()
+ if err != nil {
+ return res, err
+ }
+
+ // Make authenticated request.
+ req.Header.Set("Authorization", auth)
+ return t.Transport.RoundTrip(req)
+}
+
+type challenge struct {
+ realm string
+ domain string
+ nonce string
+ opaque string
+ stale string
+ algorithm string
+ qop string
+}
+
+func parseChallenge(input string) (*challenge, error) {
+ const ws = " \n\r\t"
+ const qs = `"`
+ s := strings.Trim(input, ws)
+ if !strings.HasPrefix(s, "Digest ") {
+ return nil, ErrBadChallenge
+ }
+ s = strings.Trim(s[7:], ws)
+ sl := strings.Split(s, ",")
+ c := &challenge{
+ algorithm: "MD5",
+ }
+ var r []string
+ for i := range sl {
+ r = strings.SplitN(strings.Trim(sl[i], ws), "=", 2)
+ switch r[0] {
+ case "realm":
+ c.realm = strings.Trim(r[1], qs)
+ case "domain":
+ c.domain = strings.Trim(r[1], qs)
+ case "nonce":
+ c.nonce = strings.Trim(r[1], qs)
+ case "opaque":
+ c.opaque = strings.Trim(r[1], qs)
+ case "stale":
+ c.stale = strings.Trim(r[1], qs)
+ case "algorithm":
+ c.algorithm = strings.Trim(r[1], qs)
+ case "qop":
+ c.qop = strings.Trim(r[1], qs)
+ default:
+ return nil, ErrBadChallenge
+ }
+ }
+ return c, nil
+}
+
+type credentials struct {
+ userhash bool
+ username string
+ realm string
+ nonce string
+ uri string
+ algorithm string
+ cnonce string
+ opaque string
+ qop string
+ nc int
+ method string
+ password string
+}
+
+func h(data string) string {
+ return fmt.Sprintf("%x", md5.Sum([]byte(data)))
+}
+
+func (c *credentials) ha1() string {
+ return h(fmt.Sprintf("%s:%s:%s", c.username, c.realm, c.password))
+}
+
+func (c *credentials) ha2() string {
+ return h(fmt.Sprintf("%s:%s", c.method, c.uri))
+}
+
+func (c *credentials) response(cnonce string) (string, error) {
+ c.nc++
+ if c.qop == "auth" {
+ if cnonce != "" {
+ c.cnonce = cnonce
+ } else {
+ b := make([]byte, 8)
+ io.ReadFull(rand.Reader, b)
+ c.cnonce = fmt.Sprintf("%x", b)[:16]
+ }
+ return h(fmt.Sprintf("%s:%s:%08x:%s:%s:%s",
+ c.ha1(), c.nonce, c.nc, c.cnonce, c.qop, c.ha2())), nil
+ } else if c.qop == "" {
+ return h(fmt.Sprintf("%s:%s:%s", c.ha1(), c.nonce, c.ha2())), nil
+ }
+ return "", ErrAlgNotImplemented
+}
+
+func (c *credentials) authorize() (string, error) {
+ if c.algorithm != "MD5" {
+ return "", ErrAlgNotImplemented
+ }
+ if c.qop != "auth" && c.qop != "" {
+ return "", ErrAlgNotImplemented
+ }
+ response, err := c.response("")
+ if err != nil {
+ return "", err
+ }
+ sl := []string{}
+ sl = append(sl, fmt.Sprintf(`username="%s"`, c.username))
+ sl = append(sl, fmt.Sprintf(`realm="%s"`, c.realm))
+ sl = append(sl, fmt.Sprintf(`nonce="%s"`, c.nonce))
+ sl = append(sl, fmt.Sprintf(`uri="%s"`, c.uri))
+ sl = append(sl, fmt.Sprintf(`response="%s"`, response))
+ sl = append(sl, fmt.Sprintf(`algorithm="%s"`, c.algorithm))
+ sl = append(sl, fmt.Sprintf(`cnonce="%s"`, c.cnonce))
+ if c.opaque != "" {
+ sl = append(sl, fmt.Sprintf(`opaque="%s"`, c.opaque))
+ }
+ if c.qop != "" {
+ sl = append(sl, fmt.Sprintf(`qop=%s`, c.qop))
+ }
+ sl = append(sl, fmt.Sprintf("nc=%08x", c.nc))
+ if c.userhash {
+ sl = append(sl, `userhash="true"`)
+ }
+ return fmt.Sprintf("Digest %s", strings.Join(sl, ", ")), nil
+}
+
+func (t *Transport) authenticate(req *http.Request, c *challenge) *credentials {
+ return &credentials{
+ username: t.Username,
+ realm: c.realm,
+ nonce: c.nonce,
+ uri: req.URL.RequestURI(),
+ algorithm: c.algorithm,
+ opaque: c.opaque,
+ qop: c.qop,
+ nc: 0,
+ method: req.Method,
+ password: t.Password,
+ }
+}
diff --git a/digest/digest_test.go b/digest/digest_test.go
new file mode 100644
index 0000000..8607a48
--- /dev/null
+++ b/digest/digest_test.go
@@ -0,0 +1,46 @@
+// 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 digest
+
+import (
+ "testing"
+)
+
+var c = &credentials{
+ username: "admin",
+ realm: "Digest:4C1F0000000000000000000000000000",
+ nonce: "GZHoABAHAAAAAAAAtejSfCEQLbW+c/fM",
+ uri: "/index",
+ algorithm: "MD5",
+ qop: "auth",
+ method: "POST",
+ password: "password",
+}
+
+var cnonce = "0a4f113b"
+
+func TestHa1(t *testing.T) {
+ r := c.ha1()
+ if r != "e00fd2f74e4bb1ccd5c3f359e13822ce" {
+ t.Fail()
+ }
+}
+
+func TestHa2(t *testing.T) {
+ r := c.ha2()
+ if r != "f272ccec928f9de4e8e0bc6319ab2c66" {
+ t.Fail()
+ }
+}
+
+func TestResponse(t *testing.T) {
+ r, err := c.response(cnonce)
+ if err != nil {
+ t.Fail()
+ }
+ if r != "ce25c065de2d1c900b21ed6d6fbe886b" {
+ t.Fail()
+ }
+}
diff --git a/go.mod b/go.mod
index 850392a..537d9af 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@
cloud.google.com/go v0.44.3
github.com/google/go-cmp v0.3.1
github.com/google/subcommands v1.0.1
+ github.com/google/uuid v1.1.1
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a
diff --git a/go.sum b/go.sum
index c3854f4..b42d0ed 100644
--- a/go.sum
+++ b/go.sum
@@ -29,6 +29,8 @@
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/subcommands v1.0.1 h1:/eqq+otEXm5vhfBrbREPCSVQbvofip6kIz+mX5TUH7k=
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
+github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=