mirror of
https://github.com/adnanh/webhook.git
synced 2025-05-15 10:04:44 +00:00
Merge pull request #47 from moorereason/hookecho
Add environment arguments and improve testing
This commit is contained in:
commit
2026328c56
7 changed files with 167 additions and 32 deletions
|
@ -11,7 +11,8 @@ If you use Slack, you can set up an "Outgoing webhook integration" to run variou
|
||||||
1. receive the request,
|
1. receive the request,
|
||||||
2. parse the headers, payload and query variables,
|
2. parse the headers, payload and query variables,
|
||||||
3. check if the specified rules for the hook are satisfied,
|
3. check if the specified rules for the hook are satisfied,
|
||||||
3. and finally, pass the specified arguments to the specified command.
|
3. and finally, pass the specified arguments to the specified command via
|
||||||
|
command line arguments or via environment variables.
|
||||||
|
|
||||||
Everything else is the responsibility of the command's author.
|
Everything else is the responsibility of the command's author.
|
||||||
|
|
||||||
|
|
27
hook/hook.go
27
hook/hook.go
|
@ -25,6 +25,12 @@ const (
|
||||||
SourceEntireHeaders string = "entire-headers"
|
SourceEntireHeaders string = "entire-headers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// EnvNamespace is the prefix used for passing arguments into the command
|
||||||
|
// environment.
|
||||||
|
EnvNamespace string = "HOOK_"
|
||||||
|
)
|
||||||
|
|
||||||
// ErrInvalidPayloadSignature describes an invalid payload signature.
|
// ErrInvalidPayloadSignature describes an invalid payload signature.
|
||||||
var ErrInvalidPayloadSignature = errors.New("invalid payload signature")
|
var ErrInvalidPayloadSignature = errors.New("invalid payload signature")
|
||||||
|
|
||||||
|
@ -239,6 +245,7 @@ type Hook struct {
|
||||||
CommandWorkingDirectory string `json:"command-working-directory"`
|
CommandWorkingDirectory string `json:"command-working-directory"`
|
||||||
ResponseMessage string `json:"response-message"`
|
ResponseMessage string `json:"response-message"`
|
||||||
CaptureCommandOutput bool `json:"include-command-output-in-response"`
|
CaptureCommandOutput bool `json:"include-command-output-in-response"`
|
||||||
|
PassEnvironmentToCommand []Argument `json:"pass-environment-to-command"`
|
||||||
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command"`
|
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command"`
|
||||||
JSONStringParameters []Argument `json:"parse-parameters-as-json"`
|
JSONStringParameters []Argument `json:"parse-parameters-as-json"`
|
||||||
TriggerRule *Rules `json:"trigger-rule"`
|
TriggerRule *Rules `json:"trigger-rule"`
|
||||||
|
@ -295,7 +302,6 @@ func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]inter
|
||||||
if arg, ok := h.PassArgumentsToCommand[i].Get(headers, query, payload); ok {
|
if arg, ok := h.PassArgumentsToCommand[i].Get(headers, query, payload); ok {
|
||||||
args = append(args, arg)
|
args = append(args, arg)
|
||||||
} else {
|
} else {
|
||||||
args = append(args, "")
|
|
||||||
return args, &ArgumentError{h.PassArgumentsToCommand[i]}
|
return args, &ArgumentError{h.PassArgumentsToCommand[i]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -303,6 +309,23 @@ func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]inter
|
||||||
return args, nil
|
return args, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtractCommandArgumentsForEnv creates a list of arguments in key=value
|
||||||
|
// format, based on the PassEnvironmentToCommand property that is ready to be used
|
||||||
|
// with exec.Command().
|
||||||
|
func (h *Hook) ExtractCommandArgumentsForEnv(headers, query, payload *map[string]interface{}) ([]string, error) {
|
||||||
|
var args = make([]string, 0)
|
||||||
|
|
||||||
|
for i := range h.PassEnvironmentToCommand {
|
||||||
|
if arg, ok := h.PassEnvironmentToCommand[i].Get(headers, query, payload); ok {
|
||||||
|
args = append(args, EnvNamespace+h.PassEnvironmentToCommand[i].Name+"="+arg)
|
||||||
|
} else {
|
||||||
|
return args, &ArgumentError{h.PassEnvironmentToCommand[i]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Hooks is an array of Hook objects
|
// Hooks is an array of Hook objects
|
||||||
type Hooks []Hook
|
type Hooks []Hook
|
||||||
|
|
||||||
|
@ -459,7 +482,7 @@ func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, bod
|
||||||
return err == nil, err
|
return err == nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, &ArgumentError{r.Parameter}
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommandStatusResponse type encapsulates the executed command exit code, message, stdout and stderr
|
// CommandStatusResponse type encapsulates the executed command exit code, message, stdout and stderr
|
||||||
|
|
|
@ -123,7 +123,7 @@ var hookExtractCommandArgumentsTests = []struct {
|
||||||
}{
|
}{
|
||||||
{"test", []Argument{Argument{"header", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"test", "z"}, true},
|
{"test", []Argument{Argument{"header", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"test", "z"}, true},
|
||||||
// failures
|
// failures
|
||||||
{"fail", []Argument{Argument{"payload", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"fail", ""}, false},
|
{"fail", []Argument{Argument{"payload", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"fail"}, false},
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHookExtractCommandArguments(t *testing.T) {
|
func TestHookExtractCommandArguments(t *testing.T) {
|
||||||
|
@ -188,8 +188,8 @@ var matchRuleTests = []struct {
|
||||||
// failures
|
// failures
|
||||||
{"value", "", "", "X", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, false},
|
{"value", "", "", "X", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, false},
|
||||||
{"regex", "^X", "", "", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, false},
|
{"regex", "^X", "", "", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, false},
|
||||||
|
{"value", "", "2", "X", Argument{"header", "a"}, &map[string]interface{}{"y": "z"}, nil, nil, []byte{}, false, false}, // reference invalid header
|
||||||
// errors
|
// errors
|
||||||
{"value", "", "2", "X", Argument{"header", "a"}, &map[string]interface{}{"y": "z"}, nil, nil, []byte{}, false, true}, // reference invalid header
|
|
||||||
{"regex", "*", "", "", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, true}, // invalid regex
|
{"regex", "*", "", "", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, true}, // invalid regex
|
||||||
{"payload-hash-sha1", "", "secret", "", Argument{"header", "a"}, &map[string]interface{}{"a": ""}, nil, nil, []byte{}, false, true}, // invalid hmac
|
{"payload-hash-sha1", "", "secret", "", Argument{"header", "a"}, &map[string]interface{}{"a": ""}, nil, nil, []byte{}, false, true}, // invalid hmac
|
||||||
}
|
}
|
||||||
|
@ -262,7 +262,7 @@ var andRuleTests = []struct {
|
||||||
"invalid rule",
|
"invalid rule",
|
||||||
AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a"}}}},
|
AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a"}}}},
|
||||||
&map[string]interface{}{"y": "z"}, nil, nil, nil,
|
&map[string]interface{}{"y": "z"}, nil, nil, nil,
|
||||||
false, true,
|
false, false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -317,7 +317,7 @@ var orRuleTests = []struct {
|
||||||
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}},
|
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}},
|
||||||
},
|
},
|
||||||
&map[string]interface{}{"y": "Z"}, nil, nil, []byte{},
|
&map[string]interface{}{"y": "Z"}, nil, nil, []byte{},
|
||||||
false, true,
|
false, false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
26
test/hookecho.go
Normal file
26
test/hookecho.go
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
// Hook Echo is a simply utility used for testing the Webhook package.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
fmt.Printf("arg: %s\n", strings.Join(os.Args[1:], " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
var env []string
|
||||||
|
for _, v := range os.Environ() {
|
||||||
|
if strings.HasPrefix(v, "HOOK_") {
|
||||||
|
env = append(env, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(env) > 0 {
|
||||||
|
fmt.Printf("env: %s\n", strings.Join(env, " "))
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,16 @@
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "github",
|
"id": "github",
|
||||||
"execute-command": "/bin/echo",
|
"execute-command": "{{ .Hookecho }}",
|
||||||
"command-working-directory": "/",
|
"command-working-directory": "/",
|
||||||
"include-command-output-in-response": true,
|
"include-command-output-in-response": true,
|
||||||
|
"pass-environment-to-command":
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"source": "payload",
|
||||||
|
"name": "pusher.email"
|
||||||
|
}
|
||||||
|
],
|
||||||
"pass-arguments-to-command":
|
"pass-arguments-to-command":
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
@ -52,7 +59,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "bitbucket",
|
"id": "bitbucket",
|
||||||
"execute-command": "/bin/echo",
|
"execute-command": "{{ .Hookecho }}",
|
||||||
"command-working-directory": "/",
|
"command-working-directory": "/",
|
||||||
"include-command-output-in-response": true,
|
"include-command-output-in-response": true,
|
||||||
"response-message": "success",
|
"response-message": "success",
|
||||||
|
@ -99,7 +106,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "gitlab",
|
"id": "gitlab",
|
||||||
"execute-command": "/bin/echo",
|
"execute-command": "{{ .Hookecho }}",
|
||||||
"command-working-directory": "/",
|
"command-working-directory": "/",
|
||||||
"response-message": "success",
|
"response-message": "success",
|
||||||
"include-command-output-in-response": true,
|
"include-command-output-in-response": true,
|
||||||
|
@ -133,3 +140,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -234,13 +234,20 @@ func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, b
|
||||||
|
|
||||||
cmd := exec.Command(h.ExecuteCommand)
|
cmd := exec.Command(h.ExecuteCommand)
|
||||||
cmd.Dir = h.CommandWorkingDirectory
|
cmd.Dir = h.CommandWorkingDirectory
|
||||||
|
|
||||||
cmd.Args, err = h.ExtractCommandArguments(headers, query, payload)
|
cmd.Args, err = h.ExtractCommandArguments(headers, query, payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("error extracting command arguments: %s", err)
|
log.Printf("error extracting command arguments: %s", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("executing %s (%s) with arguments %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, cmd.Dir)
|
cmd.Env, err = h.ExtractCommandArgumentsForEnv(headers, query, payload)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error extracting command arguments: %s", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("executing %s (%s) with arguments %s and environment %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, cmd.Env, cmd.Dir)
|
||||||
|
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
// +build !windows
|
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
@ -13,18 +11,28 @@ import (
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/adnanh/webhook/hook"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWebhook(t *testing.T) {
|
func TestWebhook(t *testing.T) {
|
||||||
bin, cleanup := buildWebhook(t)
|
hookecho, cleanupHookecho := buildHookecho(t)
|
||||||
defer cleanup()
|
defer cleanupHookecho()
|
||||||
|
|
||||||
|
config, cleanupConfig := genConfig(t, hookecho)
|
||||||
|
defer cleanupConfig()
|
||||||
|
|
||||||
|
webhook, cleanupWebhook := buildWebhook(t)
|
||||||
|
defer cleanupWebhook()
|
||||||
|
|
||||||
ip, port := serverAddress(t)
|
ip, port := serverAddress(t)
|
||||||
args := []string{"-hooks=hooks_test.json", fmt.Sprintf("-ip=%s", ip), fmt.Sprintf("-port=%s", port), "-verbose"}
|
args := []string{fmt.Sprintf("-hooks=%s", config), fmt.Sprintf("-ip=%s", ip), fmt.Sprintf("-port=%s", port), "-verbose"}
|
||||||
|
|
||||||
cmd := exec.Command(bin, args...)
|
cmd := exec.Command(webhook, args...)
|
||||||
//cmd.Stderr = os.Stderr // uncomment to see verbose output
|
//cmd.Stderr = os.Stderr // uncomment to see verbose output
|
||||||
|
cmd.Env = webhookEnv()
|
||||||
cmd.Args[0] = "webhook"
|
cmd.Args[0] = "webhook"
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
t.Fatalf("failed to start webhook: %s", err)
|
t.Fatalf("failed to start webhook: %s", err)
|
||||||
|
@ -41,11 +49,9 @@ func TestWebhook(t *testing.T) {
|
||||||
t.Errorf("New request failed: %s", err)
|
t.Errorf("New request failed: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.headers != nil {
|
|
||||||
for k, v := range tt.headers {
|
for k, v := range tt.headers {
|
||||||
req.Header.Add(k, v)
|
req.Header.Add(k, v)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var res *http.Response
|
var res *http.Response
|
||||||
|
|
||||||
|
@ -73,6 +79,58 @@ func TestWebhook(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildHookecho(t *testing.T) (bin string, cleanup func()) {
|
||||||
|
tmp, err := ioutil.TempDir("", "hookecho-test-")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cleanup == nil {
|
||||||
|
os.RemoveAll(tmp)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bin = filepath.Join(tmp, "hookecho")
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
bin += ".exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("go", "build", "-o", bin, "test/hookecho.go")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("Building hookecho: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bin, func() { os.RemoveAll(tmp) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func genConfig(t *testing.T, bin string) (config string, cleanup func()) {
|
||||||
|
tmpl := template.Must(template.ParseFiles("test/hooks.json.tmpl"))
|
||||||
|
|
||||||
|
tmp, err := ioutil.TempDir("", "webhook-config-")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cleanup == nil {
|
||||||
|
os.RemoveAll(tmp)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
path := filepath.Join(tmp, "hooks.json")
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Creating config template: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data := struct{ Hookecho string }{filepath.ToSlash(bin)}
|
||||||
|
if err := tmpl.Execute(file, data); err != nil {
|
||||||
|
t.Fatalf("Executing template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, func() { os.RemoveAll(tmp) }
|
||||||
|
}
|
||||||
|
|
||||||
func buildWebhook(t *testing.T) (bin string, cleanup func()) {
|
func buildWebhook(t *testing.T) (bin string, cleanup func()) {
|
||||||
tmp, err := ioutil.TempDir("", "webhook-test-")
|
tmp, err := ioutil.TempDir("", "webhook-test-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -142,6 +200,18 @@ func killAndWait(cmd *exec.Cmd) {
|
||||||
cmd.Wait()
|
cmd.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// webhookEnv returns the process environment without any existing hook
|
||||||
|
// namespace variables.
|
||||||
|
func webhookEnv() (env []string) {
|
||||||
|
for _, v := range os.Environ() {
|
||||||
|
if strings.HasPrefix(v, hook.EnvNamespace) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
env = append(env, v)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var hookHandlerTests = []struct {
|
var hookHandlerTests = []struct {
|
||||||
desc string
|
desc string
|
||||||
id string
|
id string
|
||||||
|
@ -302,7 +372,7 @@ var hookHandlerTests = []struct {
|
||||||
}`,
|
}`,
|
||||||
false,
|
false,
|
||||||
http.StatusOK,
|
http.StatusOK,
|
||||||
`{"message":"","output":"1481a2de7b2a7d02428ad93446ab166be7793fbb Garen Torikian lolwut@noway.biz\n","error":""}`,
|
`{"output":"arg: 1481a2de7b2a7d02428ad93446ab166be7793fbb Garen Torikian lolwut@noway.biz\nenv: HOOK_pusher.email=lolwut@noway.biz\n"}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"bitbucket", // bitbucket sends their payload using uriencoded params.
|
"bitbucket", // bitbucket sends their payload using uriencoded params.
|
||||||
|
@ -311,7 +381,7 @@ var hookHandlerTests = []struct {
|
||||||
`payload={"canon_url": "https://bitbucket.org","commits": [{"author": "marcus","branch": "master","files": [{"file": "somefile.py","type": "modified"}],"message": "Added some more things to somefile.py\n","node": "620ade18607a","parents": ["702c70160afc"],"raw_author": "Marcus Bertrand <marcus@somedomain.com>","raw_node": "620ade18607ac42d872b568bb92acaa9a28620e9","revision": null,"size": -1,"timestamp": "2012-05-30 05:58:56","utctimestamp": "2014-11-07 15:19:02+00:00"}],"repository": {"absolute_url": "/webhook/testing/","fork": false,"is_private": true,"name": "Project X","owner": "marcus","scm": "git","slug": "project-x","website": "https://atlassian.com/"},"user": "marcus"}`,
|
`payload={"canon_url": "https://bitbucket.org","commits": [{"author": "marcus","branch": "master","files": [{"file": "somefile.py","type": "modified"}],"message": "Added some more things to somefile.py\n","node": "620ade18607a","parents": ["702c70160afc"],"raw_author": "Marcus Bertrand <marcus@somedomain.com>","raw_node": "620ade18607ac42d872b568bb92acaa9a28620e9","revision": null,"size": -1,"timestamp": "2012-05-30 05:58:56","utctimestamp": "2014-11-07 15:19:02+00:00"}],"repository": {"absolute_url": "/webhook/testing/","fork": false,"is_private": true,"name": "Project X","owner": "marcus","scm": "git","slug": "project-x","website": "https://atlassian.com/"},"user": "marcus"}`,
|
||||||
true,
|
true,
|
||||||
http.StatusOK,
|
http.StatusOK,
|
||||||
`{"message":"success","output":"\n","error":""}`,
|
`{"message":"success"}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"gitlab",
|
"gitlab",
|
||||||
|
@ -361,7 +431,7 @@ var hookHandlerTests = []struct {
|
||||||
}`,
|
}`,
|
||||||
false,
|
false,
|
||||||
http.StatusOK,
|
http.StatusOK,
|
||||||
`{"message":"success","output":"b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327 John Smith john@example.com\n","error":""}`,
|
`{"message":"success","output":"arg: b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327 John Smith john@example.com\n"}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
{"empty payload", "github", nil, `{}`, false, http.StatusOK, `Hook rules were not satisfied.`},
|
{"empty payload", "github", nil, `{}`, false, http.StatusOK, `Hook rules were not satisfied.`},
|
||||||
|
|
Loading…
Add table
Reference in a new issue