forked from tektoncd/triggers
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a HTTP handler for core interceptors
This commit packages the 4 core interceptors into a single HTTP server. Each interceptor is available at a different path e.g. /cel for CEL etc. This does not wire up the implementation of the server into the EventListener which will be done in a future commit. Part of tektoncd#271 Signed-off-by: Dibyo Mukherjee <dibyo@google.com>
- Loading branch information
Showing
2 changed files
with
279 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,123 @@ | ||
package server | ||
|
||
import ( | ||
"bytes" | ||
"context" | ||
"encoding/json" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"strings" | ||
"time" | ||
|
||
"github.com/tektoncd/triggers/pkg/interceptors/bitbucket" | ||
"github.com/tektoncd/triggers/pkg/interceptors/cel" | ||
"github.com/tektoncd/triggers/pkg/interceptors/github" | ||
"github.com/tektoncd/triggers/pkg/interceptors/gitlab" | ||
|
||
"github.com/tektoncd/triggers/pkg/apis/triggers/v1alpha1" | ||
"go.uber.org/zap" | ||
"k8s.io/client-go/kubernetes" | ||
) | ||
|
||
type Server struct { | ||
KubeClient kubernetes.Interface | ||
Logger *zap.SugaredLogger | ||
interceptors map[string]v1alpha1.InterceptorInterface | ||
} | ||
|
||
func NewWithCoreInterceptors(k kubernetes.Interface, l *zap.SugaredLogger) *Server { | ||
i := map[string]v1alpha1.InterceptorInterface{ | ||
"bitbucket": bitbucket.NewInterceptor(k, l).(v1alpha1.InterceptorInterface), | ||
"cel": cel.NewInterceptor(k, l).(v1alpha1.InterceptorInterface), | ||
"github": github.NewInterceptor(k, l).(v1alpha1.InterceptorInterface), | ||
"gitlab": gitlab.NewInterceptor(k, l).(v1alpha1.InterceptorInterface), | ||
} | ||
s := Server{ | ||
KubeClient: k, | ||
Logger: l, | ||
interceptors: i, | ||
} | ||
return &s | ||
} | ||
|
||
func (is *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
b, err := is.ExecuteInterceptor(r) | ||
if err != nil { | ||
switch e := err.(type) { | ||
case Error: | ||
is.Logger.Infof("HTTP %d - %s", e.Status(), e) | ||
http.Error(w, e.Error(), e.Status()) | ||
default: | ||
is.Logger.Errorf("Non Status Error: %s", err) | ||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) | ||
} | ||
} | ||
w.Header().Add("Content-Type", "application/json") | ||
if _, err = w.Write(b); err != nil { | ||
is.Logger.Errorf("failed to write response: %s", err) | ||
} | ||
} | ||
|
||
// Error represents a handler error. It provides methods for a HTTP status | ||
// code and embeds the built-in error interface. | ||
type Error interface { | ||
error | ||
Status() int | ||
} | ||
|
||
// HTTPError represents an error with an associated HTTP status code. | ||
type HTTPError struct { | ||
Code int | ||
Err error | ||
} | ||
|
||
// Allows HTTPError to satisfy the error interface. | ||
func (se HTTPError) Error() string { | ||
return se.Err.Error() | ||
} | ||
|
||
// Returns our HTTP status code. | ||
func (se HTTPError) Status() int { | ||
return se.Code | ||
} | ||
|
||
func badRequest(err error) HTTPError { | ||
return HTTPError{Code: http.StatusBadRequest, Err: err} | ||
} | ||
|
||
func internal(err error) HTTPError { | ||
return HTTPError{Code: http.StatusInternalServerError, Err: err} | ||
} | ||
|
||
// Refactor using a service error: https://blog.questionable.services/article/http-handler-error-handling-revisited/ | ||
func (is *Server) ExecuteInterceptor(r *http.Request) ([]byte, error) { | ||
var ii v1alpha1.InterceptorInterface | ||
|
||
// Find correct interceptor | ||
ii, ok := is.interceptors[strings.TrimPrefix(strings.ToLower(r.URL.Path), "/")] | ||
if !ok { | ||
return nil, badRequest(fmt.Errorf("path did not match any interceptors")) | ||
} | ||
|
||
// Create a context | ||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) | ||
defer cancel() | ||
|
||
var body bytes.Buffer | ||
defer r.Body.Close() | ||
if _, err := io.Copy(&body, r.Body); err != nil { | ||
return nil, internal(fmt.Errorf("failed to read body: %w", err)) | ||
} | ||
var ireq v1alpha1.InterceptorRequest | ||
if err := json.Unmarshal(body.Bytes(), &ireq); err != nil { | ||
return nil, badRequest(fmt.Errorf("failed to parse body as InterceptorRequest: %w", err)) | ||
} | ||
// Decorate ctx with eventID | ||
iresp := ii.Process(ctx, &ireq) | ||
respBytes, err := json.Marshal(iresp) | ||
if err != nil { | ||
return nil, internal(err) | ||
} | ||
return respBytes, nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,156 @@ | ||
package server | ||
|
||
import ( | ||
"bytes" | ||
"encoding/json" | ||
"fmt" | ||
"io/ioutil" | ||
"net/http" | ||
"net/http/httptest" | ||
"strings" | ||
"testing" | ||
|
||
"google.golang.org/grpc/codes" | ||
|
||
"github.com/google/go-cmp/cmp" | ||
|
||
"github.com/tektoncd/triggers/pkg/apis/triggers/v1alpha1" | ||
"go.uber.org/zap/zaptest" | ||
fakekubeclient "knative.dev/pkg/client/injection/kube/client/fake" | ||
rtesting "knative.dev/pkg/reconciler/testing" | ||
) | ||
|
||
func TestServer_ServeHTTP(t *testing.T) { | ||
// includes error cases when the error is from interceptor processing. | ||
|
||
testTriggerContext := &v1alpha1.TriggerContext{ | ||
EventURL: "http://something", | ||
EventID: "abcde", | ||
TriggerID: "namespaces/default/triggers/test-trigger", | ||
} | ||
tests := []struct { | ||
name string | ||
path string | ||
req *v1alpha1.InterceptorRequest | ||
want *v1alpha1.InterceptorResponse | ||
}{{ | ||
name: "valid request that should continue", | ||
path: "/cel", | ||
req: &v1alpha1.InterceptorRequest{ | ||
Body: json.RawMessage(`{}`), | ||
Header: map[string][]string{ | ||
"X-Event-Type": {"push"}, | ||
}, | ||
InterceptorParams: map[string]interface{}{ | ||
"filter": "header.canonical(\"X-Event-Type\") == \"push\"", | ||
}, | ||
Context: testTriggerContext, | ||
}, | ||
want: &v1alpha1.InterceptorResponse{ | ||
Continue: true, | ||
}, | ||
}, { | ||
name: "valid request that should not continue", | ||
path: "/cel", | ||
req: &v1alpha1.InterceptorRequest{ | ||
Body: json.RawMessage(`{}`), | ||
Header: map[string][]string{ | ||
"X-Event-Type": {"push"}, | ||
}, | ||
InterceptorParams: map[string]interface{}{ | ||
"filter": "header.canonical(\"X-Event-Type\") == \"pull\"", | ||
}, | ||
Context: testTriggerContext, | ||
}, | ||
want: &v1alpha1.InterceptorResponse{ | ||
Continue: false, | ||
Status: v1alpha1.Status{ | ||
Code: codes.FailedPrecondition, | ||
Message: `expression header.canonical("X-Event-Type") == "pull" did not return true`, | ||
}, | ||
}, | ||
}} | ||
for _, tc := range tests { | ||
t.Run(tc.name, func(t *testing.T) { | ||
logger := zaptest.NewLogger(t) | ||
ctx, _ := rtesting.SetupFakeContext(t) | ||
kubeClient := fakekubeclient.Get(ctx) | ||
|
||
server := NewWithCoreInterceptors(kubeClient, logger.Sugar()) | ||
body, err := json.Marshal(tc.req) | ||
if err != nil { | ||
t.Fatalf("Failed to marshal errors ") | ||
} | ||
req := httptest.NewRequest("POST", fmt.Sprintf("http://example.com%s", tc.path), bytes.NewBuffer(body)) | ||
w := httptest.NewRecorder() | ||
server.ServeHTTP(w, req) | ||
resp := w.Result() | ||
if resp.StatusCode != http.StatusOK { | ||
t.Fatalf("ServeHTTP() expected statusCode 200 but got: %d", resp.StatusCode) | ||
} | ||
if resp.Header.Get("Content-Type") != "application/json" { | ||
t.Fatalf("ServeHTTP() expected Content-Type header to be application/json but got: %s", resp.Header.Get("Content-Type")) | ||
} | ||
|
||
respBody, _ := ioutil.ReadAll(resp.Body) | ||
defer resp.Body.Close() | ||
got := v1alpha1.InterceptorResponse{} | ||
if err := json.Unmarshal(respBody, &got); err != nil { | ||
t.Fatalf("ServeHTTP() failed to unmarshal response into struct: %v", err) | ||
} | ||
if diff := cmp.Diff(tc.want, &got); diff != "" { | ||
t.Fatalf("ServeHTTP() response did not match expected. Diff (-want/+got): %s", diff) | ||
} | ||
}) | ||
} | ||
|
||
} | ||
|
||
// Tests unexpected error cases where interceptor processing does not happen. | ||
func TestServer_ServeHTTP_Error(t *testing.T) { | ||
tests := []struct { | ||
name string | ||
path string | ||
req []byte | ||
wantResponseCode int | ||
wantResponseBody string | ||
}{{ | ||
name: "bad path", | ||
path: "/invalid", | ||
req: json.RawMessage(`{}`), | ||
wantResponseCode: 400, | ||
wantResponseBody: "path did not match any interceptors", | ||
}, { | ||
name: "invalid body", | ||
path: "/cel", | ||
req: json.RawMessage(`{}`), | ||
wantResponseCode: 400, | ||
wantResponseBody: "failed to parse body as InterceptorRequest", | ||
}} | ||
for _, tc := range tests { | ||
t.Run(tc.name, func(t *testing.T) { | ||
logger := zaptest.NewLogger(t) | ||
ctx, _ := rtesting.SetupFakeContext(t) | ||
kubeClient := fakekubeclient.Get(ctx) | ||
|
||
server := NewWithCoreInterceptors(kubeClient, logger.Sugar()) | ||
body, err := json.Marshal(tc.req) | ||
if err != nil { | ||
t.Fatalf("Failed to marshal errors ") | ||
} | ||
req := httptest.NewRequest("POST", fmt.Sprintf("http://example.com%s", tc.path), bytes.NewBuffer(body)) | ||
w := httptest.NewRecorder() | ||
server.ServeHTTP(w, req) | ||
resp := w.Result() | ||
if resp.StatusCode != tc.wantResponseCode { | ||
t.Fatalf("ServeHTTP() expected statusCode %d but got: %d", tc.wantResponseCode, resp.StatusCode) | ||
} | ||
|
||
respBody, _ := ioutil.ReadAll(resp.Body) | ||
defer resp.Body.Close() | ||
if !strings.Contains(string(respBody), tc.wantResponseBody) { | ||
t.Fatalf("ServeHTTP() expected response to contain : %s \n but got %s: ", tc.wantResponseBody, string(respBody)) | ||
} | ||
}) | ||
} | ||
} |