Skip to content

Commit

Permalink
plugins: initial support of parallel execution (#58)
Browse files Browse the repository at this point in the history
* add support for plugins, export some internal code, add plugin sample

* fix docs in exported code

* modify plugin runner to execute plugins independently in parallel

* clean up usage doc whitespace

* re-organize code, update release script, add better error message handling from plugins

* update circle config

* fix gitignore and revert circle config changes

* pull plugin runner into own file, modify sample, add plugin test

* revert test proto file change

* add plugin samples and use in circle tests

* update circle config to add debug detail

* prefer explicit return from goroutine and send on done chan

* override pipefail in plugin ci tests

* update README with --plugins option in usage

* revert uppercasing in json tag for Protolock type, add other json tags

* use different plugin name for error sample

* add json tags to Data and include example nodejs plugin

* run ci test on nodejs plugin

* update README with details and wiki link to plugin docs

* remove console log demo and add note in nodejs plugin example
  • Loading branch information
nilslice authored Nov 5, 2018
1 parent 84b054f commit d95c8fe
Show file tree
Hide file tree
Showing 20 changed files with 3,435 additions and 107 deletions.
65 changes: 64 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ version: 2
jobs:
build:
docker:
- image: circleci/golang:1.10
- image: circleci/golang:1.11
environment:
GO111MODULE: "on"

working_directory: /go/src/github.com/nilslice/protolock
steps:
Expand All @@ -18,3 +20,64 @@ jobs:
- run: cat proto.lock | grep "testdata:/:test.proto"
- run: protolock status
- run: protolock commit
- run: protolock status --plugins=_not-a-plugin_ | grep "executable file not found"
- run:
name: check output using plugin-sample-error
command: |
set +o pipefail
protolock status --plugins=plugin-sample-error | grep "some error"
- run:
name: check output using plugin-sample
command: |
set +o pipefail
WARNINGS=$(protolock status --plugins=plugin-sample | wc -l)
if [ "$WARNINGS" != 2 ]; then
exit 1
fi
- run:
name: check output using multiple plugins, with one error expected
command: |
set +o pipefail
protolock status --plugins=plugin-sample,plugin-sample-error | grep "some error"
protolock status --plugins=plugin-sample-error,plugin-sample | grep "some error"
- run:
name: check output using multiple plugins with errors
command: |
set +o pipefail
ERRS=$(protolock status --plugins=plugin-sample-error,plugin-sample-error | grep "some error" | wc -l)
if [ "$ERRS" != 2 ]; then
exit 1
fi
MOREERRS=$(protolock status --plugins=plugin-sample-error,plugin-sample-error,plugin-sample-error | grep "some error" | wc -l)
if [ "$MOREERRS" != 3 ]; then
exit 1
fi
plugin_nodejs:
docker:
- image: circleci/node:8

steps:
- checkout
# fetch depenencies, test code
- run: npm i --prefix plugin-samples/plugin-sample-js/
- run: npm run pack --prefix plugin-samples/plugin-sample-js/
- run:
name:
command: |
set +o pipefail
cd plugin-samples/plugin-sample-js/
WARNINGS=$(cat example.data.json | ./plugin-sample-js)
echo $WARNINGS | grep '{"filepath":"path/to/file.proto","message":"Something bad happened."}'
echo $WARNINGS | grep '{"filepath":"path/to/another.proto","message":"Something else bad happened."}'
workflows:
version: 2
build-cmd-test-plugins:
jobs:
- build
- plugin_nodejs
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
protolock
pkg
./protolock
pkg
node_modules
plugin-samples/plugin-sample-js/etc
plugin-samples/plugin-sample-js/plugin-sample-js
65 changes: 36 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,32 @@ Ever _accidentally_ break your API compatibility while you're busy fixing proble

`protolock` attempts to help prevent this from happening.

## Overview

1. **Initialize** your repository:

$ protolock init
# creates a `proto.lock` file

3. **Add changes** to .proto messages or services, verify no breaking changes made:

$ protolock status
CONFLICT: "Channel" is missing ID: 108, which had been reserved [path/to/file.proto]
CONFLICT: "Channel" is missing ID: 109, which had been reserved [path/to/file.proto]

2. **Commit** a new state of your .protos (rewrites `proto.lock` if no warnings):

$ protolock commit
# optionally provide --force flag to disregard warnings

4. **Integrate** into your protobuf compilation step:

$ protolock status && protoc -I ...

In all, prevent yourself from compiling your protobufs and generating code if breaking changes have been made.

**Recommended:** commit the output `proto.lock` file into your version control system

## Install
If you have [Go](https://golang.org) installed, you can install `protolock` by
running:
Expand All @@ -35,33 +61,14 @@ Options:
--debug [false] enable debug mode and output debug messages
--ignore comma-separated list of filepaths to ignore
--force [false] forces commit to rewrite proto.lock file and disregards warnings
--plugins comma-separated list of executable protolock plugin names
```

## Overview

1. **Initialize** your repository:

$ protolock init
# creates a `proto.lock` file

3. **Add changes** to .proto messages or services, verify no breaking changes made:

$ protolock status
CONFLICT: "Channel" is missing ID: 108, which had been reserved [path/to/file.proto]
CONFLICT: "Channel" is missing ID: 109, which had been reserved [path/to/file.proto]

2. **Commit** a new state of your .protos (rewrites `proto.lock` if no warnings):

$ protolock commit
# optionally provide --force flag to disregard warnings

4. **Integrate** into your protobuf compilation step:

$ protolock status && protoc -I ...

In all, prevent yourself from compiling your protobufs and generating code if breaking changes have been made.
## Related Projects & Users

**Recommended:** commit the output `proto.lock` file into your version control system
- [Fanatics](https://github.com/fanatics)
- [Maven Plugin](https://github.com/salesforce/proto-backwards-compat-maven-plugin) by [Salesforce](https://github.com/salesforce)
- [Istio](https://github.com/istio/api)

## Rules Enforced

Expand Down Expand Up @@ -110,11 +117,11 @@ warnings if any RPC signature has been changed while using the same name.

---

## Related Projects & Users

- [Fanatics](https://github.com/fanatics)
- [Maven Plugin](https://github.com/salesforce/proto-backwards-compat-maven-plugin) by [Salesforce](https://github.com/salesforce)
- [Istio](https://github.com/istio/api)
## Plugins
The default rules enforced by `protolock` may not cover everything you want to
do. If you have custom checks you'd like run on your .proto files, create a
plugin, and have `protolock` run it and report your warnings. Read the wiki to
learn more about [creating and using plugins](https://github.com/nilslice/protolock/wiki/Plugins).

---

Expand Down
26 changes: 22 additions & 4 deletions cmd/protolock/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Options:
--debug [false] enable debug mode and output debug messages
--ignore comma-separated list of filepaths to ignore
--force [false] forces commit to rewrite proto.lock file and disregards warnings
--plugins comma-separated list of executable protolock plugin names
`

var (
Expand All @@ -38,6 +39,7 @@ var (
strict = options.Bool("strict", true, "enable strict mode and enforce all built-in rules")
ignore = options.String("ignore", "", "comma-separated list of filepaths to ignore")
force = options.Bool("force", false, "force commit to rewrite proto.lock file and disregard warnings")
plugins = options.String("plugins", "", "comma-separated list of executable protolock plugin names")
)

func main() {
Expand Down Expand Up @@ -98,16 +100,30 @@ func main() {

case "status":
report, err := protolock.Status(*ignore)
if err != nil {
handleReport(report, err)
if err != protolock.ErrWarningsFound && err != nil {
fmt.Println("[protolock]:", err)
os.Exit(1)
}

// if plugins are provided, attempt to execute each as a exeutable
// located in the user's OS executable path as reported by stdlib's
// exec.LookPath func
if *plugins != "" {
report, err = runPlugins(*plugins, report)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}

handleReport(report, err)

default:
os.Exit(0)
}
}

func handleReport(report protolock.Report, err error) {
func handleReport(report *protolock.Report, err error) {
if len(report.Warnings) > 0 {
for _, w := range report.Warnings {
fmt.Fprintf(
Expand All @@ -119,7 +135,9 @@ func handleReport(report protolock.Report, err error) {
os.Exit(1)
}

fmt.Println(err)
if err != nil {
fmt.Println(err)
}
}

func saveToLockFile(r io.Reader) error {
Expand Down
130 changes: 130 additions & 0 deletions cmd/protolock/plugins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package main

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"sync"

"github.com/nilslice/protolock"
"github.com/nilslice/protolock/extend"
)

func runPlugins(pluginList string, report *protolock.Report) (*protolock.Report, error) {
inputData := &bytes.Buffer{}

err := json.NewEncoder(inputData).Encode(&extend.Data{
Current: report.Current,
Updated: report.Updated,
PluginWarnings: []protolock.Warning{},
})
if err != nil {
return nil, err
}

// collect plugin warnings and errors as they are returned from plugins
pluginWarningsChan := make(chan []protolock.Warning)
pluginsDone := make(chan struct{})
pluginErrsChan := make(chan error)
var allPluginErrors []error
go func() {
for {
select {
case <-pluginsDone:
return

case err := <-pluginErrsChan:
if err != nil {
allPluginErrors = append(allPluginErrors, err)
}

case warnings := <-pluginWarningsChan:
for _, warning := range warnings {
report.Warnings = append(report.Warnings, warning)
}
}
}
}()

wg := &sync.WaitGroup{}
plugins := strings.Split(pluginList, ",")
for _, name := range plugins {
wg.Add(1)

// copy input data to be passed in to and processed by each plugin
pluginInputData := bytes.NewReader(inputData.Bytes())

// run all provided plugins in parallel, each recieving the same current
// and updated Protolock structs from the `protolock status` call
go func(name string) {
defer wg.Done()
name = strings.TrimSpace(name)
path, err := exec.LookPath(name)
if err != nil {
if path == "" {
path = name
}
fmt.Println("[protolock] plugin exec error:", err)
return
}

// initialize the executable to be called from protolock using the
// absolute path and copy of the input data
plugin := &exec.Cmd{
Path: path,
Stdin: pluginInputData,
}

// execute the plugin and capture the output
output, err := plugin.Output()
if err != nil {
pluginErrsChan <- wrapPluginErr(name, path, err)
return
}

pluginData := &extend.Data{}
err = json.Unmarshal(output, pluginData)
if err != nil {
fmt.Println("[protolock] plugin data decode error:", err)
return
}

// gather all warnings from each plugin, and send to warning chan
// collector as a slice to keep together
if pluginData.PluginWarnings != nil {
pluginWarningsChan <- pluginData.PluginWarnings
}

if pluginData.PluginErrorMessage != "" {
pluginErrsChan <- wrapPluginErr(
name, path, errors.New(pluginData.PluginErrorMessage),
)
}
}(name)
}

wg.Wait()
pluginsDone <- struct{}{}

if allPluginErrors != nil {
var errorMsgs []string
for _, pluginError := range allPluginErrors {
errorMsgs = append(errorMsgs, pluginError.Error())
}

return nil, fmt.Errorf(
`[protolock:plugin] accumulated plugin errors:
%s`,
strings.Join(errorMsgs, "\n"),
)
}

return report, nil
}

func wrapPluginErr(name, path string, err error) error {
return fmt.Errorf("%s: %v (%s)", name, err, path)
}
Loading

0 comments on commit d95c8fe

Please sign in to comment.