From 2947e5e0e86c5c275e78f31991bb82d89565bad0 Mon Sep 17 00:00:00 2001 From: Cameron Moore Date: Thu, 29 Oct 2015 11:00:30 -0500 Subject: [PATCH 01/10] Remove logging side-effects from hook package The hook package should be self-contained and return errors instead of relying on the log subsystem. This commit removes the logging side-effects from the hook package. Custom errors are returned that should be transparent to the caller -- they can just treat them as simple errors if they don't care to check the type. --- hook/hook.go | 134 +++++++++++++++++++++++++++++++--------------- hook/hook_test.go | 119 +++++++++++++++++++++++----------------- webhook.go | 35 ++++++++++-- 3 files changed, 194 insertions(+), 94 deletions(-) diff --git a/hook/hook.go b/hook/hook.go index 16f4a47..232970f 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -5,9 +5,9 @@ import ( "crypto/sha1" "encoding/hex" "encoding/json" + "errors" "fmt" "io/ioutil" - "log" "reflect" "regexp" "strconv" @@ -25,17 +25,62 @@ const ( SourceEntireHeaders string = "entire-headers" ) +// ErrInvalidPayloadSignature describes an invalid payload signature. +var ErrInvalidPayloadSignature = errors.New("invalid payload signature") + +// ArgumentError describes an invalid argument passed to Hook. +type ArgumentError struct { + Argument Argument +} + +func (e *ArgumentError) Error() string { + if e == nil { + return "" + } + return fmt.Sprintf("couldn't retrieve argument for %+v", e.Argument) +} + +// SourceError describes an invalid source passed to Hook. +type SourceError struct { + Argument Argument +} + +func (e *SourceError) Error() string { + if e == nil { + return "" + } + return fmt.Sprintf("invalid source for argument %+v", e.Argument) +} + +// ParseError describes an error parsing user input. +type ParseError struct { + Err error +} + +func (e *ParseError) Error() string { + if e == nil { + return "" + } + return e.Err.Error() +} + // CheckPayloadSignature calculates and verifies SHA1 signature of the given payload -func CheckPayloadSignature(payload []byte, secret string, signature string) (string, bool) { +func CheckPayloadSignature(payload []byte, secret string, signature string) (string, error) { if strings.HasPrefix(signature, "sha1=") { signature = signature[5:] } mac := hmac.New(sha1.New, []byte(secret)) - mac.Write(payload) + _, err := mac.Write(payload) + if err != nil { + return "", err + } expectedMAC := hex.EncodeToString(mac.Sum(nil)) - return expectedMAC, hmac.Equal([]byte(signature), []byte(expectedMAC)) + if !hmac.Equal([]byte(signature), []byte(expectedMAC)) { + err = ErrInvalidPayloadSignature + } + return expectedMAC, err } // ReplaceParameter replaces parameter value with the passed value in the passed map @@ -201,7 +246,7 @@ type Hook struct { // ParseJSONParameters decodes specified arguments to JSON objects and replaces the // string with the newly created object -func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface{}) { +func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface{}) error { for i := range h.JSONStringParameters { if arg, ok := h.JSONStringParameters[i].Get(headers, query, payload); ok { var newArg map[string]interface{} @@ -212,7 +257,7 @@ func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface err := decoder.Decode(&newArg) if err != nil { - log.Printf("error parsing argument as JSON payload %+v\n", err) + return &ParseError{err} } else { var source *map[string]interface{} @@ -228,18 +273,20 @@ func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface if source != nil { ReplaceParameter(h.JSONStringParameters[i].Name, source, newArg) } else { - log.Printf("invalid source for argument %+v\n", h.JSONStringParameters[i]) + return &SourceError{h.JSONStringParameters[i]} } } } else { - log.Printf("couldn't retrieve argument for %+v\n", h.JSONStringParameters[i]) + return &ArgumentError{h.JSONStringParameters[i]} } } + + return nil } // ExtractCommandArguments creates a list of arguments, based on the // PassArgumentsToCommand property that is ready to be used with exec.Command() -func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]interface{}) []string { +func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]interface{}) ([]string, error) { var args = make([]string, 0) args = append(args, h.ExecuteCommand) @@ -249,11 +296,11 @@ func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]inter args = append(args, arg) } else { args = append(args, "") - log.Printf("couldn't retrieve argument for %+v\n", h.PassArgumentsToCommand[i]) + return args, &ArgumentError{h.PassArgumentsToCommand[i]} } } - return args + return args, nil } // Hooks is an array of Hook objects @@ -291,7 +338,7 @@ func (h *Hooks) Match(id string) *Hook { // MatchAll iterates through Hooks and returns all of the hooks that match the // given ID, if no hook matches the given ID, nil is returned func (h *Hooks) MatchAll(id string) []*Hook { - matchedHooks := make([]*Hook, 0) + var matchedHooks []*Hook for i := range *h { if (*h)[i].ID == id { matchedHooks = append(matchedHooks, &(*h)[i]) @@ -315,7 +362,7 @@ type Rules struct { // Evaluate finds the first rule property that is not nil and returns the value // it evaluates to -func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool { +func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) { switch { case r.And != nil: return r.And.Evaluate(headers, query, payload, body) @@ -327,49 +374,60 @@ func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[ return r.Match.Evaluate(headers, query, payload, body) } - return false + return false, nil } // AndRule will evaluate to true if and only if all of the ChildRules evaluate to true type AndRule []Rules // Evaluate AndRule will return true if and only if all of ChildRules evaluate to true -func (r AndRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool { +func (r AndRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) { res := true for _, v := range r { - res = res && v.Evaluate(headers, query, payload, body) + rv, err := v.Evaluate(headers, query, payload, body) + if err != nil { + return false, err + } + + res = res && rv if res == false { - return res + return res, nil } } - return res + return res, nil } // OrRule will evaluate to true if any of the ChildRules evaluate to true type OrRule []Rules // Evaluate OrRule will return true if any of ChildRules evaluate to true -func (r OrRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool { +func (r OrRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) { res := false for _, v := range r { - res = res || v.Evaluate(headers, query, payload, body) + rv, err := v.Evaluate(headers, query, payload, body) + if err != nil { + return false, err + } + + res = res || rv if res == true { - return res + return res, nil } } - return res + return res, nil } // NotRule will evaluate to true if any and only if the ChildRule evaluates to false type NotRule Rules // Evaluate NotRule will return true if and only if ChildRule evaluates to false -func (r NotRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool { - return !Rules(r).Evaluate(headers, query, payload, body) +func (r NotRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) { + rv, err := Rules(r).Evaluate(headers, query, payload, body) + return !rv, err } // MatchRule will evaluate to true based on the type @@ -389,34 +447,24 @@ const ( ) // Evaluate MatchRule will return based on the type -func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool { +func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) { if arg, ok := r.Parameter.Get(headers, query, payload); ok { switch r.Type { case MatchValue: - return arg == r.Value + return arg == r.Value, nil case MatchRegex: - ok, err := regexp.MatchString(r.Regex, arg) - if err != nil { - log.Printf("error while trying to evaluate regex: %+v", err) - } - return ok + return regexp.MatchString(r.Regex, arg) case MatchHashSHA1: - expected, ok := CheckPayloadSignature(*body, r.Secret, arg) - if !ok { - log.Printf("payload signature mismatch, expected %s got %s", expected, arg) - } - - return ok + _, err := CheckPayloadSignature(*body, r.Secret, arg) + return err == nil, err } - } else { - log.Printf("couldn't retrieve argument for %+v\n", r.Parameter) } - return false + return false, &ArgumentError{r.Parameter} } // CommandStatusResponse type encapsulates the executed command exit code, message, stdout and stderr type CommandStatusResponse struct { - ResponseMessage string `json:"message"` - Output string `json:"output"` - Error string `json:"error"` + ResponseMessage string `json:"message,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` } diff --git a/hook/hook_test.go b/hook/hook_test.go index ad77b36..fbd7b0a 100644 --- a/hook/hook_test.go +++ b/hook/hook_test.go @@ -21,9 +21,9 @@ var checkPayloadSignatureTests = []struct { func TestCheckPayloadSignature(t *testing.T) { for _, tt := range checkPayloadSignatureTests { - mac, ok := CheckPayloadSignature(tt.payload, tt.secret, tt.signature) - if ok != tt.ok || mac != tt.mac { - t.Errorf("failed to check payload signature {%q, %q, %q}:\nexpected {mac:%#v, ok:%#v},\ngot {mac:%#v, ok:%#v}", tt.payload, tt.secret, tt.signature, tt.mac, tt.ok, mac, ok) + mac, err := CheckPayloadSignature(tt.payload, tt.secret, tt.signature) + if (err == nil) != tt.ok || mac != tt.mac { + t.Errorf("failed to check payload signature {%q, %q, %q}:\nexpected {mac:%#v, ok:%#v},\ngot {mac:%#v, ok:%#v}", tt.payload, tt.secret, tt.signature, tt.mac, tt.ok, mac, (err == nil)) } } } @@ -92,23 +92,24 @@ var hookParseJSONParametersTests = []struct { params []Argument headers, query, payload *map[string]interface{} rheaders, rquery, rpayload *map[string]interface{} + ok bool }{ - {[]Argument{Argument{"header", "a"}}, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil, nil}, - {[]Argument{Argument{"url", "a"}}, nil, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil}, - {[]Argument{Argument{"payload", "a"}}, nil, nil, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}}, - {[]Argument{Argument{"header", "z"}}, &map[string]interface{}{"z": `{}`}, nil, nil, &map[string]interface{}{"z": map[string]interface{}{}}, nil, nil}, + {[]Argument{Argument{"header", "a"}}, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil, nil, true}, + {[]Argument{Argument{"url", "a"}}, nil, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil, true}, + {[]Argument{Argument{"payload", "a"}}, nil, nil, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, true}, + {[]Argument{Argument{"header", "z"}}, &map[string]interface{}{"z": `{}`}, nil, nil, &map[string]interface{}{"z": map[string]interface{}{}}, nil, nil, true}, // failures - {[]Argument{Argument{"header", "z"}}, &map[string]interface{}{"z": ``}, nil, nil, &map[string]interface{}{"z": ``}, nil, nil}, // empty string - {[]Argument{Argument{"header", "y"}}, &map[string]interface{}{"X": `{}`}, nil, nil, &map[string]interface{}{"X": `{}`}, nil, nil}, // missing parameter - {[]Argument{Argument{"string", "z"}}, &map[string]interface{}{"z": ``}, nil, nil, &map[string]interface{}{"z": ``}, nil, nil}, // invalid argument source + {[]Argument{Argument{"header", "z"}}, &map[string]interface{}{"z": ``}, nil, nil, &map[string]interface{}{"z": ``}, nil, nil, false}, // empty string + {[]Argument{Argument{"header", "y"}}, &map[string]interface{}{"X": `{}`}, nil, nil, &map[string]interface{}{"X": `{}`}, nil, nil, false}, // missing parameter + {[]Argument{Argument{"string", "z"}}, &map[string]interface{}{"z": ``}, nil, nil, &map[string]interface{}{"z": ``}, nil, nil, false}, // invalid argument source } func TestHookParseJSONParameters(t *testing.T) { for _, tt := range hookParseJSONParametersTests { h := &Hook{JSONStringParameters: tt.params} - h.ParseJSONParameters(tt.headers, tt.query, tt.payload) - if !reflect.DeepEqual(tt.headers, tt.rheaders) { - t.Errorf("failed to parse %v:\nexpected %#v,\ngot %#v", tt.params, *tt.rheaders, *tt.headers) + err := h.ParseJSONParameters(tt.headers, tt.query, tt.payload) + if (err == nil) != tt.ok || !reflect.DeepEqual(tt.headers, tt.rheaders) { + t.Errorf("failed to parse %v:\nexpected %#v, ok: %v\ngot %#v, ok: %v", tt.params, *tt.rheaders, tt.ok, *tt.headers, (err == nil)) } } } @@ -118,18 +119,19 @@ var hookExtractCommandArgumentsTests = []struct { args []Argument headers, query, payload *map[string]interface{} value []string + ok bool }{ - {"test", []Argument{Argument{"header", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"test", "z"}}, + {"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", ""}}, + {"fail", []Argument{Argument{"payload", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"fail", ""}, false}, } func TestHookExtractCommandArguments(t *testing.T) { for _, tt := range hookExtractCommandArgumentsTests { h := &Hook{ExecuteCommand: tt.exec, PassArgumentsToCommand: tt.args} - value := h.ExtractCommandArguments(tt.headers, tt.query, tt.payload) - if !reflect.DeepEqual(value, tt.value) { - t.Errorf("failed to extract args {cmd=%q, args=%v}:\nexpected %#v,\ngot %#v", tt.exec, tt.args, tt.value, value) + value, err := h.ExtractCommandArguments(tt.headers, tt.query, tt.payload) + if (err == nil) != tt.ok || !reflect.DeepEqual(value, tt.value) { + t.Errorf("failed to extract args {cmd=%q, args=%v}:\nexpected %#v, ok: %v\ngot %#v, ok: %v", tt.exec, tt.args, tt.value, tt.ok, value, (err == nil)) } } } @@ -178,24 +180,26 @@ var matchRuleTests = []struct { headers, query, payload *map[string]interface{} body []byte ok bool + err bool }{ - {"value", "", "", "z", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, true}, - {"regex", "^z", "", "z", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, true}, - {"payload-hash-sha1", "", "secret", "", Argument{"header", "a"}, &map[string]interface{}{"a": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, []byte(`{"a": "z"}`), true}, + {"value", "", "", "z", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, true, false}, + {"regex", "^z", "", "z", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, true, false}, + {"payload-hash-sha1", "", "secret", "", Argument{"header", "a"}, &map[string]interface{}{"a": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, []byte(`{"a": "z"}`), true, false}, // failures - {"value", "", "", "X", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false}, - {"value", "", "", "X", Argument{"header", "a"}, &map[string]interface{}{"y": "z"}, nil, nil, []byte{}, false}, - {"regex", "^X", "", "", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false}, - {"regex", "*", "", "", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false}, - {"payload-hash-sha1", "", "secret", "", Argument{"header", "a"}, &map[string]interface{}{"a": ""}, nil, nil, []byte{}, 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}, + // 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 } func TestMatchRule(t *testing.T) { - for _, tt := range matchRuleTests { + for i, tt := range matchRuleTests { r := MatchRule{tt.typ, tt.regex, tt.secret, tt.value, tt.param} - ok := r.Evaluate(tt.headers, tt.query, tt.payload, &tt.body) - if ok != tt.ok { - t.Errorf("failed to match %#v:\nexpected %#v,\ngot %#v", r, tt.ok, ok) + ok, err := r.Evaluate(tt.headers, tt.query, tt.payload, &tt.body) + if ok != tt.ok || (err != nil) != tt.err { + t.Errorf("%d failed to match %#v:\nexpected ok: %#v, err: %v\ngot ok: %#v, err: %v", i, r, tt.ok, tt.err, ok, (err != nil)) } } } @@ -206,6 +210,7 @@ var andRuleTests = []struct { headers, query, payload *map[string]interface{} body []byte ok bool + err bool }{ { "(a=z, b=y): a=z && b=y", @@ -214,7 +219,7 @@ var andRuleTests = []struct { {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}}, }, &map[string]interface{}{"a": "z", "b": "y"}, nil, nil, []byte{}, - true, + true, false, }, { "(a=z, b=Y): a=z && b=y", @@ -223,7 +228,7 @@ var andRuleTests = []struct { {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}}, }, &map[string]interface{}{"a": "z", "b": "Y"}, nil, nil, []byte{}, - false, + false, false, }, // Complex test to cover Rules.Evaluate { @@ -249,16 +254,23 @@ var andRuleTests = []struct { }, }, &map[string]interface{}{"a": "z", "b": "y", "c": "x", "d": "w", "e": "X", "f": "X"}, nil, nil, []byte{}, - true, + true, false, + }, + {"empty rule", AndRule{{}}, nil, nil, nil, nil, false, false}, + // failures + { + "invalid rule", + AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a"}}}}, + &map[string]interface{}{"y": "z"}, nil, nil, nil, + false, true, }, - {"empty rule", AndRule{{}}, nil, nil, nil, nil, false}, } func TestAndRule(t *testing.T) { for _, tt := range andRuleTests { - ok := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body) - if ok != tt.ok { - t.Errorf("failed to match %#v:\nexpected %#v,\ngot %#v", tt.desc, tt.ok, ok) + ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body) + if ok != tt.ok || (err != nil) != tt.err { + t.Errorf("failed to match %#v:\nexpected ok: %#v, err: %v\ngot ok: %#v, err: %v", tt.desc, tt.ok, tt.err, ok, err) } } } @@ -269,6 +281,7 @@ var orRuleTests = []struct { headers, query, payload *map[string]interface{} body []byte ok bool + err bool }{ { "(a=z, b=X): a=z || b=y", @@ -277,7 +290,7 @@ var orRuleTests = []struct { {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}}, }, &map[string]interface{}{"a": "z", "b": "X"}, nil, nil, []byte{}, - true, + true, false, }, { "(a=X, b=y): a=z || b=y", @@ -286,7 +299,7 @@ var orRuleTests = []struct { {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}}, }, &map[string]interface{}{"a": "X", "b": "y"}, nil, nil, []byte{}, - true, + true, false, }, { "(a=Z, b=Y): a=z || b=y", @@ -295,15 +308,24 @@ var orRuleTests = []struct { {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}}, }, &map[string]interface{}{"a": "Z", "b": "Y"}, nil, nil, []byte{}, - false, + false, false, + }, + // failures + { + "invalid rule", + OrRule{ + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}}, + }, + &map[string]interface{}{"y": "Z"}, nil, nil, []byte{}, + false, true, }, } func TestOrRule(t *testing.T) { for _, tt := range orRuleTests { - ok := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body) - if ok != tt.ok { - t.Errorf("%#v:\nexpected %#v,\ngot %#v", tt.desc, tt.ok, ok) + ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body) + if ok != tt.ok || (err != nil) != tt.err { + t.Errorf("%#v:\nexpected ok: %#v, err: %v\ngot ok: %#v err: %v", tt.desc, tt.ok, tt.err, ok, err) } } } @@ -314,16 +336,17 @@ var notRuleTests = []struct { headers, query, payload *map[string]interface{} body []byte ok bool + err bool }{ - {"(a=z): !a=X", NotRule{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a"}}}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, true}, - {"(a=z): !a=z", NotRule{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false}, + {"(a=z): !a=X", NotRule{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a"}}}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, true, false}, + {"(a=z): !a=z", NotRule{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, false}, } func TestNotRule(t *testing.T) { for _, tt := range notRuleTests { - ok := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body) - if ok != tt.ok { - t.Errorf("failed to match %#v:\nexpected %#v,\ngot %#v", tt.rule, tt.ok, ok) + ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body) + if ok != tt.ok || (err != nil) != tt.err { + t.Errorf("failed to match %#v:\nexpected ok: %#v, err: %v\ngot ok: %#v, err: %v", tt.rule, tt.ok, tt.err, ok, err) } } } diff --git a/webhook.go b/webhook.go index a01baa5..5d18de0 100644 --- a/webhook.go +++ b/webhook.go @@ -191,8 +191,31 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { // handle hook for _, h := range matchedHooks { - h.ParseJSONParameters(&headers, &query, &payload) - if h.TriggerRule == nil || h.TriggerRule != nil && h.TriggerRule.Evaluate(&headers, &query, &payload, &body) { + err := h.ParseJSONParameters(&headers, &query, &payload) + if err != nil { + msg := fmt.Sprintf("error parsing JSON: %s", err) + log.Printf(msg) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, msg) + return + } + + var ok bool + + if h.TriggerRule == nil { + ok = true + } else { + ok, err = h.TriggerRule.Evaluate(&headers, &query, &payload, &body) + if err != nil { + msg := fmt.Sprintf("error evaluating hook: %s", err) + log.Printf(msg) + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, msg) + return + } + } + + if ok { log.Printf("%s hook triggered successfully\n", h.ID) if h.CaptureCommandOutput { @@ -219,9 +242,15 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { } func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, body *[]byte) string { + var err error + cmd := exec.Command(h.ExecuteCommand) - cmd.Args = h.ExtractCommandArguments(headers, query, payload) 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) From 1c319a7a08b544f319ef98614ee37105449d62b3 Mon Sep 17 00:00:00 2001 From: Cameron Moore Date: Thu, 29 Oct 2015 11:17:15 -0500 Subject: [PATCH 02/10] Refactor signal handling and Windows support This commit drops webhook_windows.go in favor of simply pulling out the signal handling code to separate files. --- signals.go | 34 ++++++ signals_windows.go | 7 ++ webhook.go | 26 +--- webhook_windows.go | 287 --------------------------------------------- 4 files changed, 42 insertions(+), 312 deletions(-) create mode 100644 signals.go create mode 100644 signals_windows.go delete mode 100644 webhook_windows.go diff --git a/signals.go b/signals.go new file mode 100644 index 0000000..e4bd4c6 --- /dev/null +++ b/signals.go @@ -0,0 +1,34 @@ +// +build !windows + +package main + +import ( + "log" + "os" + "os/signal" + "syscall" +) + +func setupSignals() { + log.Printf("setting up os signal watcher\n") + + signals = make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGUSR1) + + go watchForSignals() +} + +func watchForSignals() { + log.Println("os signal watcher ready") + + for { + sig := <-signals + if sig == syscall.SIGUSR1 { + log.Println("caught USR1 signal") + + reloadHooks() + } else { + log.Printf("caught unhandled signal %+v\n", sig) + } + } +} diff --git a/signals_windows.go b/signals_windows.go new file mode 100644 index 0000000..e7a2a1d --- /dev/null +++ b/signals_windows.go @@ -0,0 +1,7 @@ +// +build windows + +package main + +func setupSignals() { + // NOOP: Windows doesn't have signals equivalent to the Unix world. +} diff --git a/webhook.go b/webhook.go index a01baa5..27afe6a 100644 --- a/webhook.go +++ b/webhook.go @@ -1,5 +1,3 @@ -//+build !windows - package main import ( @@ -12,9 +10,7 @@ import ( "net/url" "os" "os/exec" - "os/signal" "strings" - "syscall" "github.com/adnanh/webhook/hook" @@ -61,12 +57,7 @@ func init() { log.Println("version " + version + " starting") // set os signal watcher - log.Printf("setting up os signal watcher\n") - - signals = make(chan os.Signal, 1) - signal.Notify(signals, syscall.SIGUSR1) - - go watchForSignals() + setupSignals() // load and parse hooks log.Printf("attempting to load hooks from %s\n", *hooksFilePath) @@ -285,21 +276,6 @@ func watchForFileChange() { } } -func watchForSignals() { - log.Println("os signal watcher ready") - - for { - sig := <-signals - if sig == syscall.SIGUSR1 { - log.Println("caught USR1 signal") - - reloadHooks() - } else { - log.Printf("caught unhandled signal %+v\n", sig) - } - } -} - // valuesToMap converts map[string][]string to a map[string]string object func valuesToMap(values map[string][]string) map[string]interface{} { ret := make(map[string]interface{}) diff --git a/webhook_windows.go b/webhook_windows.go deleted file mode 100644 index 2351a4c..0000000 --- a/webhook_windows.go +++ /dev/null @@ -1,287 +0,0 @@ -package main - -import ( - "encoding/json" - "flag" - "fmt" - "io/ioutil" - "log" - "net/http" - "net/url" - "os" - "os/exec" - "strings" - - "github.com/adnanh/webhook/hook" - - "github.com/codegangsta/negroni" - "github.com/gorilla/mux" - - fsnotify "gopkg.in/fsnotify.v1" -) - -const ( - version = "2.3.5" -) - -var ( - ip = flag.String("ip", "", "ip the webhook should serve hooks on") - port = flag.Int("port", 9000, "port the webhook should serve hooks on") - verbose = flag.Bool("verbose", false, "show verbose output") - noPanic = flag.Bool("nopanic", false, "do not panic if hooks cannot be loaded when webhook is not running in verbose mode") - hotReload = flag.Bool("hotreload", false, "watch hooks file for changes and reload them automatically") - hooksFilePath = flag.String("hooks", "hooks.json", "path to the json file containing defined hooks the webhook should serve") - hooksURLPrefix = flag.String("urlprefix", "hooks", "url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id)") - secure = flag.Bool("secure", false, "use HTTPS instead of HTTP") - cert = flag.String("cert", "cert.pem", "path to the HTTPS certificate pem file") - key = flag.String("key", "key.pem", "path to the HTTPS certificate private key pem file") - - watcher *fsnotify.Watcher - signals chan os.Signal - - hooks hook.Hooks -) - -func init() { - hooks = hook.Hooks{} - - flag.Parse() - - log.SetPrefix("[webhook] ") - log.SetFlags(log.Ldate | log.Ltime) - - if !*verbose { - log.SetOutput(ioutil.Discard) - } - - log.Println("version " + version + " starting") - - // load and parse hooks - log.Printf("attempting to load hooks from %s\n", *hooksFilePath) - - err := hooks.LoadFromFile(*hooksFilePath) - - if err != nil { - if !*verbose && !*noPanic { - log.SetOutput(os.Stdout) - log.Fatalf("couldn't load any hooks from file! %+v\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic", err) - } - - log.Printf("couldn't load hooks from file! %+v\n", err) - } else { - log.Printf("loaded %d hook(s) from file\n", len(hooks)) - - for _, hook := range hooks { - log.Printf("\t> %s\n", hook.ID) - } - } -} - -func main() { - if *hotReload { - // set up file watcher - log.Printf("setting up file watcher for %s\n", *hooksFilePath) - - var err error - - watcher, err = fsnotify.NewWatcher() - if err != nil { - log.Fatal("error creating file watcher instance", err) - } - - defer watcher.Close() - - go watchForFileChange() - - err = watcher.Add(*hooksFilePath) - if err != nil { - log.Fatal("error adding hooks file to the watcher", err) - } - } - - l := negroni.NewLogger() - l.Logger = log.New(os.Stdout, "[webhook] ", log.Ldate|log.Ltime) - - negroniRecovery := &negroni.Recovery{ - Logger: l.Logger, - PrintStack: true, - StackAll: false, - StackSize: 1024 * 8, - } - - n := negroni.New(negroniRecovery, l) - - router := mux.NewRouter() - - var hooksURL string - - if *hooksURLPrefix == "" { - hooksURL = "/{id}" - } else { - hooksURL = "/" + *hooksURLPrefix + "/{id}" - } - - router.HandleFunc(hooksURL, hookHandler) - - n.UseHandler(router) - - if *secure { - log.Printf("starting secure (https) webhook on %s:%d", *ip, *port) - log.Fatal(http.ListenAndServeTLS(fmt.Sprintf("%s:%d", *ip, *port), *cert, *key, n)) - } else { - log.Printf("starting insecure (http) webhook on %s:%d", *ip, *port) - log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", *ip, *port), n)) - } - -} - -func hookHandler(w http.ResponseWriter, r *http.Request) { - id := mux.Vars(r)["id"] - - matchedHooks := hooks.MatchAll(id) - - if matchedHooks != nil { - log.Printf("%s got matched (%d time(s))\n", id, len(matchedHooks)) - - body, err := ioutil.ReadAll(r.Body) - if err != nil { - log.Printf("error reading the request body. %+v\n", err) - } - - // parse headers - headers := valuesToMap(r.Header) - - // parse query variables - query := valuesToMap(r.URL.Query()) - - // parse body - var payload map[string]interface{} - - contentType := r.Header.Get("Content-Type") - - if strings.Contains(contentType, "json") { - decoder := json.NewDecoder(strings.NewReader(string(body))) - decoder.UseNumber() - - err := decoder.Decode(&payload) - - if err != nil { - log.Printf("error parsing JSON payload %+v\n", err) - } - } else if strings.Contains(contentType, "form") { - fd, err := url.ParseQuery(string(body)) - if err != nil { - log.Printf("error parsing form payload %+v\n", err) - } else { - payload = valuesToMap(fd) - } - } - - // handle hook - for _, h := range matchedHooks { - h.ParseJSONParameters(&headers, &query, &payload) - if h.TriggerRule == nil || h.TriggerRule != nil && h.TriggerRule.Evaluate(&headers, &query, &payload, &body) { - log.Printf("%s hook triggered successfully\n", h.ID) - - if h.CaptureCommandOutput { - response := handleHook(h, &headers, &query, &payload, &body) - fmt.Fprintf(w, response) - } else { - go handleHook(h, &headers, &query, &payload, &body) - fmt.Fprintf(w, h.ResponseMessage) - } - - return - } - } - - // if none of the hooks got triggered - log.Printf("%s got matched (%d time(s)), but didn't get triggered because the trigger rules were not satisfied\n", matchedHooks[0].ID, len(matchedHooks)) - - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, "Hook rules were not satisfied.") - } else { - w.WriteHeader(http.StatusNotFound) - fmt.Fprintf(w, "Hook not found.") - } -} - -func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, body *[]byte) string { - cmd := exec.Command(h.ExecuteCommand) - cmd.Args = h.ExtractCommandArguments(headers, query, payload) - cmd.Dir = h.CommandWorkingDirectory - - log.Printf("executing %s (%s) with arguments %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, cmd.Dir) - - out, err := cmd.CombinedOutput() - - log.Printf("command output: %s\n", out) - - var errorResponse string - - if err != nil { - log.Printf("error occurred: %+v\n", err) - errorResponse = fmt.Sprintf("%+v", err) - } - - log.Printf("finished handling %s\n", h.ID) - - var response []byte - response, err = json.Marshal(&hook.CommandStatusResponse{ResponseMessage: h.ResponseMessage, Output: string(out), Error: errorResponse}) - - if err != nil { - log.Printf("error marshalling response: %+v", err) - return h.ResponseMessage - } - - return string(response) -} - -func reloadHooks() { - newHooks := hook.Hooks{} - - // parse and swap - log.Printf("attempting to reload hooks from %s\n", *hooksFilePath) - - err := newHooks.LoadFromFile(*hooksFilePath) - - if err != nil { - log.Printf("couldn't load hooks from file! %+v\n", err) - } else { - log.Printf("loaded %d hook(s) from file\n", len(hooks)) - - for _, hook := range hooks { - log.Printf("\t> %s\n", hook.ID) - } - - hooks = newHooks - } -} - -func watchForFileChange() { - for { - select { - case event := <-(*watcher).Events: - if event.Op&fsnotify.Write == fsnotify.Write { - log.Println("hooks file modified") - - reloadHooks() - } - case err := <-(*watcher).Errors: - log.Println("watcher error:", err) - } - } -} - -// valuesToMap converts map[string][]string to a map[string]string object -func valuesToMap(values map[string][]string) map[string]interface{} { - ret := make(map[string]interface{}) - - for key, value := range values { - if len(value) > 0 { - ret[key] = value[0] - } - } - - return ret -} From 802f3f572c5851a59a32652f9430cbc8e752d53e Mon Sep 17 00:00:00 2001 From: Cameron Moore Date: Fri, 30 Oct 2015 21:12:13 -0500 Subject: [PATCH 03/10] Add testing framework for main webhook app This commit adds a testing framework modeled after the godoc tests. It builds webhook in a temporary directory, runs it with the supplied `hooks_test.json` configuration, and then tests different payloads. I use `/bin/echo` for the test executable, so I've added build tags to exclude Windows. Three minor (I hope) changes in functionality: - I ended up moving everything from `init()` to `main()` because `init()` was firing while trying to build the tests, and it was dying since `hooks.json` didn't exist. I'm still not 100% sure `init()` was firing, but I didn't see any real need for anything to be in `init()` in the first place. - make sure logger is using `os.Stderr` - don't send `http.StatusBadRequest` when the Hook rules don't match. "Bad Request" is used to identify malformed requests. The request was properly formed and processed, so I think we should send back `http.StatusOK`. For example, if I setup a webhook rule to only execute when commits are made to the `master` branch, we shouldn't send back `http.StatusBadRequest` when we ingest a payload for the `development` branch. The test payloads are pretty verbose and could probably be shortened, but I kind of like having an example payload for each service. We can pare them down if we want to do more focused, minimalist testing. --- hooks_test.json | 135 ++++++++++++++++++ webhook.go | 7 +- webhook_test.go | 368 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 505 insertions(+), 5 deletions(-) create mode 100644 hooks_test.json create mode 100644 webhook_test.go diff --git a/hooks_test.json b/hooks_test.json new file mode 100644 index 0000000..49b9f4d --- /dev/null +++ b/hooks_test.json @@ -0,0 +1,135 @@ +[ + { + "id": "github", + "execute-command": "/bin/echo", + "command-working-directory": "/", + "include-command-output-in-response": true, + "pass-arguments-to-command": + [ + { + "source": "payload", + "name": "head_commit.id" + }, + { + "source": "payload", + "name": "pusher.name" + }, + { + "source": "payload", + "name": "pusher.email" + } + ], + "trigger-rule": + { + "and": + [ + { + "match": + { + "type": "payload-hash-sha1", + "secret": "mysecret", + "parameter": + { + "source": "header", + "name": "X-Hub-Signature" + } + } + }, + { + "match": + { + "type": "value", + "value": "refs/heads/master", + "parameter": + { + "source": "payload", + "name": "ref" + } + } + } + ] + } + }, + { + "id": "bitbucket", + "execute-command": "/bin/echo", + "command-working-directory": "/", + "include-command-output-in-response": true, + "response-message": "success", + "parse-parameters-as-json": [ + { + "source": "payload", + "name": "payload" + } + ], + "trigger-rule": { + "and": [ + { + "match": { + "type": "value", + "parameter": { + "source": "payload", + "name": "payload.canon_url" + }, + "value": "https://bitbucket.org" + } + }, + { + "match": { + "type": "value", + "parameter": { + "source": "payload", + "name": "payload.repository.absolute_url" + }, + "value": "/webhook/testing/" + } + }, + { + "match": { + "type": "value", + "parameter": { + "source": "payload", + "name": "payload.commits.0.branch" + }, + "value": "master" + } + } + ] + } + }, + { + "id": "gitlab", + "execute-command": "/bin/echo", + "command-working-directory": "/", + "response-message": "success", + "include-command-output-in-response": true, + "pass-arguments-to-command": + [ + { + "source": "payload", + "name": "commits.0.id" + }, + { + "source": "payload", + "name": "user_name" + }, + { + "source": "payload", + "name": "user_email" + } + ], + "trigger-rule": + { + "match": + { + "type": "value", + "value": "refs/heads/master", + "parameter": + { + "source": "payload", + "name": "ref" + } + } + } + } +] diff --git a/webhook.go b/webhook.go index 5d18de0..2c8c541 100644 --- a/webhook.go +++ b/webhook.go @@ -46,7 +46,7 @@ var ( hooks hook.Hooks ) -func init() { +func main() { hooks = hook.Hooks{} flag.Parse() @@ -87,9 +87,7 @@ func init() { log.Printf("\t> %s\n", hook.ID) } } -} -func main() { if *hotReload { // set up file watcher log.Printf("setting up file watcher for %s\n", *hooksFilePath) @@ -112,7 +110,7 @@ func main() { } l := negroni.NewLogger() - l.Logger = log.New(os.Stdout, "[webhook] ", log.Ldate|log.Ltime) + l.Logger = log.New(os.Stderr, "[webhook] ", log.Ldate|log.Ltime) negroniRecovery := &negroni.Recovery{ Logger: l.Logger, @@ -233,7 +231,6 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { // if none of the hooks got triggered log.Printf("%s got matched (%d time(s)), but didn't get triggered because the trigger rules were not satisfied\n", matchedHooks[0].ID, len(matchedHooks)) - w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, "Hook rules were not satisfied.") } else { w.WriteHeader(http.StatusNotFound) diff --git a/webhook_test.go b/webhook_test.go new file mode 100644 index 0000000..2b71b53 --- /dev/null +++ b/webhook_test.go @@ -0,0 +1,368 @@ +// +build !windows + +package main + +import ( + "fmt" + "io/ioutil" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestWebhook(t *testing.T) { + bin, cleanup := buildWebhook(t) + defer cleanup() + + ip, port := serverAddress(t) + args := []string{"-hooks=hooks_test.json", fmt.Sprintf("-ip=%s", ip), fmt.Sprintf("-port=%s", port), "-verbose"} + + cmd := exec.Command(bin, args...) + //cmd.Stderr = os.Stderr // uncomment to see verbose output + cmd.Args[0] = "webhook" + if err := cmd.Start(); err != nil { + t.Fatalf("failed to start webhook: %s", err) + } + defer killAndWait(cmd) + + waitForServerReady(t, ip, port) + + for _, tt := range hookHandlerTests { + url := fmt.Sprintf("http://%s:%s/hooks/%s", ip, port, tt.id) + + req, err := http.NewRequest("POST", url, ioutil.NopCloser(strings.NewReader(tt.body))) + if err != nil { + t.Errorf("New request failed: %s", err) + } + + if tt.headers != nil { + for k, v := range tt.headers { + req.Header.Add(k, v) + } + } + + var res *http.Response + + if tt.urlencoded == true { + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + } else { + req.Header.Add("Content-Type", "application/json") + } + + client := &http.Client{} + res, err = client.Do(req) + if err != nil { + t.Errorf("client.Do failed: %s", err) + } + + body, err := ioutil.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Errorf("POST %q: failed to ready body: %s", tt.desc, err) + } + + if res.StatusCode != tt.respStatus || string(body) != tt.respBody { + t.Errorf("failed %q (id: %s):\nexpected status: %#v, response: %s\ngot status: %#v, response: %s", tt.desc, tt.id, tt.respStatus, tt.respBody, res.StatusCode, body) + } + } +} + +func buildWebhook(t *testing.T) (bin string, cleanup func()) { + tmp, err := ioutil.TempDir("", "webhook-test-") + if err != nil { + t.Fatal(err) + } + defer func() { + if cleanup == nil { + os.RemoveAll(tmp) + } + }() + + bin = filepath.Join(tmp, "webhook") + if runtime.GOOS == "windows" { + bin += ".exe" + } + + cmd := exec.Command("go", "build", "-o", bin) + if err := cmd.Run(); err != nil { + t.Fatalf("Building webhook: %v", err) + } + + return bin, func() { os.RemoveAll(tmp) } +} + +func serverAddress(t *testing.T) (string, string) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + ln, err = net.Listen("tcp6", "[::1]:0") + } + if err != nil { + t.Fatal(err) + } + defer ln.Close() + host, port, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + t.Fatalf("Failed to split network address: %v", err) + } + return host, port +} + +func waitForServerReady(t *testing.T, ip, port string) { + waitForServer(t, + fmt.Sprintf("http://%v:%v/", ip, port), + http.StatusNotFound, + 5*time.Second) +} + +const pollInterval = 200 * time.Millisecond + +func waitForServer(t *testing.T, url string, status int, timeout time.Duration) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + time.Sleep(pollInterval) + res, err := http.Get(url) + if err != nil { + continue + } + if res.StatusCode == status { + return + } + } + t.Fatalf("Server failed to respond in %v", timeout) +} + +func killAndWait(cmd *exec.Cmd) { + cmd.Process.Kill() + cmd.Wait() +} + +var hookHandlerTests = []struct { + desc string + id string + headers map[string]string + body string + urlencoded bool + + respStatus int + respBody string +}{ + { + "github", + "github", + map[string]string{"X-Hub-Signature": "f68df0375d7b03e3eb29b4cf9f9ec12e08f42ff8"}, + `{ + "after":"1481a2de7b2a7d02428ad93446ab166be7793fbb", + "before":"17c497ccc7cca9c2f735aa07e9e3813060ce9a6a", + "commits":[ + { + "added":[ + + ], + "author":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "committer":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "distinct":true, + "id":"c441029cf673f84c8b7db52d0a5944ee5c52ff89", + "message":"Test", + "modified":[ + "README.md" + ], + "removed":[ + + ], + "timestamp":"2013-02-22T13:50:07-08:00", + "url":"https://github.com/octokitty/testing/commit/c441029cf673f84c8b7db52d0a5944ee5c52ff89" + }, + { + "added":[ + + ], + "author":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "committer":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "distinct":true, + "id":"36c5f2243ed24de58284a96f2a643bed8c028658", + "message":"This is me testing the windows client.", + "modified":[ + "README.md" + ], + "removed":[ + + ], + "timestamp":"2013-02-22T14:07:13-08:00", + "url":"https://github.com/octokitty/testing/commit/36c5f2243ed24de58284a96f2a643bed8c028658" + }, + { + "added":[ + "words/madame-bovary.txt" + ], + "author":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "committer":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "distinct":true, + "id":"1481a2de7b2a7d02428ad93446ab166be7793fbb", + "message":"Rename madame-bovary.txt to words/madame-bovary.txt", + "modified":[ + + ], + "removed":[ + "madame-bovary.txt" + ], + "timestamp":"2013-03-12T08:14:29-07:00", + "url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb" + } + ], + "compare":"https://github.com/octokitty/testing/compare/17c497ccc7cc...1481a2de7b2a", + "created":false, + "deleted":false, + "forced":false, + "head_commit":{ + "added":[ + "words/madame-bovary.txt" + ], + "author":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "committer":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "distinct":true, + "id":"1481a2de7b2a7d02428ad93446ab166be7793fbb", + "message":"Rename madame-bovary.txt to words/madame-bovary.txt", + "modified":[ + + ], + "removed":[ + "madame-bovary.txt" + ], + "timestamp":"2013-03-12T08:14:29-07:00", + "url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb" + }, + "pusher":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian" + }, + "ref":"refs/heads/master", + "repository":{ + "created_at":1332977768, + "description":"", + "fork":false, + "forks":0, + "has_downloads":true, + "has_issues":true, + "has_wiki":true, + "homepage":"", + "id":3860742, + "language":"Ruby", + "master_branch":"master", + "name":"testing", + "open_issues":2, + "owner":{ + "email":"lolwut@noway.biz", + "name":"octokitty" + }, + "private":false, + "pushed_at":1363295520, + "size":2156, + "stargazers":1, + "url":"https://github.com/octokitty/testing", + "watchers":1 + } + }`, + false, + http.StatusOK, + `{"message":"","output":"1481a2de7b2a7d02428ad93446ab166be7793fbb Garen Torikian lolwut@noway.biz\n","error":""}`, + }, + { + "bitbucket", // bitbucket sends their payload using uriencoded params. + "bitbucket", + nil, + `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 ","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":""}`, + }, + { + "gitlab", + "gitlab", + map[string]string{"X-Gitlab-Event": "Push Hook"}, + `{ + "object_kind": "push", + "before": "95790bf891e76fee5e1747ab589903a6a1f80f22", + "after": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "ref": "refs/heads/master", + "user_id": 4, + "user_name": "John Smith", + "user_email": "john@example.com", + "project_id": 15, + "repository": { + "name": "Diaspora", + "url": "git@example.com:mike/diasporadiaspora.git", + "description": "", + "homepage": "http://example.com/mike/diaspora", + "git_http_url":"http://example.com/mike/diaspora.git", + "git_ssh_url":"git@example.com:mike/diaspora.git", + "visibility_level":0 + }, + "commits": [ + { + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "message": "Update Catalan translation to e38cb41.", + "timestamp": "2011-12-12T14:27:31+02:00", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "author": { + "name": "Jordi Mallach", + "email": "jordi@softcatala.org" + } + }, + { + "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "message": "fixed readme", + "timestamp": "2012-01-03T23:36:29+02:00", + "url": "http://example.com/mike/diaspora/commit/da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "author": { + "name": "GitLab dev user", + "email": "gitlabdev@dv6700.(none)" + } + } + ], + "total_commits_count": 4 + }`, + false, + http.StatusOK, + `{"message":"success","output":"b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327 John Smith john@example.com\n","error":""}`, + }, + + {"empty payload", "github", nil, `{}`, false, http.StatusOK, `Hook rules were not satisfied.`}, +} From ea3dbf343809537cd17a9da751dec0628e2c9076 Mon Sep 17 00:00:00 2001 From: Cameron Moore Date: Mon, 2 Nov 2015 14:34:18 -0600 Subject: [PATCH 04/10] Add environment arguments and improve testing There's a lot in this commit. 1. Add `pass-environment-to-command` option that works much like `pass-arguments-to-command`. You can see an example usage in the "github" test case. 2. Add a test program called "hookecho" that is used to test the webhook package instead of relying upon a system `echo` command. 3. Move hooks_test.json to a template so that we can update the path to hookecho on the fly. 4. Don't return an error at the end of hook.MatchRule.Evaluate(). All tests succeed for me now. --- README.md | 3 +- hook/hook.go | 43 ++++++++--- hook/hook_test.go | 8 +-- test/hookecho.go | 26 +++++++ hooks_test.json => test/hooks.json.tmpl | 14 +++- webhook.go | 9 ++- webhook_test.go | 96 +++++++++++++++++++++---- 7 files changed, 167 insertions(+), 32 deletions(-) create mode 100644 test/hookecho.go rename hooks_test.json => test/hooks.json.tmpl (91%) diff --git a/README.md b/README.md index 54fdb91..991371e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/hook/hook.go b/hook/hook.go index 232970f..1be088b 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -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") @@ -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 @@ -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 { args = append(args, arg) } else { - args = append(args, "") return args, &ArgumentError{h.PassArgumentsToCommand[i]} } } @@ -303,6 +309,23 @@ func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]inter 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 @@ -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 diff --git a/hook/hook_test.go b/hook/hook_test.go index fbd7b0a..932395a 100644 --- a/hook/hook_test.go +++ b/hook/hook_test.go @@ -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) { @@ -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 } @@ -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, }, } @@ -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, }, } diff --git a/test/hookecho.go b/test/hookecho.go new file mode 100644 index 0000000..4ac4b4d --- /dev/null +++ b/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, " ")) + } +} diff --git a/hooks_test.json b/test/hooks.json.tmpl similarity index 91% rename from hooks_test.json rename to test/hooks.json.tmpl index 49b9f4d..e2f696b 100644 --- a/hooks_test.json +++ b/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": [ { @@ -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", @@ -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, @@ -133,3 +140,4 @@ } } ] + diff --git a/webhook.go b/webhook.go index 7d92ce0..9fdc323 100644 --- a/webhook.go +++ b/webhook.go @@ -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() diff --git a/webhook_test.go b/webhook_test.go index 2b71b53..74b1c2f 100644 --- a/webhook_test.go +++ b/webhook_test.go @@ -1,5 +1,3 @@ -// +build !windows - package main import ( @@ -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) @@ -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 @@ -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 { @@ -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 @@ -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. @@ -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 ","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", @@ -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.`}, From b7081f393417d9cb874de0c8061fb3e4c811e761 Mon Sep 17 00:00:00 2001 From: almir Date: Tue, 3 Nov 2015 09:07:38 +0100 Subject: [PATCH 05/10] - adjust Makefile syntax --- Makefile | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 3ac7fef..b8c2342 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,18 @@ -DOCKER_IMAGE_NAME=adnanh/webhook -CONTAINER_NAME=webhook +DOCKER_IMAGE_NAME = adnanh/webhook +CONTAINER_NAME = webhook COMMIT := $(shell git rev-parse HEAD) SHORTCOMMIT := $(shell git rev-parse HEAD|cut -c-7) TEMPDIR := $(shell mktemp -d) docker-build: Dockerfile - docker build --force-rm=true --tag=${DOCKER_IMAGE_NAME} . + docker build --force-rm=true --tag=$(DOCKER_IMAGE_NAME) . docker-run: @echo "Here's an example command on how to run a webhook container:" - @echo "docker run -d -p 9000:9000 -v /etc/webhook:/etc/webhook --name=${CONTAINER_NAME} \\" - @echo " ${DOCKER_IMAGE_NAME} -verbose -hooks=/etc/webhook/hooks.json -hotreload" + @echo "docker run -d -p 9000:9000 -v /etc/webhook:/etc/webhook --name=$(CONTAINER_NAME) \\" + @echo " $(DOCKER_IMAGE_NAME) -verbose -hooks=/etc/webhook/hooks.json -hotreload" build_rpm: git archive --format=tar.gz --prefix webhook-$(COMMIT)/ --output $(TEMPDIR)/webhook-$(SHORTCOMMIT).tar.gz $(COMMIT) rpmbuild -ta --define "_commit $(COMMIT)" $(TEMPDIR)/webhook-$(SHORTCOMMIT).tar.gz - From 5f041cb9ae2a5ba71277a6ea97d0bfc2188ed2ac Mon Sep 17 00:00:00 2001 From: almir Date: Tue, 3 Nov 2015 09:25:53 +0100 Subject: [PATCH 06/10] - add docker section to readme --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index 991371e..4a37b72 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,36 @@ However, hook defined like that could pose a security threat to your system, bec # Examples Check out [Hook examples page](https://github.com/adnanh/webhook/wiki/Hook-Examples) for more complex examples of hooks. +# Running webhook in Docker +The simplest usage of [adnanh/webhook](https://hub.docker.com/r/adnanh/webhook/) image is for one to host the hooks JSON file on their machine and mount the directory in which those are kept as a volume to the Docker container: +```shell +docker run -d -p 9000:9000 -v /dir/to/hooks/on/host:/etc/webhook --name=webhook \ + adnanh/webhook -verbose -hooks=/etc/webhook/hooks.json -hotreload +``` + +Another method of using this Docker image is to create a simple `Dockerfile`: +```docker +FROM adnanh/webhook +COPY hooks.json.example /etc/webhook/hooks.json +``` + +This `Dockerfile` and `hooks.json` files should be placed inside the same directory. After that run `docker build -t my-webhook-image .` and then start your container: +```shell +docker run -d -p 9000:9000 --name=webhook my-webhook-image -verbose -hooks=/etc/webhook/hooks.json -hotreload +``` + +Additionally, one can specify the parameters to be passed to [webhook](https://github.com/adnanh/webhook/) in `Dockerfile` simply by adding one more line to the previous example: +```docker +FROM adnanh/webhook +COPY hooks.json.example /etc/webhook/hooks.json +CMD ["-verbose", "-hooks=/etc/webhook/hooks.json", "-hotreload"] +``` + +Now, after building your Docker image with `docker build -t my-webhook-image .`, you can start your container by running just: +```shell +docker run -d -p 9000:9000 --name=webhook my-webhook-image +``` + # Contributing Any form of contribution is welcome and highly appreciated. From aff3a155d79a0d4a4c0cfecb741fc200be9c7053 Mon Sep 17 00:00:00 2001 From: almir Date: Tue, 3 Nov 2015 09:28:42 +0100 Subject: [PATCH 07/10] - fix hooks.json file name in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a37b72..a2bfa8c 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ FROM adnanh/webhook COPY hooks.json.example /etc/webhook/hooks.json ``` -This `Dockerfile` and `hooks.json` files should be placed inside the same directory. After that run `docker build -t my-webhook-image .` and then start your container: +This `Dockerfile` and `hooks.json.example` files should be placed inside the same directory. After that run `docker build -t my-webhook-image .` and then start your container: ```shell docker run -d -p 9000:9000 --name=webhook my-webhook-image -verbose -hooks=/etc/webhook/hooks.json -hotreload ``` From 8d1498e28501522bdbfa200efed1061a0db235c4 Mon Sep 17 00:00:00 2001 From: Cameron Moore Date: Tue, 3 Nov 2015 10:47:17 -0600 Subject: [PATCH 08/10] Remove unnecessary else clause --- hook/hook.go | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/hook/hook.go b/hook/hook.go index 1be088b..cefc158 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -265,23 +265,23 @@ func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface if err != nil { return &ParseError{err} + } + + var source *map[string]interface{} + + switch h.JSONStringParameters[i].Source { + case SourceHeader: + source = headers + case SourcePayload: + source = payload + case SourceQuery: + source = query + } + + if source != nil { + ReplaceParameter(h.JSONStringParameters[i].Name, source, newArg) } else { - var source *map[string]interface{} - - switch h.JSONStringParameters[i].Source { - case SourceHeader: - source = headers - case SourcePayload: - source = payload - case SourceQuery: - source = query - } - - if source != nil { - ReplaceParameter(h.JSONStringParameters[i].Name, source, newArg) - } else { - return &SourceError{h.JSONStringParameters[i]} - } + return &SourceError{h.JSONStringParameters[i]} } } else { return &ArgumentError{h.JSONStringParameters[i]} From b314eda1f9541e898389a035dd333f773b109042 Mon Sep 17 00:00:00 2001 From: Cameron Moore Date: Fri, 6 Nov 2015 15:09:29 -0600 Subject: [PATCH 09/10] Remove webhook-contrib content and update README The Docker, Travis-CI, RPM, and init files are being moved to the separate `webhook-contrib` repository. --- .travis.yml | 30 ----- Dockerfile | 14 --- Makefile | 18 --- README.md | 37 ++---- golang-github-adnanh-webhook.spec | 186 ------------------------------ webhook.init | 111 ------------------ 6 files changed, 7 insertions(+), 389 deletions(-) delete mode 100644 .travis.yml delete mode 100644 Dockerfile delete mode 100644 Makefile delete mode 100644 golang-github-adnanh-webhook.spec delete mode 100644 webhook.init diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9bc507a..0000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -branches: - only: - - master -notifications: - email: - on_success: never - on_failure: always -sudo: required -services: -- docker -before_install: -- echo "deb http://us.archive.ubuntu.com/ubuntu trusty main universe" | sudo tee -a /etc/apt/sources.list -- sudo apt-get update -- docker pull fedora:22 -before_script: -- docker run -i --privileged -d -v $HOME/build/$TRAVIS_REPO_SLUG:$HOME/build/$TRAVIS_REPO_SLUG --name test_fedora fedora:22 bash -- docker exec -i test_fedora bash -c "dnf install -y rpmdevtools make mock git python-pip" -- docker exec -i test_fedora bash -c "dnf install -y go-compilers-golang-compiler" -- docker exec -i test_fedora bash -c "dnf install -y https://kojipkgs.fedoraproject.org//packages/golang-github-go-fsnotify-fsnotify/1.2.0/0.1.git96c060f.fc22/noarch/golang-github-go-fsnotify-fsnotify-devel-1.2.0-0.1.git96c060f.fc22.noarch.rpm" -- docker exec -i test_fedora bash -c "dnf install -y https://kojipkgs.fedoraproject.org//packages/golang-github-codegangsta-negroni/0.1/1.gitc7477ad.fc22/noarch/golang-github-codegangsta-negroni-devel-0.1-1.gitc7477ad.fc22.noarch.rpm" -- docker exec -i test_fedora bash -c "dnf install -y https://kojipkgs.fedoraproject.org//packages/golang-github-gorilla-mux/0/0.18.git8096f47.fc22/noarch/golang-github-gorilla-mux-devel-0-0.18.git8096f47.fc22.noarch.rpm" -- docker exec -i test_fedora bash -c "useradd -g root fedora; usermod -a -G mock fedora" -- docker exec -i test_fedora bash -c "pip install git+https://github.com/shawnsi/docker-rpmbuild" -script: -- docker exec -i test_fedora bash -c "pip install git+https://github.com/shawnsi/docker-rpmbuild" -- docker exec -u fedora -i test_fedora bash -c "cd $HOME/build/$TRAVIS_REPO_SLUG ; make build_rpm" - - - - diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 6b1f368..0000000 --- a/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM alpine -MAINTAINER Adnan Hajdarevic - -ENV GOPATH /go -ENV SRCPATH ${GOPATH}/src/github.com/adnanh/webhook -COPY . ${SRCPATH} -RUN apk add --update -t build-deps go git libc-dev gcc libgcc && \ - cd ${SRCPATH} && go get -d && go build -o /usr/local/bin/webhook && \ - apk del --purge build-deps && \ - rm -rf /var/cache/apk/* && \ - rm -rf ${GOPATH} - -EXPOSE 9000 -ENTRYPOINT ["/usr/local/bin/webhook"] diff --git a/Makefile b/Makefile deleted file mode 100644 index b8c2342..0000000 --- a/Makefile +++ /dev/null @@ -1,18 +0,0 @@ -DOCKER_IMAGE_NAME = adnanh/webhook -CONTAINER_NAME = webhook -COMMIT := $(shell git rev-parse HEAD) -SHORTCOMMIT := $(shell git rev-parse HEAD|cut -c-7) -TEMPDIR := $(shell mktemp -d) - - -docker-build: Dockerfile - docker build --force-rm=true --tag=$(DOCKER_IMAGE_NAME) . - -docker-run: - @echo "Here's an example command on how to run a webhook container:" - @echo "docker run -d -p 9000:9000 -v /etc/webhook:/etc/webhook --name=$(CONTAINER_NAME) \\" - @echo " $(DOCKER_IMAGE_NAME) -verbose -hooks=/etc/webhook/hooks.json -hotreload" - -build_rpm: - git archive --format=tar.gz --prefix webhook-$(COMMIT)/ --output $(TEMPDIR)/webhook-$(SHORTCOMMIT).tar.gz $(COMMIT) - rpmbuild -ta --define "_commit $(COMMIT)" $(TEMPDIR)/webhook-$(SHORTCOMMIT).tar.gz diff --git a/README.md b/README.md index a2bfa8c..c479e65 100644 --- a/README.md +++ b/README.md @@ -62,41 +62,14 @@ However, hook defined like that could pose a security threat to your system, bec # Examples Check out [Hook examples page](https://github.com/adnanh/webhook/wiki/Hook-Examples) for more complex examples of hooks. -# Running webhook in Docker -The simplest usage of [adnanh/webhook](https://hub.docker.com/r/adnanh/webhook/) image is for one to host the hooks JSON file on their machine and mount the directory in which those are kept as a volume to the Docker container: -```shell -docker run -d -p 9000:9000 -v /dir/to/hooks/on/host:/etc/webhook --name=webhook \ - adnanh/webhook -verbose -hooks=/etc/webhook/hooks.json -hotreload -``` - -Another method of using this Docker image is to create a simple `Dockerfile`: -```docker -FROM adnanh/webhook -COPY hooks.json.example /etc/webhook/hooks.json -``` - -This `Dockerfile` and `hooks.json.example` files should be placed inside the same directory. After that run `docker build -t my-webhook-image .` and then start your container: -```shell -docker run -d -p 9000:9000 --name=webhook my-webhook-image -verbose -hooks=/etc/webhook/hooks.json -hotreload -``` - -Additionally, one can specify the parameters to be passed to [webhook](https://github.com/adnanh/webhook/) in `Dockerfile` simply by adding one more line to the previous example: -```docker -FROM adnanh/webhook -COPY hooks.json.example /etc/webhook/hooks.json -CMD ["-verbose", "-hooks=/etc/webhook/hooks.json", "-hotreload"] -``` - -Now, after building your Docker image with `docker build -t my-webhook-image .`, you can start your container by running just: -```shell -docker run -d -p 9000:9000 --name=webhook my-webhook-image -``` - # Contributing Any form of contribution is welcome and highly appreciated. Big thanks to [all the current contributors](https://github.com/adnanh/webhook/graphs/contributors) for their contributions! +# Community Contributions +See the [webhook-contrib][wc] repository for a collections of tools and helpers related to [webhook][w] that have been contributed by the [webhook][w] community. + # License The MIT License (MIT) @@ -120,3 +93,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +[w]: https://github.com/adnanh/webhook +[wc]: https://github.com/adnanh/webhook-contrib diff --git a/golang-github-adnanh-webhook.spec b/golang-github-adnanh-webhook.spec deleted file mode 100644 index be36131..0000000 --- a/golang-github-adnanh-webhook.spec +++ /dev/null @@ -1,186 +0,0 @@ -%if 0%{?fedora} || 0%{?rhel} == 6 -%global with_devel 1 -%global with_bundled 0 -%global with_debug 1 -%global with_check 1 -%global with_unit_test 1 -%else -%global with_devel 0 -%global with_bundled 0 -%global with_debug 0 -%global with_check 0 -%global with_unit_test 0 -%endif - -%if 0%{?with_debug} -%global _dwz_low_mem_die_limit 0 -%else -%global debug_package %{nil} -%endif - -%global provider github -%global provider_tld com -%global project adnanh -%global repo webhook -# https://github.com/adnanh/webhook -%global provider_prefix %{provider}.%{provider_tld}/%{project}/%{repo} -%global import_path %{provider_prefix} -%if 0%{?_commit:1} -# rpmbuild -ba --define "_commit 409b441c31f83279af0db289123eb4b0b14809a6" *.spec -%global commit %{_commit} -%else -%global commit 409b441c31f83279af0db289123eb4b0b14809a6 -%endif -%global shortcommit %(c=%{commit}; echo ${c:0:7}) - -Name: golang-%{provider}-%{project}-%{repo} -Version: 0 -Release: 0.1.git%{shortcommit}%{?dist} -Summary: webhook is a lightweight configurable webhook server written in Go -License: MIT -URL: https://%{provider_prefix} -Source0: https://%{provider_prefix}/archive/%{commit}/%{repo}-%{shortcommit}.tar.gz - -# e.g. el6 has ppc64 arch without gcc-go, so EA tag is required -ExclusiveArch: %{?go_arches:%{go_arches}}%{!?go_arches:%{ix86} x86_64 %{arm}} -# If go_compiler is not set to 1, there is no virtual provide. Use golang instead. -BuildRequires: %{?go_compiler:compiler(go-compiler)}%{!?go_compiler:golang} - -%if ! 0%{?with_bundled} -BuildRequires: golang(github.com/codegangsta/negroni) -BuildRequires: golang(github.com/gorilla/mux) -#BuildRequires: golang(gopkg.in/fsnotify.v1) -BuildRequires: golang(github.com/go-fsnotify/fsnotify) -%endif - -%description -%{summary} - -%if 0%{?with_devel} -%package devel -Summary: %{summary} -BuildArch: noarch - -%if 0%{?with_check} && ! 0%{?with_bundled} -%endif - - -Provides: golang(%{import_path}/hook) = %{version}-%{release} - -%description devel -%{summary} - -This package contains library source intended for -building other packages which use import path with -%{import_path} prefix. -%endif - -%if 0%{?with_unit_test} && 0%{?with_devel} -%package unit-test-devel -Summary: Unit tests for %{name} package -%if 0%{?with_check} -#Here comes all BuildRequires: PACKAGE the unit tests -#in %%check section need for running -%endif - -# test subpackage tests code from devel subpackage -Requires: %{name}-devel = %{version}-%{release} - -%description unit-test-devel -%{summary} - -This package contains unit tests for project -providing packages with %{import_path} prefix. -%endif - -%prep -%setup -q -n %{repo}-%{commit} - -%build -mkdir -p src/github.com/adnanh -ln -s ../../../ src/github.com/adnanh/webhook - -%if ! 0%{?with_bundled} -export GOPATH=$(pwd):%{gopath} -%else -export GOPATH=$(pwd):$(pwd)/Godeps/_workspace:%{gopath} -%endif - -%gobuild -o bin/webhook %{import_path} - -%install -install -d -p %{buildroot}%{_bindir} -install -p -m 0755 bin/webhook %{buildroot}%{_bindir} -install -d -p %{buildroot}%{_initrddir} -install -p -m 0755 webhook.init %{buildroot}%{_initrddir}/webhook -install -d -p %{buildroot}%{_sysconfdir}/webhook/ -install -p -m 0640 hooks.json.example %{buildroot}%{_sysconfdir}/webhook/ - -# source codes for building projects -%if 0%{?with_devel} -install -d -p %{buildroot}/%{gopath}/src/%{import_path}/ -echo "%%dir %%{gopath}/src/%%{import_path}/." >> devel.file-list -# find all *.go but no *_test.go files and generate devel.file-list -for file in $(find . -iname "*.go" \! -iname "*_test.go") ; do - echo "%%dir %%{gopath}/src/%%{import_path}/$(dirname $file)" >> devel.file-list - install -d -p %{buildroot}/%{gopath}/src/%{import_path}/$(dirname $file) - cp -pav $file %{buildroot}/%{gopath}/src/%{import_path}/$file - echo "%%{gopath}/src/%%{import_path}/$file" >> devel.file-list -done -%endif - -# testing files for this project -%if 0%{?with_unit_test} && 0%{?with_devel} -install -d -p %{buildroot}/%{gopath}/src/%{import_path}/ -# find all *_test.go files and generate unit-test-devel.file-list -for file in $(find . -iname "*_test.go" -or -iname "hooks.json.example"); do - echo "%%dir %%{gopath}/src/%%{import_path}/$(dirname $file)" >> devel.file-list - install -d -p %{buildroot}/%{gopath}/src/%{import_path}/$(dirname $file) - cp -pav $file %{buildroot}/%{gopath}/src/%{import_path}/$file - echo "%%{gopath}/src/%%{import_path}/$file" >> unit-test-devel.file-list -done -%endif - -%if 0%{?with_devel} -sort -u -o devel.file-list devel.file-list -%endif - -%check -%if 0%{?with_check} && 0%{?with_unit_test} && 0%{?with_devel} -%if ! 0%{?with_bundled} -export GOPATH=%{buildroot}/%{gopath}:%{gopath} -%else -export GOPATH=%{buildroot}/%{gopath}:$(pwd)/Godeps/_workspace:%{gopath} -%endif - -%gotest %{import_path}/hook -%endif - -#define license tag if not already defined -%{!?_licensedir:%global license %doc} - -%files -%license LICENSE -%doc README.md hooks.json.example -%config(noreplace)%{_sysconfdir}/webhook/* -%{_bindir}/webhook -%{_initrddir}/webhook - -%if 0%{?with_devel} -%files devel -f devel.file-list -%license LICENSE -%doc README.md -%dir %{gopath}/src/%{provider}.%{provider_tld}/%{project} -%endif - -%if 0%{?with_unit_test} && 0%{?with_devel} -%files unit-test-devel -f unit-test-devel.file-list -%license LICENSE -%doc README.md -%endif - -%changelog -* Thu Oct 29 2015 Tim Hughes - 0-0.1.git409b441 -- First package for Fedora - - diff --git a/webhook.init b/webhook.init deleted file mode 100644 index 6a14f7c..0000000 --- a/webhook.init +++ /dev/null @@ -1,111 +0,0 @@ -#!/bin/sh -# -# Start the Webhook daemon -# -# chkconfig: 35 82 16 -# description: webhook is a lightweight configurable tool written in Go, that \ -# allows you to easily create HTTP endpoints (hooks) on your server, which \ -# you can use to execute configured commands. - -### BEGIN INIT INFO -# Provides: webhook -# Required-Start: $syslog $local_fs -# Required-Stop: $syslog $local_fs -# Should-Start: 0 1 2 6 -# Should-Stop: 3 5 -# Default-Start: 0 1 2 6 -# Default-Stop: 3 5 -# Short-Description: webhook allows you to easily create HTTP endpoints (hooks) on your server -# Description: webhook allows you to easily create HTTP endpoints (hooks) on your server -### END INIT INFO - -# Source function library. -. /etc/rc.d/init.d/functions - -exec="/usr/bin/webhook" -prog="webhook" -config="/etc/webhook/hooks.json" - -[ -e /etc/sysconfig/$prog ] && . /etc/sysconfig/$prog - - - - -lockfile=/var/lock/subsys/$prog -pidfile=/var/run/$prog.pid - -start() { - [ -x $exec ] || exit 5 - [ -f $config ] || exit 6 - echo -n $"Starting $prog: " - # if not running, start it up here, usually something like "daemon $exec" - ((exec $exec -hooks=$config $OPTIONS &>/dev/null)&) - retval=$? - echo - [ $retval -eq 0 ] && touch $lockfile - return $retval -} - -stop() { - echo -n $"Stopping $prog: " - killproc $prog - retval=$? - echo - [ $retval -eq 0 ] && rm -f $lockfile - return $retval -} - -restart() { - stop - start -} - -reload() { - restart -} - -force_reload() { - restart -} - -rh_status() { - # run checks to determine if the service is running or use generic status - status $prog -} - -rh_status_q() { - rh_status >/dev/null 2>&1 -} - - -case "$1" in - start) - rh_status_q && exit 0 - $1 - ;; - stop) - rh_status_q || exit 0 - $1 - ;; - restart) - $1 - ;; - reload) - rh_status_q || exit 7 - $1 - ;; - force-reload) - force_reload - ;; - status) - rh_status - ;; - condrestart|try-restart) - rh_status_q || exit 0 - restart - ;; - *) - echo $"Usage: $0 {start|stop|status|restart|condrestart|try-restart|reload|force-reload}" - exit 2 -esac -exit $? From 82bba7ba074ac1851d0df979dd694e856d600f2b Mon Sep 17 00:00:00 2001 From: Adnan Hajdarevic Date: Fri, 13 Nov 2015 09:24:17 +0100 Subject: [PATCH 10/10] bump up the version --- webhook.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webhook.go b/webhook.go index 9fdc323..ef2ca65 100644 --- a/webhook.go +++ b/webhook.go @@ -21,7 +21,7 @@ import ( ) const ( - version = "2.3.5" + version = "2.3.6" ) var (