Skip to content

Commit

Permalink
Merge pull request gohugoio#47 from moorereason/hookecho
Browse files Browse the repository at this point in the history
Add environment arguments and improve testing
  • Loading branch information
adnanh committed Nov 2, 2015
2 parents 6774079 + ea3dbf3 commit 2026328
Show file tree
Hide file tree
Showing 7 changed files with 167 additions and 32 deletions.
3 changes: 2 additions & 1 deletion README.md
Expand Up @@ -11,7 +11,8 @@ If you use Slack, you can set up an "Outgoing webhook integration" to run variou
1. receive the request,
2. parse the headers, payload and query variables,
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.

Expand Down
43 changes: 33 additions & 10 deletions hook/hook.go
Expand Up @@ -25,6 +25,12 @@ const (
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.
var ErrInvalidPayloadSignature = errors.New("invalid payload signature")

Expand Down Expand Up @@ -234,14 +240,15 @@ func (ha *Argument) Get(headers, query, payload *map[string]interface{}) (string

// Hook type is a structure containing details for a single hook
type Hook struct {
ID string `json:"id"`
ExecuteCommand string `json:"execute-command"`
CommandWorkingDirectory string `json:"command-working-directory"`
ResponseMessage string `json:"response-message"`
CaptureCommandOutput bool `json:"include-command-output-in-response"`
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command"`
JSONStringParameters []Argument `json:"parse-parameters-as-json"`
TriggerRule *Rules `json:"trigger-rule"`
ID string `json:"id"`
ExecuteCommand string `json:"execute-command"`
CommandWorkingDirectory string `json:"command-working-directory"`
ResponseMessage string `json:"response-message"`
CaptureCommandOutput bool `json:"include-command-output-in-response"`
PassEnvironmentToCommand []Argument `json:"pass-environment-to-command"`
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command"`
JSONStringParameters []Argument `json:"parse-parameters-as-json"`
TriggerRule *Rules `json:"trigger-rule"`
}

// ParseJSONParameters decodes specified arguments to JSON objects and replaces the
Expand Down Expand Up @@ -295,14 +302,30 @@ func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]inter
if arg, ok := h.PassArgumentsToCommand[i].Get(headers, query, payload); ok {
args = append(args, arg)
} else {
args = append(args, "")
return args, &ArgumentError{h.PassArgumentsToCommand[i]}
}
}

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
type Hooks []Hook

Expand Down Expand Up @@ -459,7 +482,7 @@ func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, bod
return err == nil, err
}
}
return false, &ArgumentError{r.Parameter}
return false, nil
}

// CommandStatusResponse type encapsulates the executed command exit code, message, stdout and stderr
Expand Down
8 changes: 4 additions & 4 deletions hook/hook_test.go
Expand Up @@ -123,7 +123,7 @@ var hookExtractCommandArgumentsTests = []struct {
}{
{"test", []Argument{Argument{"header", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"test", "z"}, true},
// 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) {
Expand Down Expand Up @@ -188,8 +188,8 @@ var matchRuleTests = []struct {
// failures
{"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},
{"value", "", "2", "X", Argument{"header", "a"}, &map[string]interface{}{"y": "z"}, nil, nil, []byte{}, false, false}, // reference invalid header
// 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
{"payload-hash-sha1", "", "secret", "", Argument{"header", "a"}, &map[string]interface{}{"a": ""}, nil, nil, []byte{}, false, true}, // invalid hmac
}
Expand Down Expand Up @@ -262,7 +262,7 @@ var andRuleTests = []struct {
"invalid rule",
AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a"}}}},
&map[string]interface{}{"y": "z"}, nil, nil, nil,
false, true,
false, false,
},
}

Expand Down Expand Up @@ -317,7 +317,7 @@ var orRuleTests = []struct {
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}},
},
&map[string]interface{}{"y": "Z"}, nil, nil, []byte{},
false, true,
false, false,
},
}

Expand Down
26 changes: 26 additions & 0 deletions test/hookecho.go
@@ -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, " "))
}
}
14 changes: 11 additions & 3 deletions hooks_test.json → test/hooks.json.tmpl
@@ -1,9 +1,16 @@
[
{
"id": "github",
"execute-command": "/bin/echo",
"execute-command": "{{ .Hookecho }}",
"command-working-directory": "/",
"include-command-output-in-response": true,
"pass-environment-to-command":
[
{
"source": "payload",
"name": "pusher.email"
}
],
"pass-arguments-to-command":
[
{
Expand Down Expand Up @@ -52,7 +59,7 @@
},
{
"id": "bitbucket",
"execute-command": "/bin/echo",
"execute-command": "{{ .Hookecho }}",
"command-working-directory": "/",
"include-command-output-in-response": true,
"response-message": "success",
Expand Down Expand Up @@ -99,7 +106,7 @@
},
{
"id": "gitlab",
"execute-command": "/bin/echo",
"execute-command": "{{ .Hookecho }}",
"command-working-directory": "/",
"response-message": "success",
"include-command-output-in-response": true,
Expand Down Expand Up @@ -133,3 +140,4 @@
}
}
]

9 changes: 8 additions & 1 deletion webhook.go
Expand Up @@ -234,13 +234,20 @@ func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, b

cmd := exec.Command(h.ExecuteCommand)
cmd.Dir = h.CommandWorkingDirectory

cmd.Args, err = h.ExtractCommandArguments(headers, query, payload)
if err != nil {
log.Printf("error extracting command arguments: %s", err)
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()

Expand Down
96 changes: 83 additions & 13 deletions webhook_test.go
@@ -1,5 +1,3 @@
// +build !windows

package main

import (
Expand All @@ -13,18 +11,28 @@ import (
"runtime"
"strings"
"testing"
"text/template"
"time"

"github.com/adnanh/webhook/hook"
)

func TestWebhook(t *testing.T) {
bin, cleanup := buildWebhook(t)
defer cleanup()
hookecho, cleanupHookecho := buildHookecho(t)
defer cleanupHookecho()

config, cleanupConfig := genConfig(t, hookecho)
defer cleanupConfig()

webhook, cleanupWebhook := buildWebhook(t)
defer cleanupWebhook()

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.Env = webhookEnv()
cmd.Args[0] = "webhook"
if err := cmd.Start(); err != nil {
t.Fatalf("failed to start webhook: %s", err)
Expand All @@ -41,10 +49,8 @@ func TestWebhook(t *testing.T) {
t.Errorf("New request failed: %s", err)
}

if tt.headers != nil {
for k, v := range tt.headers {
req.Header.Add(k, v)
}
for k, v := range tt.headers {
req.Header.Add(k, v)
}

var res *http.Response
Expand Down Expand Up @@ -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()) {
tmp, err := ioutil.TempDir("", "webhook-test-")
if err != nil {
Expand Down Expand Up @@ -142,6 +200,18 @@ func killAndWait(cmd *exec.Cmd) {
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 {
desc string
id string
Expand Down Expand Up @@ -302,7 +372,7 @@ var hookHandlerTests = []struct {
}`,
false,
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.
Expand All @@ -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"}`,
true,
http.StatusOK,
`{"message":"success","output":"\n","error":""}`,
`{"message":"success"}`,
},
{
"gitlab",
Expand Down Expand Up @@ -361,7 +431,7 @@ var hookHandlerTests = []struct {
}`,
false,
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.`},
Expand Down

0 comments on commit 2026328

Please sign in to comment.