forked from TrueCloudLab/distribution
9f0c8d6616
This changeset implements webhook notification endpoints for dispatching registry events. Repository instances can be decorated by a listener that converts calls into context-aware events, using a bridge. Events generated in the bridge are written to a sink. Implementations of sink include a broadcast and endpoint sink which can be used to configure event dispatch. Endpoints represent a webhook notification target, with queueing and retries built in. They can be added to a Broadcaster, which is a simple sink that writes a block of events to several sinks, to provide a complete dispatch mechanism. The main caveat to the current approach is that all unsent notifications are inmemory. Best effort is made to ensure that notifications are not dropped, to the point where queues may back up on faulty endpoints. If the endpoint is fixed, the events will be retried and all messages will go through. Internally, this functionality is all made up of Sink objects. The queuing functionality is implemented with an eventQueue sink and retries are implemented with retryingSink. Replacing the inmemory queuing with something persistent should be as simple as replacing broadcaster with a remote queue and that sets up the sinks to be local workers listening to that remote queue. Metrics are kept for each endpoint and exported via expvar. This may not be a permanent appraoch but should provide enough information for troubleshooting notification problems. Signed-off-by: Stephen J Day <stephen.day@docker.com>
155 lines
3.8 KiB
Go
155 lines
3.8 KiB
Go
package notifications
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strconv"
|
|
"testing"
|
|
)
|
|
|
|
// TestHTTPSink mocks out an http endpoint and notifies it under a couple of
|
|
// conditions, ensuring correct behavior.
|
|
func TestHTTPSink(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
if r.Method != "POST" {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
t.Fatalf("unexpected request method: %v", r.Method)
|
|
return
|
|
}
|
|
|
|
// Extract the content type and make sure it matches
|
|
contentType := r.Header.Get("Content-Type")
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
t.Fatalf("error parsing media type: %v, contenttype=%q", err, contentType)
|
|
return
|
|
}
|
|
|
|
if mediaType != EventsMediaType {
|
|
w.WriteHeader(http.StatusUnsupportedMediaType)
|
|
t.Fatalf("incorrect media type: %q != %q", mediaType, EventsMediaType)
|
|
return
|
|
}
|
|
|
|
var envelope Envelope
|
|
dec := json.NewDecoder(r.Body)
|
|
if err := dec.Decode(&envelope); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
t.Fatalf("error decoding request body: %v", err)
|
|
return
|
|
}
|
|
|
|
// Let caller choose the status
|
|
status, err := strconv.Atoi(r.FormValue("status"))
|
|
if err != nil {
|
|
t.Logf("error parsing status: %v", err)
|
|
|
|
// May just be empty, set status to 200
|
|
status = http.StatusOK
|
|
}
|
|
|
|
w.WriteHeader(status)
|
|
}))
|
|
|
|
metrics := newSafeMetrics()
|
|
sink := newHTTPSink(server.URL, 0, nil,
|
|
&endpointMetricsHTTPStatusListener{safeMetrics: metrics})
|
|
|
|
var expectedMetrics EndpointMetrics
|
|
expectedMetrics.Statuses = make(map[string]int)
|
|
|
|
for _, tc := range []struct {
|
|
events []Event // events to send
|
|
url string
|
|
failure bool // true if there should be a failure.
|
|
statusCode int // if not set, no status code should be incremented.
|
|
}{
|
|
{
|
|
statusCode: http.StatusOK,
|
|
events: []Event{
|
|
createTestEvent("push", "library/test", "manifest")},
|
|
},
|
|
{
|
|
statusCode: http.StatusOK,
|
|
events: []Event{
|
|
createTestEvent("push", "library/test", "manifest"),
|
|
createTestEvent("push", "library/test", "layer"),
|
|
createTestEvent("push", "library/test", "layer"),
|
|
},
|
|
},
|
|
{
|
|
statusCode: http.StatusTemporaryRedirect,
|
|
},
|
|
{
|
|
statusCode: http.StatusBadRequest,
|
|
failure: true,
|
|
},
|
|
{
|
|
// Case where connection never goes through.
|
|
url: "http://shoudlntresolve/",
|
|
failure: true,
|
|
},
|
|
} {
|
|
|
|
if tc.failure {
|
|
expectedMetrics.Failures += len(tc.events)
|
|
} else {
|
|
expectedMetrics.Successes += len(tc.events)
|
|
}
|
|
|
|
if tc.statusCode > 0 {
|
|
expectedMetrics.Statuses[fmt.Sprintf("%d %s", tc.statusCode, http.StatusText(tc.statusCode))] += len(tc.events)
|
|
}
|
|
|
|
url := tc.url
|
|
if url == "" {
|
|
url = server.URL + "/"
|
|
}
|
|
// setup endpoint to respond with expected status code.
|
|
url += fmt.Sprintf("?status=%v", tc.statusCode)
|
|
sink.url = url
|
|
|
|
t.Logf("testcase: %v, fail=%v", url, tc.failure)
|
|
// Try a simple event emission.
|
|
err := sink.Write(tc.events...)
|
|
|
|
if !tc.failure {
|
|
if err != nil {
|
|
t.Fatalf("unexpected error send event: %v", err)
|
|
}
|
|
} else {
|
|
if err == nil {
|
|
t.Fatalf("the endpoint should have rejected the request")
|
|
}
|
|
}
|
|
|
|
if !reflect.DeepEqual(metrics.EndpointMetrics, expectedMetrics) {
|
|
t.Fatalf("metrics not as expected: %#v != %#v", metrics.EndpointMetrics, expectedMetrics)
|
|
}
|
|
}
|
|
|
|
if err := sink.Close(); err != nil {
|
|
t.Fatalf("unexpected error closing http sink: %v", err)
|
|
}
|
|
|
|
// double close returns error
|
|
if err := sink.Close(); err == nil {
|
|
t.Fatalf("second close should have returned error: %v", err)
|
|
}
|
|
|
|
}
|
|
|
|
func createTestEvent(action, repo, typ string) Event {
|
|
event := createEvent(action)
|
|
|
|
event.Target.Type = typ
|
|
event.Target.Name = repo
|
|
|
|
return *event
|
|
}
|