Commit b4780a41 authored by xenolf's avatar xenolf

Added webhook functionality to the git middleware.

The webhook providers reside behind a small interface which determines if
a provider should run. If a provider should run it delegates
responsibility of the request to the provider.
ghdeploy initial commit

Added webhook functionality to the git middleware.
The webhook providers reside behind a small interface which determines if a provider should run. If a provider should run it delegates responsibility of the request to the provider.

Add tests

Remove old implementation

Fix inconsistency with git interval pulling.

Remove '\n' from logging statements and put the initial pull into a startup function
parent 4852f058
......@@ -11,6 +11,7 @@ import (
"github.com/mholt/caddy/middleware"
"github.com/mholt/caddy/middleware/git"
"github.com/mholt/caddy/middleware/git/webhook"
)
// Git configures a new Git service routine.
......@@ -20,13 +21,30 @@ func Git(c *Controller) (middleware.Middleware, error) {
return nil, err
}
c.Startup = append(c.Startup, func() error {
// Start service routine in background
git.Start(repo)
// If a HookUrl is set, we switch to event based pulling.
// Install the url handler
if repo.HookUrl != "" {
// Do a pull right away to return error
return repo.Pull()
})
c.Startup = append(c.Startup, func() error {
return repo.Pull()
})
webhook := &webhook.WebHook{Repo: repo}
return func(next middleware.Handler) middleware.Handler {
webhook.Next = next
return webhook
}, nil
} else {
c.Startup = append(c.Startup, func() error {
// Start service routine in background
git.Start(repo)
// Do a pull right away to return error
return repo.Pull()
})
}
return nil, err
}
......@@ -75,6 +93,17 @@ func gitParse(c *Controller) (*git.Repo, error) {
if t > 0 {
repo.Interval = time.Duration(t) * time.Second
}
case "hook":
if !c.NextArg() {
return nil, c.ArgErr()
}
repo.HookUrl = c.Val()
// optional secret for validation
if c.NextArg() {
repo.HookSecret = c.Val()
}
case "then":
thenArgs := c.RemainingArgs()
if len(thenArgs) == 0 {
......
......@@ -59,6 +59,9 @@ type Repo struct {
lastPull time.Time // time of the last successful pull
lastCommit string // hash for the most recent commit
sync.Mutex
HookUrl string // url to listen on for webhooks
HookSecret string // secret to validate hooks
}
// Pull attempts a git clone.
......
package webhook
import (
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"github.com/mholt/caddy/middleware/git"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
)
type GithubHook struct{}
type ghRelease struct {
Action string `json:"action"`
Release struct {
TagName string `json:"tag_name"`
Name interface{} `json:"name"`
} `json:"release"`
}
type ghPush struct {
Ref string `json:"ref"`
}
// Logger is used to log errors; if nil, the default log.Logger is used.
var Logger *log.Logger
// logger is an helper function to retrieve the available logger
func logger() *log.Logger {
if Logger == nil {
Logger = log.New(os.Stderr, "", log.LstdFlags)
}
return Logger
}
func (g GithubHook) DoesHandle(h http.Header) bool {
userAgent := h.Get("User-Agent")
// GitHub always uses a user-agent like "GitHub-Hookshot/<id>"
if userAgent != "" && strings.HasPrefix(userAgent, "GitHub-Hookshot") {
return true
}
return false
}
func (g GithubHook) Handle(w http.ResponseWriter, r *http.Request, repo *git.Repo) (int, error) {
if r.Method != "POST" {
return http.StatusMethodNotAllowed, errors.New("The request had an invalid method.")
}
// read full body - required for signature
body, err := ioutil.ReadAll(r.Body)
err = g.handleSignature(r, body, repo.HookSecret)
if err != nil {
return http.StatusBadRequest, err
}
event := r.Header.Get("X-Github-Event")
if event == "" {
return http.StatusBadRequest, errors.New("The 'X-Github-Event' header is required but was missing.")
}
switch event {
case "ping":
w.Write([]byte("pong"))
case "push":
err := g.handlePush(body, repo)
if err != nil {
return http.StatusBadRequest, err
}
case "release":
err := g.handleRelease(body, repo)
if err != nil {
return http.StatusBadRequest, err
}
// return 400 if we do not handle the event type.
// This is to visually show the user a configuration error in the GH ui.
default:
return http.StatusBadRequest, nil
}
return http.StatusOK, nil
}
// Check for an optional signature in the request
// if it is signed, verify the signature.
func (g GithubHook) handleSignature(r *http.Request, body []byte, secret string) error {
signature := r.Header.Get("X-Hub-Signature")
if signature != "" {
if secret == "" {
logger().Print("Unable to verify request signature. Secret not set in caddyfile!")
} else {
mac := hmac.New(sha1.New, []byte(secret))
mac.Write(body)
expectedMac := hex.EncodeToString(mac.Sum(nil))
if signature[5:] != expectedMac {
return errors.New("Could not verify request signature. The signature is invalid!")
}
}
}
return nil
}
func (g GithubHook) handlePush(body []byte, repo *git.Repo) error {
var push ghPush
err := json.Unmarshal(body, &push)
if err != nil {
return err
}
// extract the branch being pushed from the ref string
// and if it matches with our locally tracked one, pull.
refSlice := strings.Split(push.Ref, "/")
if len(refSlice) != 3 {
return errors.New("The push request contained an invalid reference string.")
}
branch := refSlice[2]
if branch == repo.Branch {
logger().Print("Received pull notification for the tracking branch, updating...")
repo.Pull()
}
return nil
}
func (g GithubHook) handleRelease(body []byte, repo *git.Repo) error {
var release ghRelease
err := json.Unmarshal(body, &release)
if err != nil {
return err
}
if release.Release.TagName == "" {
return errors.New("The release request contained an invalid TagName.")
}
logger().Printf("Received new release '%s'. -> Updating local repository to this release.", release.Release.Name)
// Update the local branch to the release tag name
// this will pull the release tag.
repo.Branch = release.Release.TagName
repo.Pull()
return nil
}
package webhook
import (
"bytes"
"github.com/mholt/caddy/middleware/git"
"net/http"
"net/http/httptest"
"testing"
)
func TestGithubDeployPush(t *testing.T) {
repo := &git.Repo{Branch: "master", HookUrl: "/github_deploy", HookSecret: "supersecret"}
ghHook := GithubHook{}
for i, test := range []struct {
body string
event string
responseBody string
code int
}{
{"", "", "", 400},
{"", "push", "", 400},
{pushBodyOther, "push", "", 200},
{pushBodyPartial, "push", "", 400},
{"", "release", "", 400},
{"", "ping", "pong", 200},
} {
req, err := http.NewRequest("POST", "/github_deploy", bytes.NewBuffer([]byte(test.body)))
if err != nil {
t.Fatalf("Test %v: Could not create HTTP request: %v", i, err)
}
if test.event != "" {
req.Header.Add("X-Github-Event", test.event)
}
rec := httptest.NewRecorder()
code, err := ghHook.Handle(rec, req, repo)
if code != test.code {
t.Errorf("Test %d: Expected response code to be %d but was %d", i, test.code, code)
}
if rec.Body.String() != test.responseBody {
t.Errorf("Test %d: Expected response body to be '%v' but was '%v'", i, test.responseBody, rec.Body.String())
}
}
}
var pushBodyPartial = `
{
"ref": ""
}
`
var pushBodyOther = `
{
"ref": "refs/heads/some-other-branch"
}
`
package webhook
import (
"github.com/mholt/caddy/middleware"
"github.com/mholt/caddy/middleware/git"
"net/http"
)
// Middleware for handling web hooks of git providers
type WebHook struct {
Repo *git.Repo
Next middleware.Handler
}
// Interface for specific providers to implement.
type hookHandler interface {
DoesHandle(http.Header) bool
Handle(w http.ResponseWriter, r *http.Request, repo *git.Repo) (int, error)
}
// Slice of all registered hookHandlers.
// Register new hook handlers here!
var handlers = []hookHandler{
GithubHook{},
}
// ServeHTTP implements the middlware.Handler interface.
func (h WebHook) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
if r.URL.Path == h.Repo.HookUrl {
for _, handler := range handlers {
// if a handler indicates it does handle the request,
// we do not try other handlers. Only one handler ever
// handles a specific request.
if handler.DoesHandle(r.Header) {
return handler.Handle(w, r, h.Repo)
}
}
}
return h.Next.ServeHTTP(w, r)
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment