diff --git a/charts/captain-hook/templates/serviceaccount.yaml b/charts/captain-hook/templates/serviceaccount.yaml index 3ed0f2e..2295a54 100644 --- a/charts/captain-hook/templates/serviceaccount.yaml +++ b/charts/captain-hook/templates/serviceaccount.yaml @@ -27,6 +27,7 @@ rules: - "list" - "create" - "delete" + - "update" --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding diff --git a/go.mod b/go.mod index 6cc488c..cbec727 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.15 require ( github.com/cenkalti/backoff v2.2.1+incompatible + github.com/google/uuid v1.2.0 // indirect github.com/gorilla/mux v1.8.0 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.8.0 diff --git a/go.sum b/go.sum index 3ba6638..67386a7 100644 --- a/go.sum +++ b/go.sum @@ -148,6 +148,8 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gnostic v0.4.1 h1:DLJCy1n/vrD4HPjOvYcT8aYQXpPIzoRZONaYwyycI+I= diff --git a/pkg/hook/handler.go b/pkg/hook/handler.go index c2a51f0..c60dff0 100644 --- a/pkg/hook/handler.go +++ b/pkg/hook/handler.go @@ -1,8 +1,6 @@ package hook import ( - "bytes" - "crypto/tls" "fmt" "io" "io/ioutil" @@ -13,10 +11,8 @@ import ( "github.com/garethjevans/captain-hook/pkg/store" - "github.com/cenkalti/backoff" "github.com/garethjevans/captain-hook/pkg/version" "github.com/gorilla/mux" - "github.com/pkg/errors" "github.com/sirupsen/logrus" ) @@ -25,30 +21,29 @@ const ( ) var ( - defaultMaxRetryDuration = 45 * time.Second + defaultMaxRetryDuration = 10 * time.Second ) // Options struct containing all options. type Options struct { - Path string - Version string - ForwardURL string - InsecureRelay bool - client *http.Client - maxRetryDuration *time.Duration - store store.Store + Path string + Version string + ForwardURL string + handler *handler } // NewHook create a new hook handler. func NewHook() (*Options, error) { logrus.Infof("creating new webhook listener") return &Options{ - Path: os.Getenv("HOOK_PATH"), - ForwardURL: os.Getenv("FORWARD_URL"), - InsecureRelay: os.Getenv("INSECURE_RELAY") == "true", - Version: version.Version, - maxRetryDuration: &defaultMaxRetryDuration, - store: store.NewKubernetesStore(), + Path: os.Getenv("HOOK_PATH"), + Version: version.Version, + ForwardURL: os.Getenv("FORWARD_URL"), + handler: &handler{ + InsecureRelay: os.Getenv("INSECURE_RELAY") == "true", + maxRetryDuration: &defaultMaxRetryDuration, + store: store.NewKubernetesStore(), + }, }, nil } @@ -130,8 +125,8 @@ func (o *Options) handleWebHookRequests(w http.ResponseWriter, r *http.Request) func (o *Options) onGeneralHook(bodyBytes []byte, headers http.Header) error { // Set a default max retry duration of 30 seconds if it's not set. - if o.maxRetryDuration == nil { - o.maxRetryDuration = &defaultMaxRetryDuration + if o.handler.maxRetryDuration == nil { + o.handler.maxRetryDuration = &defaultMaxRetryDuration } githubDeliveryEvent := headers.Get("X-Github-Delivery") @@ -141,16 +136,15 @@ func (o *Options) onGeneralHook(bodyBytes []byte, headers http.Header) error { // log.WithError(err).Errorf("unable to decode hmac") //} - // store in db - err := o.store.StoreHook(o.ForwardURL, string(bodyBytes), headers) - if err != nil { - logrus.Errorf("failed to store webhook: %s", err) - return err + hook := Hook{ + ForwardURL: o.ForwardURL, + Body: bodyBytes, + Headers: headers, } - err = o.retryWebhookDelivery(o.ForwardURL, bodyBytes, headers) + err := o.handler.Handle(&hook) if err != nil { - logrus.Errorf("failed to deliver webhook after %s, %s", o.maxRetryDuration, err) + logrus.Errorf("failed to deliver webhook after %s, %s", o.handler.maxRetryDuration, err) return err } @@ -158,78 +152,3 @@ func (o *Options) onGeneralHook(bodyBytes []byte, headers http.Header) error { return nil } - -func (o *Options) retryWebhookDelivery(forwardURL string, bodyBytes []byte, header http.Header) error { - f := func() error { - logrus.Debugf("relaying %s", string(bodyBytes)) - //g := hmac.NewGenerator("sha256", decodedHmac) - //signature := g.HubSignature(bodyBytes) - - var httpClient *http.Client - - if o.client != nil { - httpClient = o.client - } else { - if o.InsecureRelay { - // #nosec G402 - tr := &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - } - - httpClient = &http.Client{Transport: tr} - } else { - httpClient = &http.Client{} - } - } - - req, err := http.NewRequest("POST", forwardURL, bytes.NewReader(bodyBytes)) - if err != nil { - return err - } - req.Header = header - - // does this need to be resigned? - //req.Header.Add("X-Hub-Signature", signature) - - resp, err := httpClient.Do(req) - if err != nil { - return err - } - - logrus.Infof("got resp code %d from url '%s'", resp.StatusCode, forwardURL) - - // If we got a 500, check if it's got the "repository not configured" string in the body. If so, we retry. - if resp.StatusCode == 500 { - respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 10000000)) - if err != nil { - return backoff.Permanent(errors.Wrap(err, "parsing resp.body")) - } - err = resp.Body.Close() - if err != nil { - return backoff.Permanent(errors.Wrap(err, "closing resp.body")) - } - logrus.Infof("got error respBody '%s'", string(respBody)) - } - - // If we got anything other than a 2xx, retry as well. - // We're leaving this distinct from the "not configured" behavior in case we want to resurrect that later. (apb) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return errors.Errorf("%s not available, error was %s", req.URL.String(), resp.Status) - } - - // And finally, if we haven't gotten any errors, just return nil because we're good. - return nil - } - - bo := backoff.NewExponentialBackOff() - // Try again after 2/4/8/... seconds if necessary, for up to 90 seconds, may take up to a minute to for the secret to replicate - bo.InitialInterval = 2 * time.Second - bo.MaxElapsedTime = 2 * (*o.maxRetryDuration) - bo.Reset() - - return backoff.RetryNotify(f, bo, func(e error, t time.Duration) { - logrus.Infof("webhook relaying failed: %s, backing off for %s", e, t) - }) -} diff --git a/pkg/hook/handler_test.go b/pkg/hook/handler_test.go index 05905dd..7f9b0a9 100644 --- a/pkg/hook/handler_test.go +++ b/pkg/hook/handler_test.go @@ -74,8 +74,10 @@ func TestWebhooks(t *testing.T) { retryDuration := 5 * time.Second handler := Options{ - maxRetryDuration: &retryDuration, - store: store.NewLoggingStore(), + handler: &handler{ + maxRetryDuration: &retryDuration, + store: store.NewLoggingStore(), + }, } attempts := 0 @@ -89,7 +91,7 @@ func TestWebhooks(t *testing.T) { defer server.Close() handler.ForwardURL = server.URL - handler.client = server.Client() + handler.handler.client = server.Client() w := NewFakeRespone(t) handler.handleWebHookRequests(w, r) diff --git a/pkg/hook/store_handler.go b/pkg/hook/store_handler.go new file mode 100644 index 0000000..adfa4aa --- /dev/null +++ b/pkg/hook/store_handler.go @@ -0,0 +1,126 @@ +package hook + +import ( + "bytes" + "crypto/tls" + "io" + "io/ioutil" + "net/http" + "time" + + "github.com/cenkalti/backoff" + "github.com/garethjevans/captain-hook/pkg/store" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +type handler struct { + ForwardURL string + InsecureRelay bool + client *http.Client + maxRetryDuration *time.Duration + store store.Store +} + +func (h *handler) Handle(hook *Hook) error { + // need to have a think about that the logic would be here. + hookID, err := h.store.StoreHook(hook.ForwardURL, hook.Body, hook.Headers) + if err != nil { + return err + } + + hook.ID = hookID + + // attempt to send + err = h.send(hook.ForwardURL, hook.Body, hook.Headers) + if err != nil { + // if failed, mark as failed with the error as the message + err = h.store.Error(hookID, err.Error()) + if err != nil { + return err + } + } + + // if success, mark as successful, + err = h.store.Success(hookID) + if err != nil { + return err + } + + return nil +} + +func (h *handler) send(forwardURL string, bodyBytes []byte, header http.Header) error { + f := func() error { + logrus.Debugf("relaying %s", string(bodyBytes)) + //g := hmac.NewGenerator("sha256", decodedHmac) + //signature := g.HubSignature(bodyBytes) + + var httpClient *http.Client + + if h.client != nil { + httpClient = h.client + } else { + if h.InsecureRelay { + // #nosec G402 + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + + httpClient = &http.Client{Transport: tr} + } else { + httpClient = &http.Client{} + } + } + + req, err := http.NewRequest("POST", forwardURL, bytes.NewReader(bodyBytes)) + if err != nil { + return err + } + req.Header = header + + // does this need to be resigned? + //req.Header.Add("X-Hub-Signature", signature) + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + + logrus.Infof("got resp code %d from url '%s'", resp.StatusCode, forwardURL) + + // If we got a 500, check if it's got the "repository not configured" string in the body. If so, we retry. + if resp.StatusCode == 500 { + respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 10000000)) + if err != nil { + return backoff.Permanent(errors.Wrap(err, "parsing resp.body")) + } + err = resp.Body.Close() + if err != nil { + return backoff.Permanent(errors.Wrap(err, "closing resp.body")) + } + logrus.Infof("got error respBody '%s'", string(respBody)) + } + + // If we got anything other than a 2xx, retry as well. + // We're leaving this distinct from the "not configured" behavior in case we want to resurrect that later. (apb) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return errors.Errorf("%s not available, error was %s", req.URL.String(), resp.Status) + } + + // And finally, if we haven't gotten any errors, just return nil because we're good. + return nil + } + + bo := backoff.NewExponentialBackOff() + // Try again after 2/4/8/... seconds if necessary, for up to 90 seconds, may take up to a minute to for the secret to replicate + bo.InitialInterval = 2 * time.Second + bo.MaxElapsedTime = 2 * (*h.maxRetryDuration) + bo.Reset() + + return backoff.RetryNotify(f, bo, func(e error, t time.Duration) { + logrus.Infof("webhook relaying failed: %s, backing off for %s", e, t) + }) +} diff --git a/pkg/hook/types.go b/pkg/hook/types.go new file mode 100644 index 0000000..d2d4cc3 --- /dev/null +++ b/pkg/hook/types.go @@ -0,0 +1,11 @@ +package hook + +// Hook struct to hold everything related to a hook. +type Hook struct { + ID string + ForwardURL string + Headers map[string][]string + Body []byte + // Status? State? + +} diff --git a/pkg/store/interface.go b/pkg/store/interface.go index 93a1a88..66f873a 100644 --- a/pkg/store/interface.go +++ b/pkg/store/interface.go @@ -1,9 +1,13 @@ package store -import "net/http" - // Store interface to implement a storage strategy. type Store interface { // StoreHook stores a webhook in the store. - StoreHook(forwardURL string, body string, header http.Header) error + StoreHook(forwardURL string, body []byte, headers map[string][]string) (string, error) + + // Success marks a hook as successful. + Success(id string) error + + // Marks a hook as error, with the error message. + Error(id string, message string) error } diff --git a/pkg/store/kubernetes_store.go b/pkg/store/kubernetes_store.go index 6a4f780..c1cfaed 100644 --- a/pkg/store/kubernetes_store.go +++ b/pkg/store/kubernetes_store.go @@ -3,7 +3,6 @@ package store import ( "context" "io/ioutil" - "net/http" "os" "strings" @@ -26,10 +25,10 @@ func NewKubernetesStore() Store { return &kubernetesStore{config: config} } -func (s *kubernetesStore) StoreHook(forwardURL string, body string, header http.Header) error { +func (s *kubernetesStore) StoreHook(forwardURL string, body []byte, header map[string][]string) (string, error) { cs, err := v1alpha1.NewForConfig(s.config) if err != nil { - return err + return "", err } logrus.Debugf("got clientset %s", cs) @@ -39,7 +38,7 @@ func (s *kubernetesStore) StoreHook(forwardURL string, body string, header http. }, Spec: v1alpha12.HookSpec{ ForwardURL: forwardURL, - Body: body, + Body: string(body), Headers: header, }, Status: v1alpha12.HookStatus{ @@ -50,14 +49,66 @@ func (s *kubernetesStore) StoreHook(forwardURL string, body string, header http. logrus.Debugf("persisting hook %+v", hook) namespace, err := s.namespace() if err != nil { - return err + return "", err } created, err := cs.Hooks(namespace).Create(context.TODO(), &hook, v1.CreateOptions{}) if err != nil { - return err + return "", err } logrus.Debugf("persisted hook %+v", created) + return created.ObjectMeta.Name, nil +} + +func (s *kubernetesStore) Success(id string) error { + cs, err := v1alpha1.NewForConfig(s.config) + if err != nil { + return err + } + logrus.Debugf("got clientset %s", cs) + + namespace, err := s.namespace() + if err != nil { + return err + } + hook, err := cs.Hooks(namespace).Get(context.TODO(), id, v1.GetOptions{}) + if err != nil { + return err + } + + hook.Status.Status = v1alpha12.HookStatusTypeSuccess + hook.Status.Message = "" + + _, err = cs.Hooks(namespace).Update(context.TODO(), hook, v1.UpdateOptions{}) + if err != nil { + return err + } + return nil +} + +func (s *kubernetesStore) Error(id string, message string) error { + cs, err := v1alpha1.NewForConfig(s.config) + if err != nil { + return err + } + logrus.Debugf("got clientset %s", cs) + + namespace, err := s.namespace() + if err != nil { + return err + } + hook, err := cs.Hooks(namespace).Get(context.TODO(), id, v1.GetOptions{}) + if err != nil { + return err + } + + hook.Status.Status = v1alpha12.HookStatusTypeFailed + hook.Status.Message = message + + _, err = cs.Hooks(namespace).Update(context.TODO(), hook, v1.UpdateOptions{}) + if err != nil { + return err + } return nil } diff --git a/pkg/store/logging_store.go b/pkg/store/logging_store.go index eec9b5d..87a2dd1 100644 --- a/pkg/store/logging_store.go +++ b/pkg/store/logging_store.go @@ -1,8 +1,7 @@ package store import ( - "net/http" - + "github.com/google/uuid" "github.com/sirupsen/logrus" ) @@ -13,8 +12,18 @@ func NewLoggingStore() Store { return &loggingStore{} } -func (s *loggingStore) StoreHook(forwardURL string, body string, header http.Header) error { +func (s *loggingStore) StoreHook(forwardURL string, body []byte, header map[string][]string) (string, error) { logrus.Debugf("storing hook to %s", forwardURL) + uuid := uuid.New() + return uuid.String(), nil +} + +func (s *loggingStore) Success(id string) error { + logrus.Infof("hook is successful: %s", id) + return nil +} +func (s *loggingStore) Error(id string, message string) error { + logrus.Errorf("hook is errored: %s, %s", message, id) return nil }