Add pw_test_server module
This change adds a pw_test_server module which implements a gRPC server
for queueing and distributing unit tests across multiple test runners.
The server is implemented as a Go library which can be imported and used
by developers to build a custom unit test running infrastructure.
To use the server, a UnitTestRunner interface that processes requests to
run unit tests must be implemented and registered with the server. An
implementation of this interface which runs unit test executables
through an external command is provided alongside the server.
An example program that uses the server library to run a unit test
server is also provided within the module. This program uses the
command-based test runners to run unit tests on a local machine. It is
configurable through a config file, allowing multiple workers to be
registered with the server. The program additionally doubles as a gRPC
client for the server which can be invoked with the path to a unit test
executable to schedule it to be run.
Change-Id: I347d230370620395de09e277f9763d7df1c4abad
diff --git a/docs/BUILD.gn b/docs/BUILD.gn
index 662adc4..af3cd1f 100644
--- a/docs/BUILD.gn
+++ b/docs/BUILD.gn
@@ -34,5 +34,6 @@
"$dir_pw_docgen:docs",
"$dir_pw_preprocessor:docs",
"$dir_pw_string:docs",
+ "$dir_pw_test_server:docs",
]
}
diff --git a/docs/modules.rst b/docs/modules.rst
index cb2aa77..b166a0f 100644
--- a/docs/modules.rst
+++ b/docs/modules.rst
@@ -9,3 +9,4 @@
pw_docgen/docgen
pw_preprocessor/docs
pw_string/docs
+ pw_test_server/docs
diff --git a/modules.gni b/modules.gni
index 9c1d816..16c6a2e 100644
--- a/modules.gni
+++ b/modules.gni
@@ -33,6 +33,7 @@
dir_pw_span = "$dir_pigweed/pw_span"
dir_pw_status = "$dir_pigweed/pw_status"
dir_pw_string = "$dir_pigweed/pw_string"
+dir_pw_test_server = "$dir_pigweed/pw_test_server"
dir_pw_toolchain = "$dir_pigweed/pw_toolchain"
dir_pw_unit_test = "$dir_pigweed/pw_unit_test"
dir_pw_varint = "$dir_pigweed/pw_varint"
diff --git a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
index 1dde12c..6efa86e 100755
--- a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
+++ b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
@@ -221,13 +221,14 @@
the License.
""".splitlines(True))
-_EXCLUDE_FROM_COPYRIGHT_NOTICE = (
+_EXCLUDE_FROM_COPYRIGHT_NOTICE: Sequence[str] = (
r'(?:.+/)?\..+',
r'AUTHORS',
r'LICENSE',
r'.*\.md',
r'.*\.rst',
r'(?:.+/)?requirements.txt',
+ r'(.+/)?go.(mod|sum)',
)
diff --git a/pw_test_server/BUILD.gn b/pw_test_server/BUILD.gn
new file mode 100644
index 0000000..851ae59
--- /dev/null
+++ b/pw_test_server/BUILD.gn
@@ -0,0 +1,24 @@
+# Copyright 2019 The Pigweed Authors
+#
+# 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
+#
+# https://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.
+
+import("$dir_pw_docgen/docs.gni")
+
+pw_doc_group("docs") {
+ sources = [
+ "docs.rst",
+ ]
+ group_deps = [
+ "go:docs",
+ ]
+}
diff --git a/pw_test_server/config.proto b/pw_test_server/config.proto
new file mode 100644
index 0000000..550783f
--- /dev/null
+++ b/pw_test_server/config.proto
@@ -0,0 +1,32 @@
+// Copyright 2019 The Pigweed Authors
+//
+// 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
+//
+// https://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.
+syntax = "proto3";
+
+package pw.test_server;
+
+// Configuration options for running a test server.
+message ServerConfig {
+ // All runner programs that can be launched concurrently.
+ repeated TestRunner runner = 1;
+}
+
+// A program that can run a unit test binary. Must take the path to a test
+// executable as a single positional argument.
+message TestRunner {
+ // The program to run.
+ string command = 1;
+
+ // Other option arguments to the program.
+ repeated string args = 2;
+}
diff --git a/pw_test_server/docs.rst b/pw_test_server/docs.rst
new file mode 100644
index 0000000..2215fe8
--- /dev/null
+++ b/pw_test_server/docs.rst
@@ -0,0 +1,108 @@
+.. _chapter-test-server:
+
+.. default-domain:: cpp
+
+.. highlight:: sh
+
+---------------
+pw_test_server
+---------------
+The test server module implements a gRPC server which runs unit tests.
+
+Overview
+--------
+The unit test server is responsible for processing requests to run unit tests
+and distributing them among a pool of test workers that run in parallel. This
+allows unit tests to be run across multiple devices simultaneously, greatly
+speeding up their execution time.
+
+Additionally, the server allows many tests to be queued up at once and scheduled
+across available devices, making it possible to automatically run unit tests
+from a Ninja build after code is updated. This integrates nicely with the
+``pw watch`` command to re-run all affected unit tests after modifying code.
+
+The test server is implemented as a library in various programming languages.
+This library provides the core gRPC server and a mechanism through which unit
+test worker routines can be registered. Code using the library instantiates a
+server with some custom workers for the desired target to run scheduled unit
+tests.
+
+The pw_test_server module also provides a standalone ``pw_test_server`` program
+which runs the test server with configurable workers that launch external
+processes to run unit tests. This program should be sufficient to quickly get
+unit tests running in a simple setup, such as multiple devices plugged into a
+development machine.
+
+Standalone executable
+---------------------
+This section describes how to use the ``pw_test_server`` program to set up a
+simple unit test server with workers.
+
+Configuration
+^^^^^^^^^^^^^
+The standalone server is configured from a file written in Protobuf text format
+containing a ``pw.test_server.ServerConfig`` message as defined in
+``//pw_test_server/config.proto``.
+
+At least one ``worker`` message must be specified. Each of the workers refers to
+a script or program which is invoked with the path to a unit test executable
+file as a positional argument. Other arguments provided to the program must be
+options/switches.
+
+For example, the config file below defines two workers, each connecting to an
+STM32F429I Discovery board with a specified serial number.
+
+**server_config.txt**
+
+.. code:: text
+
+ runner {
+ command: "stm32f429i_disc1_unit_test_runner"
+ args: "--openocd-config"
+ args: "targets/stm32f429i-disc1/py/stm32f429i_disc1_utils/openocd_stm32f4xx.cfg"
+ args: "--serial"
+ args: "066DFF575051717867013127"
+ }
+
+ runner {
+ command: "stm32f429i_disc1_unit_test_runner"
+ args: "--openocd-config"
+ args: "targets/stm32f429i-disc1/py/stm32f429i_disc1_utils/openocd_stm32f4xx.cfg"
+ args: "--serial"
+ args: "0667FF494849887767196023"
+ }
+
+
+Running the server
+^^^^^^^^^^^^^^^^^^
+To start the standalone server, run the ``pw_test_server`` program with the
+``-server`` flag and point it to your config file.
+
+.. code:: text
+
+ $ pw_test_server -server -config server_config.txt -port 8080
+
+
+Sending test requests
+^^^^^^^^^^^^^^^^^^^^^
+To request the server to run a unit test, the ``pw_test_server`` program is
+run in client mode, specifying the path to the unit test executable through a
+``-test`` option.
+
+.. code:: text
+
+ $ pw_test_server -host localhost -port 8080 -test /path/to/my/test.elf
+
+This command blocks until the test has run and prints out its output. Multiple
+tests can be scheduled in parallel; the server will distribute them among its
+available workers.
+
+Library APIs
+------------
+To use the test server library in your own code, refer to one of its programming
+language APIs below.
+
+.. toctree::
+ :maxdepth: 1
+
+ go/docs
diff --git a/pw_test_server/example_config.txt b/pw_test_server/example_config.txt
new file mode 100644
index 0000000..94e09d2
--- /dev/null
+++ b/pw_test_server/example_config.txt
@@ -0,0 +1,19 @@
+# Copyright 2019 The Pigweed Authors
+#
+# 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
+#
+# https://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.
+
+runner {
+ command: "stm32f429i_disc1_unit_test_runner"
+ args: "--port"
+ args: "/dev/ttyACM0"
+}
diff --git a/pw_test_server/go/BUILD.gn b/pw_test_server/go/BUILD.gn
new file mode 100644
index 0000000..9eed4a2
--- /dev/null
+++ b/pw_test_server/go/BUILD.gn
@@ -0,0 +1,21 @@
+# Copyright 2019 The Pigweed Authors
+#
+# 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
+#
+# https://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.
+
+import("$dir_pw_docgen/docs.gni")
+
+pw_doc_group("docs") {
+ sources = [
+ "docs.rst",
+ ]
+}
diff --git a/pw_test_server/go/client/client.go b/pw_test_server/go/client/client.go
new file mode 100644
index 0000000..d61a226
--- /dev/null
+++ b/pw_test_server/go/client/client.go
@@ -0,0 +1,77 @@
+// Copyright 2019 The Pigweed Authors
+//
+// 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
+//
+// https://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 client
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "time"
+
+ "google.golang.org/grpc"
+
+ pb "pigweed.dev/module/pw_test_server/gen"
+)
+
+// Client is a gRPC client that communicates with a TestServer service.
+type Client struct {
+ conn *grpc.ClientConn
+}
+
+// New creates a gRPC client which connects to a gRPC server hosted at the
+// specified address.
+func New(host string, port int) (*Client, error) {
+ // The server currently only supports running locally over an insecure
+ // connection.
+ // TODO(frolv): Investigate adding TLS support to the server and client.
+ opts := []grpc.DialOption{grpc.WithInsecure()}
+
+ conn, err := grpc.Dial(fmt.Sprintf("%s:%d", host, port), opts...)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Client{conn}, nil
+}
+
+// RunTest sends a RunUnitTest RPC to the test server.
+func (c *Client) RunTest(path string) error {
+ abspath, err := filepath.Abs(path)
+ if err != nil {
+ return err
+ }
+
+ client := pb.NewTestServerClient(c.conn)
+ req := &pb.UnitTestDescriptor{FilePath: abspath}
+
+ res, err := client.RunUnitTest(context.Background(), req)
+ if err != nil {
+ return err
+ }
+
+ fmt.Printf("%s\n", path)
+ fmt.Printf(
+ "Queued for %v, ran in %v\n\n",
+ time.Duration(res.QueueTimeNs),
+ time.Duration(res.RunTimeNs),
+ )
+ fmt.Println(string(res.Output))
+
+ if res.Result != pb.TestStatus_SUCCESS {
+ return errors.New("Unit test failed")
+ }
+
+ return nil
+}
diff --git a/pw_test_server/go/docs.rst b/pw_test_server/go/docs.rst
new file mode 100644
index 0000000..f35374b
--- /dev/null
+++ b/pw_test_server/go/docs.rst
@@ -0,0 +1,80 @@
+.. _chapter-test-server:
+
+.. default-domain:: go
+
+.. highlight:: go
+
+--
+Go
+--
+
+Server
+------
+
+.. TODO(frolv): Build and host documentation using godoc and link to it.
+
+Full API documentation for the server library can be found here.
+
+Example program
+^^^^^^^^^^^^^^^
+
+The code below implements a very basic test server with two test workers which
+print out the path of the tests they are scheduled to run.
+
+.. code::
+
+ package main
+
+ import (
+ "flag"
+ "log"
+
+ pb "pigweed.dev/module/pw_test_server/gen"
+ "pigweed.dev/module/pw_test_server/server"
+ )
+
+ // Custom test worker that implements the interface server.UnitTestRunner.
+ type MyWorker struct {
+ id int
+ }
+
+ func (w *MyWorker) WorkerStart() error {
+ log.Printf("Starting test worker %d\n", w.id)
+ return nil
+ }
+
+ func (w *MyWorker) WorkerExit() {
+ log.Printf("Exiting test worker %d\n", w.id)
+ }
+
+ func (w *MyWorker) HandleRunRequest(req *server.UnitTestRunRequest) *server.UnitTestRunResponse {
+ log.Printf("Worker %d running unit test %s\n", w.id, req.Path)
+ return &server.UnitTestRunResponse{
+ Output: []byte("Success!"),
+ Status: pb.TestStatus_SUCCESS,
+ }
+ }
+
+ // To run:
+ //
+ // $ go build -o server
+ // $ ./server -port 80
+ //
+ func main() {
+ port := flag.Int("port", 8080, "Port on which to run server")
+ flag.Parse()
+
+ s := server.New()
+
+ // Create and register as many unit test workers as you need.
+ s.RegisterWorker(&MyWorker{id: 0})
+ s.RegisterWorker(&MyWorker{id: 1})
+
+ if err := s.Bind(*port); err != nil {
+ log.Fatalf("Failed to bind to port %d: %v", *port, err)
+ }
+
+ if err := s.Serve(); err != nil {
+ log.Fatalf("Failed to start server: %v", err)
+ }
+ }
diff --git a/pw_test_server/go/go.mod b/pw_test_server/go/go.mod
new file mode 100644
index 0000000..4b4fdb8
--- /dev/null
+++ b/pw_test_server/go/go.mod
@@ -0,0 +1,8 @@
+module pigweed.dev/module/pw_test_server
+
+go 1.13
+
+require (
+ github.com/golang/protobuf v1.3.2
+ google.golang.org/grpc v1.25.1
+)
diff --git a/pw_test_server/go/go.sum b/pw_test_server/go/go.sum
new file mode 100644
index 0000000..16310d9
--- /dev/null
+++ b/pw_test_server/go/go.sum
@@ -0,0 +1,47 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.25.1 h1:wdKvqQk7IttEw92GoRyKG2IDrUIpgpj6H6m81yfeMW0=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/pw_test_server/go/main.go b/pw_test_server/go/main.go
new file mode 100644
index 0000000..7284068
--- /dev/null
+++ b/pw_test_server/go/main.go
@@ -0,0 +1,157 @@
+// Copyright 2019 The Pigweed Authors
+//
+// 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
+//
+// https://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 main
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "io/ioutil"
+ "log"
+
+ "github.com/golang/protobuf/proto"
+ "pigweed.dev/module/pw_test_server/client"
+ "pigweed.dev/module/pw_test_server/server"
+
+ pb "pigweed.dev/module/pw_test_server/gen"
+)
+
+// ServerOptions contains command-line options for the server.
+type ServerOptions struct {
+ // Path to a server configuration file.
+ config string
+
+ // Port on which to run.
+ port int
+}
+
+// ClientOptions contains command-line options for the client.
+type ClientOptions struct {
+ // Hostname of the server to request.
+ host string
+
+ // Port on which the server is running.
+ port int
+
+ // Path to a unit test binary.
+ testPath string
+}
+
+// configureServerFromFile sets up the server with workers specifyed in a
+// config file. The file contains a pw.test_server.ServerConfig protobuf message
+// in canonical protobuf text format.
+func configureServerFromFile(s *server.Server, filepath string) error {
+ content, err := ioutil.ReadFile(filepath)
+ if err != nil {
+ return err
+ }
+
+ var config pb.ServerConfig
+ if err := proto.UnmarshalText(string(content), &config); err != nil {
+ return err
+ }
+
+ log.Printf("Parsed server configuration from %s\n", filepath)
+
+ runners := config.GetRunner()
+ if runners == nil {
+ return nil
+ }
+
+ // Create an exec worker for each of the runner messages listed in the
+ // config and register them with the server.
+ for i, runner := range runners {
+ // Build the complete command for the worker from its "command"
+ // and "args" fields in the proto message. The command is
+ // required; arguments are optional.
+ cmd := []string{runner.GetCommand()}
+ if cmd[0] == "" {
+ msg := fmt.Sprintf(
+ "ServerConfig.runner[%d] does not specify a command; skipping\n", i)
+ return errors.New(msg)
+ }
+
+ if args := runner.GetArgs(); args != nil {
+ cmd = append(cmd, args...)
+ }
+
+ worker := server.NewExecTestRunner(i, cmd)
+ s.RegisterWorker(worker)
+
+ log.Printf(
+ "Registered unit test worker %s with args %v\n",
+ cmd[0],
+ cmd[1:])
+ }
+
+ return nil
+}
+
+func runServer(opts *ServerOptions) {
+ srv := server.New()
+
+ if opts.config != "" {
+ if err := configureServerFromFile(srv, opts.config); err != nil {
+ log.Fatalf("Failed to parse config file %s: %v", opts.config, err)
+ }
+ }
+
+ if err := srv.Bind(opts.port); err != nil {
+ log.Fatal(err)
+ }
+
+ if err := srv.Serve(); err != nil {
+ log.Fatalf("Failed to start server: %v", err)
+ }
+}
+
+func runClient(opts *ClientOptions) {
+ if opts.testPath == "" {
+ log.Fatalf("Must provide -test option")
+ }
+
+ cli, err := client.New(opts.host, opts.port)
+ if err != nil {
+ log.Fatalf("Failed to create gRPC client: %v", err)
+ }
+
+ if err := cli.RunTest(opts.testPath); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func main() {
+ serverPtr := flag.Bool("server", false, "Run as test server instead of client")
+ configPtr := flag.String("config", "", "Path to server configuration file")
+ portPtr := flag.Int("port", 8080, "Server port")
+ hostPtr := flag.String("host", "localhost", "Server host")
+ testPtr := flag.String("test", "", "Path to unit test executable")
+
+ flag.Parse()
+
+ if *serverPtr {
+ opts := &ServerOptions{
+ config: *configPtr,
+ port: *portPtr,
+ }
+ runServer(opts)
+ } else {
+ opts := &ClientOptions{
+ host: *hostPtr,
+ port: *portPtr,
+ testPath: *testPtr,
+ }
+ runClient(opts)
+ }
+}
diff --git a/pw_test_server/go/server/exec_test_runner.go b/pw_test_server/go/server/exec_test_runner.go
new file mode 100644
index 0000000..174960c
--- /dev/null
+++ b/pw_test_server/go/server/exec_test_runner.go
@@ -0,0 +1,81 @@
+// Copyright 2019 The Pigweed Authors
+//
+// 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
+//
+// https://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 server
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+
+ pb "pigweed.dev/module/pw_test_server/gen"
+)
+
+// ExecTestRunner is a struct that implements the UnitTestRunner interface,
+// running its tests by executing a command with the path of the unit test
+// executable as an argument.
+type ExecTestRunner struct {
+ command []string
+ logger *log.Logger
+}
+
+// NewExecTestRunner creates a new ExecTestRunner with a custom logger.
+func NewExecTestRunner(id int, command []string) *ExecTestRunner {
+ logPrefix := fmt.Sprintf("[ExecTestRunner %d] ", id)
+ logger := log.New(os.Stdout, logPrefix, log.LstdFlags)
+ return &ExecTestRunner{command, logger}
+}
+
+func (r *ExecTestRunner) WorkerStart() error {
+ r.logger.Printf("Starting worker")
+ return nil
+}
+
+func (r *ExecTestRunner) WorkerExit() {
+ r.logger.Printf("Exiting worker")
+}
+
+// HandleRunRequest runs a requested unit test binary by executing the runner's
+// command with the unit test as an argument. The combined stdout and stderr of
+// the command is returned as the unit test output.
+func (r *ExecTestRunner) HandleRunRequest(req *UnitTestRunRequest) *UnitTestRunResponse {
+ res := &UnitTestRunResponse{Status: pb.TestStatus_SUCCESS}
+
+ r.logger.Printf("Running unit test %s\n", req.Path)
+
+ // Copy runner command args, appending unit test binary path to the end.
+ args := append([]string(nil), r.command[1:]...)
+ args = append(args, req.Path)
+
+ cmd := exec.Command(r.command[0], args...)
+ output, err := cmd.CombinedOutput()
+
+ if err != nil {
+ if e, ok := err.(*exec.ExitError); ok {
+ // A nonzero exit status is interpreted as a unit test
+ // failure.
+ r.logger.Printf("Command exited with status %d\n", e.ExitCode())
+ res.Status = pb.TestStatus_FAILURE
+ } else {
+ // Any other error with the command execution is
+ // reported as an internal error to the requester.
+ r.logger.Printf("Command failed: %v\n", err)
+ res.Err = err
+ return res
+ }
+ }
+
+ res.Output = output
+ return res
+}
diff --git a/pw_test_server/go/server/server.go b/pw_test_server/go/server/server.go
new file mode 100644
index 0000000..edb9f11
--- /dev/null
+++ b/pw_test_server/go/server/server.go
@@ -0,0 +1,160 @@
+// Copyright 2019 The Pigweed Authors
+//
+// 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
+//
+// https://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 server
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net"
+ "time"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/reflection"
+ "google.golang.org/grpc/status"
+
+ pb "pigweed.dev/module/pw_test_server/gen"
+)
+
+var (
+ errServerNotBound = errors.New("Server not bound to a port")
+ errServerNotRunning = errors.New("Server is not running")
+)
+
+// Server is a gRPC server that runs a TestServer service.
+type Server struct {
+ grpcServer *grpc.Server
+ listener net.Listener
+ testsPassed uint32
+ testsFailed uint32
+ startTime time.Time
+ active bool
+ workerPool *TestWorkerPool
+}
+
+// New creates a gRPC server with a registered TestServer service.
+func New() *Server {
+ s := &Server{
+ grpcServer: grpc.NewServer(),
+ workerPool: newWorkerPool("ServerWorkerPool"),
+ }
+
+ reflection.Register(s.grpcServer)
+ pb.RegisterTestServerServer(s.grpcServer, &pwTestServer{s})
+
+ return s
+}
+
+// Bind starts a TCP listener on a specified port.
+func (s *Server) Bind(port int) error {
+ lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
+ if err != nil {
+ return err
+ }
+ s.listener = lis
+ return nil
+}
+
+// RegisterWorker adds a unit test worker to the server's worker pool.
+func (s *Server) RegisterWorker(worker UnitTestRunner) {
+ s.workerPool.RegisterWorker(worker)
+}
+
+// RunTest runs a unit test executable through a worker in the test server,
+// returning the worker's response. The function blocks until the test has
+// been processed.
+func (s *Server) RunTest(path string) (*UnitTestRunResponse, error) {
+ if !s.active {
+ return nil, errServerNotRunning
+ }
+
+ resChan := make(chan *UnitTestRunResponse, 1)
+ defer close(resChan)
+
+ s.workerPool.QueueTest(&UnitTestRunRequest{
+ Path: path,
+ ResponseChannel: resChan,
+ })
+
+ res := <-resChan
+
+ if res.Err != nil {
+ return nil, res.Err
+ }
+
+ if res.Status == pb.TestStatus_SUCCESS {
+ s.testsPassed += 1
+ } else {
+ s.testsFailed += 1
+ }
+
+ return res, nil
+}
+
+// Serve starts the gRPC test server on its configured port. Bind must have been
+// called before this; an error is returned if it is not. This function blocks
+// until the server is terminated.
+func (s *Server) Serve() error {
+ if s.listener == nil {
+ return errServerNotBound
+ }
+
+ log.Printf("Starting gRPC server on %v\n", s.listener.Addr())
+
+ s.startTime = time.Now()
+ s.active = true
+ s.workerPool.Start()
+
+ return s.grpcServer.Serve(s.listener)
+}
+
+// pwTestServer implements the pw.test_server.TestServer gRPC service.
+type pwTestServer struct {
+ server *Server
+}
+
+// RunUnitTest runs a single unit test binary and returns its result.
+func (s *pwTestServer) RunUnitTest(
+ ctx context.Context,
+ desc *pb.UnitTestDescriptor,
+) (*pb.UnitTestRunStatus, error) {
+ testRes, err := s.server.RunTest(desc.FilePath)
+ if err != nil {
+ return nil, status.Error(codes.Internal, "Internal server error")
+ }
+
+ res := &pb.UnitTestRunStatus{
+ Result: testRes.Status,
+ QueueTimeNs: uint64(testRes.QueueTime),
+ RunTimeNs: uint64(testRes.RunTime),
+ Output: testRes.Output,
+ }
+ return res, nil
+}
+
+// Status returns information about the server.
+func (s *pwTestServer) Status(
+ ctx context.Context,
+ _ *pb.Empty,
+) (*pb.ServerStatus, error) {
+ resp := &pb.ServerStatus{
+ UptimeNs: uint64(time.Since(s.server.startTime)),
+ TestsPassed: s.server.testsPassed,
+ TestsFailed: s.server.testsFailed,
+ }
+
+ return resp, nil
+}
diff --git a/pw_test_server/go/server/test_worker_pool.go b/pw_test_server/go/server/test_worker_pool.go
new file mode 100644
index 0000000..9713ab4
--- /dev/null
+++ b/pw_test_server/go/server/test_worker_pool.go
@@ -0,0 +1,229 @@
+// Copyright 2019 The Pigweed Authors
+//
+// 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
+//
+// https://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 server
+
+import (
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ pb "pigweed.dev/module/pw_test_server/gen"
+)
+
+// UnitTestRunRequest represents a client request to run a single unit test
+// executable.
+type UnitTestRunRequest struct {
+ // Filesystem path to the unit test executable.
+ Path string
+
+ // Channel to which the unit test response is sent back.
+ ResponseChannel chan<- *UnitTestRunResponse
+
+ // Time when the request was queued. Internal to the worker pool.
+ queueStart time.Time
+}
+
+// UnitTestRunResponse is the response sent after a unit test run request is
+// processed.
+type UnitTestRunResponse struct {
+ // Length of time that the run request was queued before being handled
+ // by a test worker. Set by the worker pool.
+ QueueTime time.Duration
+
+ // Length of time the unit test runner command took to run the test.
+ // Set by the worker pool.
+ RunTime time.Duration
+
+ // Raw output of the unit test.
+ Output []byte
+
+ // Result of the unit test run.
+ Status pb.TestStatus
+
+ // Error that occurred during the test run, if any. This is not an error
+ // with the unit test (e.g. test failure); rather, an internal error
+ // occurring in the test worker pool as it attempted to run the test.
+ // If this is not nil, none of the other fields in this struct are
+ // guaranteed to be valid.
+ Err error
+}
+
+// UnitTestRunner represents a worker which handles unit test run requests.
+type UnitTestRunner interface {
+ // WorkerStart is the lifecycle hook called when the worker routine is
+ // started. Any resources required by the worker should be initialized
+ // here.
+ WorkerStart() error
+
+ // HandleRunRequest is the method called when a unit test is scheduled
+ // to run on the worker by the worker pool. It processes the request,
+ // runs the unit test, and returns an appropriate response.
+ HandleRunRequest(*UnitTestRunRequest) *UnitTestRunResponse
+
+ // WorkerExit is the lifecycle hook called before the worker exits.
+ // Should be used to clean up any resources used by the worker.
+ WorkerExit()
+}
+
+// TestWorkerPool represents a collection of unit test workers which run unit
+// test binaries. The worker pool can schedule unit test runs, distributing the
+// tests among its available workers.
+type TestWorkerPool struct {
+ activeWorkers uint32
+ logger *log.Logger
+ workers []UnitTestRunner
+ waitGroup sync.WaitGroup
+ testChannel chan *UnitTestRunRequest
+ quitChannel chan bool
+}
+
+var (
+ errWorkerPoolActive = errors.New("Worker pool is running")
+ errNoRegisteredWorkers = errors.New("No workers registered in pool")
+)
+
+// newWorkerPool creates an empty test worker pool.
+func newWorkerPool(name string) *TestWorkerPool {
+ logPrefix := fmt.Sprintf("[%s] ", name)
+ return &TestWorkerPool{
+ logger: log.New(os.Stdout, logPrefix, log.LstdFlags),
+ workers: make([]UnitTestRunner, 0),
+ testChannel: make(chan *UnitTestRunRequest, 1024),
+ quitChannel: make(chan bool, 64),
+ }
+}
+
+// RegisterWorker adds a new unit test worker to the pool which uses the given
+// command and arguments to run its unit tests. This cannot be done when the
+// pool is processing requests; Stop() must be called first.
+func (p *TestWorkerPool) RegisterWorker(worker UnitTestRunner) error {
+ if p.Active() {
+ return errWorkerPoolActive
+ }
+ p.workers = append(p.workers, worker)
+ return nil
+}
+
+// Start launches all registered workers in the pool.
+func (p *TestWorkerPool) Start() error {
+ if p.Active() {
+ return errWorkerPoolActive
+ }
+
+ p.logger.Printf("Starting %d workers\n", len(p.workers))
+ for _, worker := range p.workers {
+ p.waitGroup.Add(1)
+ atomic.AddUint32(&p.activeWorkers, 1)
+ go p.runWorker(worker)
+ }
+
+ return nil
+}
+
+// Stop terminates all running workers in the pool. The work queue is not
+// cleared; queued requests persist and can be processed by calling Start()
+// again.
+func (p *TestWorkerPool) Stop() {
+ if !p.Active() {
+ return
+ }
+
+ // Send N quit commands to the workers and wait for them to exit.
+ for i := uint32(0); i < p.activeWorkers; i++ {
+ p.quitChannel <- true
+ }
+ p.waitGroup.Wait()
+
+ p.logger.Println("All workers in pool stopped")
+}
+
+// Active returns true if any worker routines are currently running.
+func (p *TestWorkerPool) Active() bool {
+ return p.activeWorkers > 0
+}
+
+// QueueTest adds a unit test to the worker pool's queue of tests. If no workers
+// are registered in the pool, this operation fails and an immediate response is
+// sent back to the requester indicating the error.
+func (p *TestWorkerPool) QueueTest(req *UnitTestRunRequest) {
+ if len(p.workers) == 0 {
+ p.logger.Printf("Attempt to queue test %s with no active workers", req.Path)
+ req.ResponseChannel <- &UnitTestRunResponse{
+ Err: errNoRegisteredWorkers,
+ }
+ return
+ }
+
+ p.logger.Printf("Queueing unit test %s\n", req.Path)
+
+ // Start tracking how long the request is queued.
+ req.queueStart = time.Now()
+ p.testChannel <- req
+}
+
+// runWorker is a function run by the test worker pool in a separate goroutine
+// for each of its registered workers. The function is responsible for calling
+// the appropriate worker lifecycle hooks and processing requests as they come
+// in through the worker pool's queue.
+func (p *TestWorkerPool) runWorker(worker UnitTestRunner) {
+ defer func() {
+ atomic.AddUint32(&p.activeWorkers, ^uint32(0))
+ p.waitGroup.Done()
+ }()
+
+ if err := worker.WorkerStart(); err != nil {
+ return
+ }
+
+processLoop:
+ for {
+ // Force the quit channel to be processed before the request
+ // channel by using a select statement with an empty default
+ // case to make the read non-blocking. If the quit channel is
+ // empty, the code will fall through to the main select below.
+ select {
+ case q, ok := <-p.quitChannel:
+ if q || !ok {
+ break processLoop
+ }
+ default:
+ }
+
+ select {
+ case q, ok := <-p.quitChannel:
+ if q || !ok {
+ break processLoop
+ }
+ case req, ok := <-p.testChannel:
+ if !ok {
+ continue
+ }
+
+ queueTime := time.Since(req.queueStart)
+
+ runStart := time.Now()
+ res := worker.HandleRunRequest(req)
+ res.RunTime = time.Since(runStart)
+
+ res.QueueTime = queueTime
+ req.ResponseChannel <- res
+ }
+ }
+
+ worker.WorkerExit()
+}
diff --git a/pw_test_server/unit_test.proto b/pw_test_server/unit_test.proto
new file mode 100644
index 0000000..2535823
--- /dev/null
+++ b/pw_test_server/unit_test.proto
@@ -0,0 +1,52 @@
+// Copyright 2019 The Pigweed Authors
+//
+// 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
+//
+// https://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.
+syntax = "proto3";
+
+package pw.test_server;
+
+service TestServer {
+ // Queues a single unit test, blocking until it has run.
+ rpc RunUnitTest(UnitTestDescriptor) returns (UnitTestRunStatus) {}
+
+ // Returns information about the server.
+ rpc Status(Empty) returns (ServerStatus) {}
+}
+
+message Empty {}
+
+enum TestStatus {
+ PENDING = 0;
+ SUCCESS = 1;
+ FAILURE = 2;
+ SKIPPED = 3;
+}
+
+message UnitTestDescriptor {
+ // Local file path to the unit test binary.
+ string file_path = 1;
+}
+
+message UnitTestRunStatus {
+ TestStatus result = 1;
+ uint64 queue_time_ns = 2;
+ uint64 run_time_ns = 3;
+ bytes output = 4;
+}
+
+message ServerStatus {
+ uint64 uptime_ns = 1;
+ uint32 tests_queued = 2;
+ uint32 tests_passed = 3;
+ uint32 tests_failed = 4;
+}