[testrunner] Add testrunner binary

IN-824 #comment

Change-Id: I04223efa0dcbd32daaaed899fae2d6c11a6dd9ac
diff --git a/cmd/testrunner/main.go b/cmd/testrunner/main.go
new file mode 100644
index 0000000..ded31f8
--- /dev/null
+++ b/cmd/testrunner/main.go
@@ -0,0 +1,216 @@
+// 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 main
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"log"
+	"os"
+	"path"
+	"strings"
+	"time"
+
+	"fuchsia.googlesource.com/tools/botanist"
+	"fuchsia.googlesource.com/tools/runtests"
+	"fuchsia.googlesource.com/tools/testsharder"
+	"golang.org/x/crypto/ssh"
+)
+
+// TODO(IN-824): Produce a tar archive of all output files.
+// TODO(IN-824): Include log_listener output.
+
+const (
+	// Default amount of time to wait before failing to perform any IO action.
+	defaultIOTimeout = 1 * time.Minute
+
+	// The username used to authenticate with the Fuchsia device.
+	sshUser = "fuchsia"
+
+	// The test output directory to create on the Fuchsia device.
+	fuchsiaOutputDir = "/data/infra/testrunner"
+)
+
+// Command-line flags
+var (
+	// Whether to show Usage and exit.
+	help bool
+
+	// The directory where output should be written. This path will contain both
+	// summary.json and the set of output files for each test.
+	outputDir string
+
+	// The path to a file containing properties of the Fuchsia device to use for testing.
+	deviceFilepath string
+)
+
+func usage() {
+	fmt.Println(`
+		testrunner [flags] tests-file
+
+		Executes all tests found in the JSON [tests-file]
+		Required environment variables:
+
+		"NODENAME":  The nodename of the attached Fuchsia device to use for testing. This
+		           can usually be found using the 'netls' tool.
+		"SSH_KEY":   Path to the SSH private key used to connect to the Fuchsia device.
+	`)
+}
+
+func init() {
+	flag.BoolVar(&help, "help", false, "Whether to show Usage and exit.")
+	flag.StringVar(&outputDir, "output", "", "Directory where output should be written")
+	flag.Usage = usage
+}
+
+func main() {
+	flag.Parse()
+
+	if help || flag.NArg() != 1 {
+		flag.Usage()
+		flag.PrintDefaults()
+		return
+	}
+
+	if outputDir == "" {
+		log.Fatal("-output is required")
+	}
+
+	if err := execute(flag.Arg(0)); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func execute(testsFilepath string) error {
+	// Validate inputs.
+	nodename := os.Getenv("NODENAME")
+	if nodename == "" {
+		return errors.New("missing environment variable NODENAME")
+	}
+
+	privateKeyPath := os.Getenv("SSH_KEY")
+	if privateKeyPath == "" {
+		return errors.New("missing environment variable SSH_KEY")
+	}
+
+	// Initialize the connection to the Fuchsia device.
+	sshClient, err := sshIntoNode(nodename, privateKeyPath)
+	if err != nil {
+		return fmt.Errorf("failed to connect to node %q: %v", nodename, err)
+	}
+
+	fuchsiaTester := &FuchsiaTester{
+		remoteOutputDir: fuchsiaOutputDir,
+		delegate: &SSHTester{
+			client: sshClient,
+		},
+	}
+
+	// Parse test input.
+	bytes, err := ioutil.ReadFile(testsFilepath)
+	if err != nil {
+		return fmt.Errorf("failed to read %s: %v", testsFilepath, err)
+	}
+
+	var tests []testsharder.Test
+	if err := json.Unmarshal(bytes, &tests); err != nil {
+		return fmt.Errorf("failed to unmarshal %s: %v", testsFilepath, err)
+	}
+
+	// Execute all tests.
+	summary, err := runTests(tests, fuchsiaTester.Test, RunTestInSubprocess, outputDir)
+	if err != nil {
+		return err
+	}
+
+	summaryFile, err := os.Create(path.Join(outputDir, "summary.json"))
+	if err != nil {
+		return err
+	}
+
+	// Log summary to `outputDir`.
+	encoder := json.NewEncoder(summaryFile)
+	return encoder.Encode(summary)
+}
+
+func sshIntoNode(nodename, privateKeyPath string) (*ssh.Client, error) {
+	privateKey, err := ioutil.ReadFile(privateKeyPath)
+	if err != nil {
+		return nil, err
+	}
+
+	signer, err := ssh.ParsePrivateKey(privateKey)
+	if err != nil {
+		return nil, err
+	}
+
+	config := &ssh.ClientConfig{
+		User: sshUser,
+		Auth: []ssh.AuthMethod{
+			ssh.PublicKeys(signer),
+		},
+		Timeout:         defaultIOTimeout,
+		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+	}
+
+	return botanist.SSHIntoNode(context.Background(), nodename, config)
+}
+
+func runTests(tests []testsharder.Test, fuchsia Tester, local Tester, outputDir string) (*runtests.TestSummary, error) {
+	// Execute all tests.
+	summary := new(runtests.TestSummary)
+	for _, test := range tests {
+		var tester Tester
+		switch strings.ToLower(test.OS) {
+		case "fuchsia":
+			tester = fuchsia
+		case "linux", "mac":
+			tester = local
+		default:
+			log.Printf("cannot run '%s' on unknown OS '%s'", test.Name, test.OS)
+			continue
+		}
+
+		details, err := runTest(context.Background(), test, tester, outputDir)
+		if err != nil {
+			log.Println(err)
+		}
+
+		if details != nil {
+			summary.Tests = append(summary.Tests, *details)
+		}
+	}
+
+	return summary, nil
+}
+
+func runTest(ctx context.Context, test testsharder.Test, tester Tester, outputDir string) (*runtests.TestDetails, error) {
+	// Create a file for test output.
+	output, err := os.Create(path.Join(outputDir, test.Name, runtests.TestOutputFilename))
+	if err != nil {
+		return nil, err
+	}
+	defer output.Close()
+
+	result := runtests.TestSuccess
+	multistdout := io.MultiWriter(output, os.Stdout)
+	multistderr := io.MultiWriter(output, os.Stderr)
+	if err := tester(ctx, test, multistdout, multistderr); err != nil {
+		result = runtests.TestFailure
+		log.Println(err)
+	}
+
+	// Record the test details in the summary.
+	return &runtests.TestDetails{
+		Name:       test.Name,
+		OutputFile: output.Name(),
+		Result:     result,
+	}, nil
+}
diff --git a/cmd/testrunner/tester.go b/cmd/testrunner/tester.go
new file mode 100644
index 0000000..53296d4
--- /dev/null
+++ b/cmd/testrunner/tester.go
@@ -0,0 +1,61 @@
+// 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 main
+
+import (
+	"context"
+	"io"
+
+	"fuchsia.googlesource.com/tools/testrunner"
+	"fuchsia.googlesource.com/tools/testsharder"
+	"golang.org/x/crypto/ssh"
+)
+
+// Tester is executes a Test.
+type Tester func(context.Context, testsharder.Test, io.Writer, io.Writer) error
+
+// RunTestInSubprocess is a TesterFunc that executes the given test in a local subprocess.
+func RunTestInSubprocess(ctx context.Context, test testsharder.Test, stdout io.Writer, stderr io.Writer) error {
+	var command []string
+	if len(test.Location) > 0 {
+		command = []string{test.Location}
+	} else {
+		command = test.Command
+	}
+
+	runner := new(testrunner.SubprocessRunner)
+	return runner.Run(ctx, command, stdout, stderr)
+}
+
+// SSHTester is executes tests over an SSH connection. It assumes the test.Command
+// contains the command line to execute on the remote machine.
+type SSHTester struct {
+	client *ssh.Client
+}
+
+func (t *SSHTester) Test(ctx context.Context, test testsharder.Test, stdout io.Writer, stderr io.Writer) error {
+	session, err := t.client.NewSession()
+	if err != nil {
+		return err
+	}
+	defer session.Close()
+
+	runner := &testrunner.SSHRunner{Session: session}
+	return runner.Run(ctx, test.Command, stdout, stderr)
+}
+
+// This is a hack. We have to run Fuchsia tests using `runtests` on the remote device
+// because there are many ways to execute Fuchsia tests and runtests already does this
+// correctly. This wrapper around SSHTester is meant to keep SSHTester free of OS-specific
+// behavior. Later we'll delete this and use SSHTester directly.
+type FuchsiaTester struct {
+	remoteOutputDir string
+	delegate        *SSHTester
+}
+
+func (t *FuchsiaTester) Test(ctx context.Context, test testsharder.Test, stdout io.Writer, stderr io.Writer) error {
+	test.Command = []string{"runtests", "-t", test.Location, "-o", t.remoteOutputDir + "runtests"}
+	return t.delegate.Test(ctx, test, stdout, stderr)
+}
diff --git a/cmd/testrunner/tester_test.go b/cmd/testrunner/tester_test.go
new file mode 100644
index 0000000..b182a42
--- /dev/null
+++ b/cmd/testrunner/tester_test.go
@@ -0,0 +1,104 @@
+// 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 main
+
+import (
+	"bytes"
+	"context"
+	"os"
+	"strings"
+	"testing"
+
+	"fuchsia.googlesource.com/tools/testsharder"
+)
+
+func TestTester(t *testing.T) {
+	tester := RunTestInSubprocess
+	cases := []testCase{
+		{
+			name:   "should run a command a local subprocess",
+			tester: tester,
+			test: testsharder.Test{
+				Name: "hello_world_test",
+				// Assumes that we're running on a Unix system.
+				Command: []string{"/bin/echo", "Hello world!"},
+			},
+			expectedOutput: "Hello world!",
+		},
+	}
+
+	runTestCases(t, cases)
+}
+
+// Verifies that SSHTester can execute tests on a remote device. These tests are
+// only meant for local verification.
+func TestSSHTester(t *testing.T) {
+	t.Skip("ssh tests are meant for local testing only")
+
+	nodename := os.Getenv("NODENAME")
+	if nodename == "" {
+		t.Fatalf("missing environment variable '%s'", "NODENAME")
+	}
+
+	privateKeyPath := os.Getenv("SSH_KEY")
+	if privateKeyPath == "" {
+		t.Fatalf("missing environment variable '%s'", privateKeyPath)
+	}
+
+	client, err := sshIntoNode(nodename, privateKeyPath)
+	if err != nil {
+		t.Fatalf("failed to connect to node '%s': %v", nodename, err)
+	}
+
+	tester := &SSHTester{client: client}
+	cases := []testCase{
+		{
+			name:   "should run a command over SSH",
+			tester: tester.Test,
+			test: testsharder.Test{
+				Name: "hello_world_test",
+				// Just 'echo' and not '/bin/echo' because this assumes we're running on
+				// Fuchsia.
+				Command: []string{"echo", "Hello world!"},
+			},
+			expectedOutput: "Hello world!",
+		},
+		{
+			name:   "should run successive commands over SSH",
+			tester: tester.Test,
+			test: testsharder.Test{
+				Name:    "hello_again_test",
+				Command: []string{"echo", "Hello again!"},
+			},
+			expectedOutput: "Hello again!",
+		},
+	}
+
+	runTestCases(t, cases)
+}
+
+type testCase struct {
+	name           string
+	test           testsharder.Test
+	tester         Tester
+	expectedOutput string
+	expectError    bool
+}
+
+func runTestCases(t *testing.T, cases []testCase) {
+	for _, tt := range cases {
+		t.Run(tt.name, func(t *testing.T) {
+			output := new(bytes.Buffer)
+			err := tt.tester(context.Background(), tt.test, output, output)
+			if tt.expectError && err == nil {
+				t.Fatalf("%s: got nil, wanted error", tt.name)
+			} else if !tt.expectError && err != nil {
+				t.Fatalf("%s: got err '%v', wanted nil", tt.name, err)
+			} else if tt.expectedOutput != strings.TrimSpace(output.String()) {
+				t.Fatalf("%s: got output: '%s', want: '%s'", tt.name, output.String(), tt.expectedOutput)
+			}
+		})
+	}
+}
diff --git a/go.mod b/go.mod
index 53cca96..48997a7 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,9 @@
 	github.com/google/uuid v1.1.0
 	github.com/googleapis/gax-go v2.0.2+incompatible // indirect
 	github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6 // indirect
+	github.com/kr/fs v0.1.0 // indirect
+	github.com/pkg/errors v0.8.1 // indirect
+	github.com/pkg/sftp v1.8.3
 	go.chromium.org/luci v0.0.0-20181218015242-20acb618582d
 	go.opencensus.io v0.18.0 // indirect
 	golang.org/x/arch v0.0.0-20181203225421-5a4828bb7045 // indirect
diff --git a/go.sum b/go.sum
index 2c3692e..49189fa 100644
--- a/go.sum
+++ b/go.sum
@@ -20,8 +20,14 @@
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6 h1:UDMh68UUwekSh5iP2OMhRRZJiiBccgV7axzUG8vi56c=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8=
+github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/sftp v1.8.3 h1:9jSe2SxTM8/3bXZjtqnkgTBW+lA8db0knZJyns7gpBA=
+github.com/pkg/sftp v1.8.3/go.mod h1:NxmoDg/QLVWluQDUYG7XBZTLUpKeFa8e3aMf1BfjyHk=
 github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
@@ -33,6 +39,7 @@
 golang.org/x/arch v0.0.0-20181203225421-5a4828bb7045/go.mod h1:cYlCBUl1MsqxdiKgmc4uh7TxZfWSFLOGSRR090WDxt8=
 golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0=
 golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc h1:F5tKCVGp+MUAHhKp5MZtGqAlGX3+oCsiL1Q629FL90M=
 golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=