Skip to content

Commit

Permalink
feat(executor/grpc): Add GRPC executor (#187) (#188)
Browse files Browse the repository at this point in the history
Add GRPC executor which uses grpcurl to do requests to grpc endpoints.

Fixes: #187 
Signed-off-by: Matous Dzivjak <matous.dzivjak@kiwi.com>
  • Loading branch information
Matouš Dzivjak authored and yesnault committed May 3, 2019
1 parent 0275a77 commit ed8400d
Show file tree
Hide file tree
Showing 7 changed files with 334 additions and 3 deletions.
4 changes: 4 additions & 0 deletions Gopkg.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,7 @@
[[constraint]]
name = "gopkg.in/gorp.v1"
version = "1.7.1"

[[constraint]]
name = "github.com/fullstorydev/grpcurl"
version = "1.2.1"
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Flags:
* **smtp**: https://github.com/ovh/venom/tree/master/executors/smtp
* **ssh**: https://github.com/ovh/venom/tree/master/executors/ssh
* **web**: https://github.com/ovh/venom/tree/master/executors/web
* **grpc**: https://github.com/ovh/venom/tree/master/executors/grpc

## TestSuite files

Expand Down
4 changes: 3 additions & 1 deletion cli/venom/run/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
"github.com/hashicorp/hcl"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
yaml "gopkg.in/yaml.v2"
"gopkg.in/yaml.v2"

"github.com/ovh/venom"
"github.com/ovh/venom/context/default"
Expand All @@ -22,6 +22,7 @@ import (

"github.com/ovh/venom/executors/dbfixtures"
"github.com/ovh/venom/executors/exec"
"github.com/ovh/venom/executors/grpc"
"github.com/ovh/venom/executors/http"
"github.com/ovh/venom/executors/imap"
"github.com/ovh/venom/executors/kafka"
Expand Down Expand Up @@ -88,6 +89,7 @@ var Cmd = &cobra.Command{
v.RegisterExecutor(dbfixtures.Name, dbfixtures.New())
v.RegisterExecutor(redis.Name, redis.New())
v.RegisterExecutor(kafka.Name, kafka.New())
v.RegisterExecutor(grpc.Name, grpc.New())

// Register Context
v.RegisterTestCaseContext(defaultctx.Name, defaultctx.New())
Expand Down
83 changes: 83 additions & 0 deletions executors/grpc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Venom - Executor Grpc

Step for execute GRPC Request

Based on `grpcurl`, see [grpcurl](https://github.com/fullstorydev/grpcurl) for more information.
This executor relies on the gRPC server reflection, which should be enabled on the server as described
[here](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md).
gRPC server reflection is not enabled by default and not implemented for every gRPC library,
make sure your library of choice supports reflection before implementing tests using this executor.
gRPC server reflection also does not properly work with `gogo/protobuf`: grpc/grpc-go#1873

## Tests

Results of test are parsed as json and saved in `bodyjson`. Status codes correspond
to the official status codes of gRPC.
You can find what individual return codes mean [here](https://github.com/grpc/grpc/blob/master/doc/statuscodes.md).

## Input

In your yaml file, you can use:

```yaml
- url mandatory
- service mandatory: service to call
- method mandatory: list, describe, or method of the endpoint
- plaintext optional: use plaintext protocol instead of TLS
- data optional: data to marshal to json and send as a request
- headers optional: data to send as additional headers
- connect_timeout optional: The maximum time, in seconds, to wait for connection to be established. Defaults to 10 seconds.
```
Example:
```yaml

name: Title of TestSuite
testcases:

- name: request GRPC
steps:
- type: grpc
url: serverUrlWithoutHttp:8090
plaintext: true # skip TLS
data:
foo: bar
service: coolService.api
method: GetAllFoos
assertions:
- result.code ShouldEqual 0
- result.bodyjson.foo ShouldEqual bar

```

Multiline script:

```yaml
name: Title of TestSuite
testcases:
- name: multiline script
steps:
- script: |
echo "Foo" \
echo "Bar"
```
## Output
```yaml
executor
systemout
systemerr
err
code
timeseconds
timehuman
```

- result.timeseconds & result.timehuman: time of execution
- result.executor.executor.script: script executed
- result.err: if exists, this field contains error
- result.systemout: Standard Output of executed script
- result.systemerr: Error Output of executed script
- result.code: Exit Code
228 changes: 228 additions & 0 deletions executors/grpc/executor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package grpc

import (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"time"

"github.com/fullstorydev/grpcurl"
"github.com/golang/protobuf/proto"
"github.com/jhump/protoreflect/desc"
"github.com/jhump/protoreflect/grpcreflect"
"github.com/mitchellh/mapstructure"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
reflectpb "google.golang.org/grpc/reflection/grpc_reflection_v1alpha"
"google.golang.org/grpc/status"

"github.com/ovh/venom"
"github.com/ovh/venom/executors"
)

// Name for test exec
const Name = "grpc"

// New returns a new Test Exec
func New() venom.Executor {
return &Executor{}
}

// Executor represents a Test Exec
type Executor struct {
Url string `json:"url" yaml:"url"`
Service string `json:"service" yaml:"service"`
Method string `json:"method" yaml:"method"`
Plaintext bool `json:"plaintext,omitempty" yaml:"plaintext,omitempty"`
JsonDefaultFields bool `json:"default_field" yaml:"default_field"`
IncludeTextSeparator bool `json:"include_test_separator" yaml:"include_test_separator"`
Data map[string]interface{} `json:"data" yaml:"data"`
Headers map[string]string `json:"headers" yaml:"headers"`
ConnectTimeout *int64 `json:"connect_timeout" yaml:"connect_timeout"`
}

// Result represents a step result
type Result struct {
Executor Executor `json:"executor,omitempty" yaml:"executor,omitempty"`
Systemout string `json:"systemout,omitempty" yaml:"systemout,omitempty"`
SystemoutJSON interface{} `json:"systemoutjson,omitempty" yaml:"systemoutjson,omitempty"`
Systemerr string `json:"systemerr,omitempty" yaml:"systemerr,omitempty"`
SystemerrJSON interface{} `json:"systemerrjson,omitempty" yaml:"systemerrjson,omitempty"`
Err string `json:"err,omitempty" yaml:"err,omitempty"`
Code string `json:"code,omitempty" yaml:"code,omitempty"`
TimeSeconds float64 `json:"timeseconds,omitempty" yaml:"timeseconds,omitempty"`
TimeHuman string `json:"timehuman,omitempty" yaml:"timehuman,omitempty"`
}

type customHandler struct {
formatter grpcurl.Formatter
target *Result
err error
}

// OnResolveMethod is called with a descriptor of the method that is being invoked.
func (*customHandler) OnResolveMethod(m *desc.MethodDescriptor) {}

// OnSendHeaders is called with the request metadata that is being sent.
func (*customHandler) OnSendHeaders(metadata.MD) {}

// OnReceiveHeaders is called when response headers have been received.
func (*customHandler) OnReceiveHeaders(m metadata.MD) {}

// OnReceiveResponse is called for each response message received.
func (c *customHandler) OnReceiveResponse(msg proto.Message) {
res, err := c.formatter(msg)
if err != nil || c.err != nil {
c.err = err
return
}
c.target.Systemout = res
}

// OnReceiveTrailers is called when response trailers and final RPC status have been received.
func (c *customHandler) OnReceiveTrailers(stat *status.Status, met metadata.MD) {
if err := stat.Err(); err != nil {
c.target.Systemerr = err.Error()
}
c.target.Code = strconv.Itoa(int(uint32(stat.Code())))
}

// ZeroValueResult return an empty implemtation of this executor result
func (Executor) ZeroValueResult() venom.ExecutorResult {
r, _ := executors.Dump(Result{})
return r
}

// GetDefaultAssertions return default assertions for type exec
func (Executor) GetDefaultAssertions() *venom.StepAssertions {
return &venom.StepAssertions{Assertions: []string{"result.code ShouldEqual 0"}}
}

// Run execute TestStep of type exec
func (Executor) Run(testCaseContext venom.TestCaseContext, l venom.Logger, step venom.TestStep, workdir string) (venom.ExecutorResult, error) {
// decode test
var e Executor
if err := mapstructure.Decode(step, &e); err != nil {
return nil, err
}

// prepare headers
headers := make([]string, len(e.Headers))
for k, v := range e.Headers {
headers = append(headers, fmt.Sprintf("%s: %s", k, v))
}

// prepare data
data, err := json.Marshal(e.Data)
if err != nil {
return nil, fmt.Errorf("runGrpcurl: Cannot marshal request data: %s\n", err)
}

result := Result{Executor: e}
start := time.Now()

ctx := context.Background()

// prepare dial function
dial := func() *grpc.ClientConn {
dialTime := 10 * time.Second
if e.ConnectTimeout != nil && *e.ConnectTimeout > 0 {
dialTime = time.Duration(*e.ConnectTimeout * int64(time.Second))
}
ctx, cancel := context.WithTimeout(ctx, dialTime)
defer cancel()
var creds credentials.TransportCredentials
cc, err := grpcurl.BlockingDial(ctx, "tcp", e.Url, creds)
if err != nil {
return nil
}
return cc
}

var cc *grpc.ClientConn
var descSource grpcurl.DescriptorSource
var refClient *grpcreflect.Client
md := grpcurl.MetadataFromHeaders(headers)
refCtx := metadata.NewOutgoingContext(ctx, md)
cc = dial()
refClient = grpcreflect.NewClient(refCtx, reflectpb.NewServerReflectionClient(cc))
descSource = grpcurl.DescriptorSourceFromServer(ctx, refClient)

// arrange for the RPCs to be cleanly shutdown
defer func() {
if refClient != nil {
refClient.Reset()
refClient = nil
}
if cc != nil {
_ = cc.Close()
cc = nil
}
}()

// Invoke an RPC
if cc == nil {
cc = dial()
}

// prepare request and send
in := bytes.NewReader(data)
rf, formatter, err := grpcurl.RequestParserAndFormatterFor(
grpcurl.FormatJSON,
descSource,
e.JsonDefaultFields,
e.IncludeTextSeparator,
in,
)
if err != nil {
return nil, fmt.Errorf("dailed to construct request parser and formatter %s", err)
}

// prepare custom handler to handle response
handle := customHandler{
formatter,
&result,
nil,
}

// invoke the gRPC
err = grpcurl.InvokeRPC(ctx, descSource, cc, e.Service+"/"+e.Method, append(headers), &handle, rf.Next)
if err != nil {
return nil, fmt.Errorf("error invoking method %s", err)
}

elapsed := time.Since(start)
result.TimeSeconds = elapsed.Seconds()
result.TimeHuman = elapsed.String()

if handle.err != nil {
result.Err = handle.err.Error()
}

// parse stdout as JSON
var outJSONArray []interface{}
if err := json.Unmarshal([]byte(result.Systemout), &outJSONArray); err != nil {
outJSONMap := map[string]interface{}{}
if err2 := json.Unmarshal([]byte(result.Systemout), &outJSONMap); err2 == nil {
result.SystemoutJSON = outJSONMap
}
} else {
result.SystemoutJSON = outJSONArray
}

// parse stderr output as JSON
var errJSONArray []interface{}
if err := json.Unmarshal([]byte(result.Systemout), &errJSONArray); err != nil {
errJSONMap := map[string]interface{}{}
if err2 := json.Unmarshal([]byte(result.Systemout), &errJSONMap); err2 == nil {
result.SystemoutJSON = errJSONMap
}
} else {
result.SystemoutJSON = errJSONArray
}

return executors.Dump(result)
}
5 changes: 3 additions & 2 deletions lib/lib.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package venom

import (
"testing"

"encoding/json"
"testing"

"github.com/ovh/venom"
"github.com/ovh/venom/context/default"
redisctx "github.com/ovh/venom/context/redis"
"github.com/ovh/venom/context/webctx"
"github.com/ovh/venom/executors/grpc"

"github.com/ovh/venom/executors/dbfixtures"
"github.com/ovh/venom/executors/exec"
Expand Down Expand Up @@ -80,6 +80,7 @@ func TestCase(t *testing.T, name string, variables map[string]string) *T {
v.RegisterExecutor(ssh.Name, ssh.New())
v.RegisterExecutor(web.Name, web.New())
v.RegisterExecutor(redis.Name, redis.New())
v.RegisterExecutor(grpc.Name, grpc.New())

v.RegisterTestCaseContext(redisctx.Name, redisctx.New())
v.RegisterTestCaseContext(defaultctx.Name, defaultctx.New())
Expand Down
12 changes: 12 additions & 0 deletions tests/MyTestSuiteGrpc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: TestSuite GRPC
testcases:
- name: GRPC testcase
steps:
- type: grpc
url: "{{.grpcUrl}}"
service: "{{.grpcService}}"
method: "{{.grpcMethod}}"
plaintext: true
assertions:
- result.code ShouldEqual 0
- result.systemoutjson.foo ShouldEqual bar

0 comments on commit ed8400d

Please sign in to comment.