Browse Source
[Notifications] Improving webhook notifications (#6965)
[Notifications] Improving webhook notifications (#6965)
* worker setup * fix tests * start worker * graceful worker drain * retry queue * migrate queue to watermill * adding filters and improvements * add the event type to the webhook message * eliminating redundant JSON serialization * resolve review comments * trigger actions * fix tests * typo fixes * read max_backoff_seconds from config * add more context to the dead letter * close the http response on errors * drain the http response body in case not empty * eliminate exported typesπpull/6993/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1236 additions and 40 deletions
-
7go.mod
-
6go.sum
-
64weed/notification/webhook/filter.go
-
225weed/notification/webhook/filter_test.go
-
44weed/notification/webhook/http.go
-
12weed/notification/webhook/http_test.go
-
182weed/notification/webhook/types.go
-
200weed/notification/webhook/webhook_queue.go
-
536weed/notification/webhook/webhook_queue_test.go
@ -0,0 +1,64 @@ |
|||||
|
package webhook |
||||
|
|
||||
|
import ( |
||||
|
"strings" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/glog" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
||||
|
) |
||||
|
|
||||
|
type filter struct { |
||||
|
eventTypes map[eventType]bool |
||||
|
pathPrefixes []string |
||||
|
} |
||||
|
|
||||
|
func newFilter(cfg *config) *filter { |
||||
|
f := &filter{ |
||||
|
eventTypes: make(map[eventType]bool), |
||||
|
pathPrefixes: cfg.pathPrefixes, |
||||
|
} |
||||
|
|
||||
|
if len(cfg.eventTypes) == 0 { |
||||
|
f.eventTypes[eventTypeCreate] = true |
||||
|
f.eventTypes[eventTypeDelete] = true |
||||
|
f.eventTypes[eventTypeUpdate] = true |
||||
|
f.eventTypes[eventTypeRename] = true |
||||
|
} else { |
||||
|
for _, et := range cfg.eventTypes { |
||||
|
t := eventType(et) |
||||
|
if !t.valid() { |
||||
|
glog.Warningf("invalid event type: %v", t) |
||||
|
|
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
f.eventTypes[t] = true |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return f |
||||
|
} |
||||
|
|
||||
|
func (f *filter) shouldPublish(key string, notification *filer_pb.EventNotification) bool { |
||||
|
if !f.matchesPath(key) { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
eventType := detectEventType(notification) |
||||
|
|
||||
|
return f.eventTypes[eventType] |
||||
|
} |
||||
|
|
||||
|
func (f *filter) matchesPath(key string) bool { |
||||
|
if len(f.pathPrefixes) == 0 { |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
for _, prefix := range f.pathPrefixes { |
||||
|
if strings.HasPrefix(key, prefix) { |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return false |
||||
|
} |
@ -0,0 +1,225 @@ |
|||||
|
package webhook |
||||
|
|
||||
|
import ( |
||||
|
"testing" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
||||
|
) |
||||
|
|
||||
|
func TestFilterEventTypes(t *testing.T) { |
||||
|
tests := []struct { |
||||
|
name string |
||||
|
eventTypes []string |
||||
|
notification *filer_pb.EventNotification |
||||
|
expectedType eventType |
||||
|
shouldPublish bool |
||||
|
}{ |
||||
|
{ |
||||
|
name: "create event - allowed", |
||||
|
eventTypes: []string{"create", "delete"}, |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "test.txt"}, |
||||
|
}, |
||||
|
expectedType: eventTypeCreate, |
||||
|
shouldPublish: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "create event - not allowed", |
||||
|
eventTypes: []string{"delete", "update"}, |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "test.txt"}, |
||||
|
}, |
||||
|
expectedType: eventTypeCreate, |
||||
|
shouldPublish: false, |
||||
|
}, |
||||
|
{ |
||||
|
name: "delete event - allowed", |
||||
|
eventTypes: []string{"create", "delete"}, |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
OldEntry: &filer_pb.Entry{Name: "test.txt"}, |
||||
|
}, |
||||
|
expectedType: eventTypeDelete, |
||||
|
shouldPublish: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "update event - allowed", |
||||
|
eventTypes: []string{"update"}, |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
OldEntry: &filer_pb.Entry{Name: "test.txt"}, |
||||
|
NewEntry: &filer_pb.Entry{Name: "test.txt"}, |
||||
|
}, |
||||
|
expectedType: eventTypeUpdate, |
||||
|
shouldPublish: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "rename event - allowed", |
||||
|
eventTypes: []string{"rename"}, |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
OldEntry: &filer_pb.Entry{Name: "old.txt"}, |
||||
|
NewEntry: &filer_pb.Entry{Name: "new.txt"}, |
||||
|
NewParentPath: "/new/path", |
||||
|
}, |
||||
|
expectedType: eventTypeRename, |
||||
|
shouldPublish: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "rename event - not allowed", |
||||
|
eventTypes: []string{"create", "delete", "update"}, |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
OldEntry: &filer_pb.Entry{Name: "old.txt"}, |
||||
|
NewEntry: &filer_pb.Entry{Name: "new.txt"}, |
||||
|
NewParentPath: "/new/path", |
||||
|
}, |
||||
|
expectedType: eventTypeRename, |
||||
|
shouldPublish: false, |
||||
|
}, |
||||
|
{ |
||||
|
name: "all events allowed when empty", |
||||
|
eventTypes: []string{}, |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "test.txt"}, |
||||
|
}, |
||||
|
expectedType: eventTypeCreate, |
||||
|
shouldPublish: true, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
for _, tt := range tests { |
||||
|
t.Run(tt.name, func(t *testing.T) { |
||||
|
cfg := &config{eventTypes: tt.eventTypes} |
||||
|
f := newFilter(cfg) |
||||
|
|
||||
|
eventType := detectEventType(tt.notification) |
||||
|
if eventType != tt.expectedType { |
||||
|
t.Errorf("detectEventType() = %v, want %v", eventType, tt.expectedType) |
||||
|
} |
||||
|
|
||||
|
shouldPublish := f.shouldPublish("/test/path", tt.notification) |
||||
|
if shouldPublish != tt.shouldPublish { |
||||
|
t.Errorf("shouldPublish() = %v, want %v", shouldPublish, tt.shouldPublish) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestFilterPathPrefixes(t *testing.T) { |
||||
|
tests := []struct { |
||||
|
name string |
||||
|
pathPrefixes []string |
||||
|
key string |
||||
|
shouldPublish bool |
||||
|
}{ |
||||
|
{ |
||||
|
name: "matches single prefix", |
||||
|
pathPrefixes: []string{"/data/"}, |
||||
|
key: "/data/file.txt", |
||||
|
shouldPublish: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "matches one of multiple prefixes", |
||||
|
pathPrefixes: []string{"/data/", "/logs/", "/tmp/"}, |
||||
|
key: "/logs/app.log", |
||||
|
shouldPublish: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "no match", |
||||
|
pathPrefixes: []string{"/data/", "/logs/"}, |
||||
|
key: "/other/file.txt", |
||||
|
shouldPublish: false, |
||||
|
}, |
||||
|
{ |
||||
|
name: "empty prefixes allows all", |
||||
|
pathPrefixes: []string{}, |
||||
|
key: "/any/path/file.txt", |
||||
|
shouldPublish: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "exact prefix match", |
||||
|
pathPrefixes: []string{"/data"}, |
||||
|
key: "/data", |
||||
|
shouldPublish: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "partial match not allowed", |
||||
|
pathPrefixes: []string{"/data/"}, |
||||
|
key: "/database/file.txt", |
||||
|
shouldPublish: false, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
for _, tt := range tests { |
||||
|
t.Run(tt.name, func(t *testing.T) { |
||||
|
cfg := &config{ |
||||
|
pathPrefixes: tt.pathPrefixes, |
||||
|
eventTypes: []string{"create"}, |
||||
|
} |
||||
|
f := newFilter(cfg) |
||||
|
|
||||
|
notification := &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "test.txt"}, |
||||
|
} |
||||
|
|
||||
|
shouldPublish := f.shouldPublish(tt.key, notification) |
||||
|
if shouldPublish != tt.shouldPublish { |
||||
|
t.Errorf("shouldPublish() = %v, want %v", shouldPublish, tt.shouldPublish) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestFilterCombined(t *testing.T) { |
||||
|
cfg := &config{ |
||||
|
eventTypes: []string{"create", "update"}, |
||||
|
pathPrefixes: []string{"/data/", "/logs/"}, |
||||
|
} |
||||
|
f := newFilter(cfg) |
||||
|
|
||||
|
tests := []struct { |
||||
|
name string |
||||
|
key string |
||||
|
notification *filer_pb.EventNotification |
||||
|
shouldPublish bool |
||||
|
}{ |
||||
|
{ |
||||
|
name: "allowed event and path", |
||||
|
key: "/data/file.txt", |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
||||
|
}, |
||||
|
shouldPublish: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "allowed event but wrong path", |
||||
|
key: "/other/file.txt", |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
||||
|
}, |
||||
|
shouldPublish: false, |
||||
|
}, |
||||
|
{ |
||||
|
name: "wrong event but allowed path", |
||||
|
key: "/data/file.txt", |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
OldEntry: &filer_pb.Entry{Name: "file.txt"}, |
||||
|
}, |
||||
|
shouldPublish: false, |
||||
|
}, |
||||
|
{ |
||||
|
name: "wrong event and wrong path", |
||||
|
key: "/other/file.txt", |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
OldEntry: &filer_pb.Entry{Name: "file.txt"}, |
||||
|
}, |
||||
|
shouldPublish: false, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
for _, tt := range tests { |
||||
|
t.Run(tt.name, func(t *testing.T) { |
||||
|
shouldPublish := f.shouldPublish(tt.key, tt.notification) |
||||
|
if shouldPublish != tt.shouldPublish { |
||||
|
t.Errorf("shouldPublish() = %v, want %v", shouldPublish, tt.shouldPublish) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
@ -0,0 +1,182 @@ |
|||||
|
package webhook |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"net/url" |
||||
|
"slices" |
||||
|
"strconv" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/util" |
||||
|
"google.golang.org/protobuf/proto" |
||||
|
) |
||||
|
|
||||
|
const ( |
||||
|
queueName = "webhook" |
||||
|
pubSubTopicName = "webhook_topic" |
||||
|
deadLetterTopic = "webhook_dead_letter" |
||||
|
) |
||||
|
|
||||
|
type eventType string |
||||
|
|
||||
|
const ( |
||||
|
eventTypeCreate eventType = "create" |
||||
|
eventTypeDelete eventType = "delete" |
||||
|
eventTypeUpdate eventType = "update" |
||||
|
eventTypeRename eventType = "rename" |
||||
|
) |
||||
|
|
||||
|
func (e eventType) valid() bool { |
||||
|
return slices.Contains([]eventType{ |
||||
|
eventTypeCreate, |
||||
|
eventTypeDelete, |
||||
|
eventTypeUpdate, |
||||
|
eventTypeRename, |
||||
|
}, |
||||
|
e, |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
var ( |
||||
|
pubSubHandlerNameTemplate = func(n int) string { |
||||
|
return "webhook_handler_" + strconv.Itoa(n) |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
type client interface { |
||||
|
sendMessage(message *webhookMessage) error |
||||
|
} |
||||
|
|
||||
|
type webhookMessage struct { |
||||
|
Key string `json:"key"` |
||||
|
EventType string `json:"event_type"` |
||||
|
Notification *filer_pb.EventNotification `json:"message_data"` |
||||
|
} |
||||
|
|
||||
|
func newWebhookMessage(key string, message proto.Message) *webhookMessage { |
||||
|
notification, ok := message.(*filer_pb.EventNotification) |
||||
|
if !ok { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
eventType := string(detectEventType(notification)) |
||||
|
|
||||
|
return &webhookMessage{ |
||||
|
Key: key, |
||||
|
EventType: eventType, |
||||
|
Notification: notification, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
type config struct { |
||||
|
endpoint string |
||||
|
authBearerToken string |
||||
|
timeoutSeconds int |
||||
|
|
||||
|
maxRetries int |
||||
|
backoffSeconds int |
||||
|
maxBackoffSeconds int |
||||
|
nWorkers int |
||||
|
bufferSize int |
||||
|
|
||||
|
eventTypes []string |
||||
|
pathPrefixes []string |
||||
|
} |
||||
|
|
||||
|
func newConfigWithDefaults(configuration util.Configuration, prefix string) *config { |
||||
|
c := &config{ |
||||
|
endpoint: configuration.GetString(prefix + "endpoint"), |
||||
|
authBearerToken: configuration.GetString(prefix + "bearer_token"), |
||||
|
timeoutSeconds: 10, |
||||
|
maxRetries: 3, |
||||
|
backoffSeconds: 3, |
||||
|
maxBackoffSeconds: 30, |
||||
|
nWorkers: 5, |
||||
|
bufferSize: 10_000, |
||||
|
} |
||||
|
|
||||
|
if bufferSize := configuration.GetInt(prefix + "buffer_size"); bufferSize > 0 { |
||||
|
c.bufferSize = bufferSize |
||||
|
} |
||||
|
if workers := configuration.GetInt(prefix + "workers"); workers > 0 { |
||||
|
c.nWorkers = workers |
||||
|
} |
||||
|
if maxRetries := configuration.GetInt(prefix + "max_retries"); maxRetries > 0 { |
||||
|
c.maxRetries = maxRetries |
||||
|
} |
||||
|
if backoffSeconds := configuration.GetInt(prefix + "backoff_seconds"); backoffSeconds > 0 { |
||||
|
c.backoffSeconds = backoffSeconds |
||||
|
} |
||||
|
if maxBackoffSeconds := configuration.GetInt(prefix + "max_backoff_seconds"); maxBackoffSeconds > 0 { |
||||
|
c.maxBackoffSeconds = maxBackoffSeconds |
||||
|
} |
||||
|
if timeout := configuration.GetInt(prefix + "timeout_seconds"); timeout > 0 { |
||||
|
c.timeoutSeconds = timeout |
||||
|
} |
||||
|
|
||||
|
c.eventTypes = configuration.GetStringSlice(prefix + "event_types") |
||||
|
c.pathPrefixes = configuration.GetStringSlice(prefix + "path_prefixes") |
||||
|
|
||||
|
return c |
||||
|
} |
||||
|
|
||||
|
func (c *config) validate() error { |
||||
|
if c.endpoint == "" { |
||||
|
return fmt.Errorf("webhook endpoint is required") |
||||
|
} |
||||
|
|
||||
|
_, err := url.Parse(c.endpoint) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("invalid webhook endpoint: %w", err) |
||||
|
} |
||||
|
|
||||
|
if c.timeoutSeconds < 1 || c.timeoutSeconds > 300 { |
||||
|
return fmt.Errorf("timeout must be between 1 and 300 seconds, got %d", c.timeoutSeconds) |
||||
|
} |
||||
|
|
||||
|
if c.maxRetries < 0 || c.maxRetries > 10 { |
||||
|
return fmt.Errorf("max retries must be between 0 and 10, got %d", c.maxRetries) |
||||
|
} |
||||
|
|
||||
|
if c.backoffSeconds < 1 || c.backoffSeconds > 60 { |
||||
|
return fmt.Errorf("backoff seconds must be between 1 and 60, got %d", c.backoffSeconds) |
||||
|
} |
||||
|
|
||||
|
if c.maxBackoffSeconds < c.backoffSeconds || c.maxBackoffSeconds > 300 { |
||||
|
return fmt.Errorf("max backoff seconds must be between %d and 300, got %d", c.backoffSeconds, c.maxBackoffSeconds) |
||||
|
} |
||||
|
|
||||
|
if c.nWorkers < 1 || c.nWorkers > 100 { |
||||
|
return fmt.Errorf("workers must be between 1 and 100, got %d", c.nWorkers) |
||||
|
} |
||||
|
|
||||
|
if c.bufferSize < 100 || c.bufferSize > 1_000_000 { |
||||
|
return fmt.Errorf("buffer size must be between 100 and 1,000,000, got %d", c.bufferSize) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func detectEventType(notification *filer_pb.EventNotification) eventType { |
||||
|
hasOldEntry := notification.OldEntry != nil |
||||
|
hasNewEntry := notification.NewEntry != nil |
||||
|
hasNewParentPath := notification.NewParentPath != "" |
||||
|
|
||||
|
if !hasOldEntry && hasNewEntry { |
||||
|
return eventTypeCreate |
||||
|
} |
||||
|
|
||||
|
if hasOldEntry && !hasNewEntry { |
||||
|
return eventTypeDelete |
||||
|
} |
||||
|
|
||||
|
if hasOldEntry && hasNewEntry { |
||||
|
if hasNewParentPath { |
||||
|
return eventTypeRename |
||||
|
} |
||||
|
|
||||
|
return eventTypeUpdate |
||||
|
} |
||||
|
|
||||
|
return eventTypeUpdate |
||||
|
} |
@ -0,0 +1,536 @@ |
|||||
|
package webhook |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
"net/http/httptest" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
||||
|
"google.golang.org/protobuf/proto" |
||||
|
) |
||||
|
|
||||
|
func TestConfigValidation(t *testing.T) { |
||||
|
tests := []struct { |
||||
|
name string |
||||
|
config *config |
||||
|
wantErr bool |
||||
|
errMsg string |
||||
|
}{ |
||||
|
{ |
||||
|
name: "valid config", |
||||
|
config: &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
authBearerToken: "test-token", |
||||
|
timeoutSeconds: 30, |
||||
|
maxRetries: 3, |
||||
|
backoffSeconds: 5, |
||||
|
maxBackoffSeconds: 30, |
||||
|
nWorkers: 5, |
||||
|
bufferSize: 10000, |
||||
|
}, |
||||
|
wantErr: false, |
||||
|
}, |
||||
|
{ |
||||
|
name: "empty endpoint", |
||||
|
config: &config{ |
||||
|
endpoint: "", |
||||
|
timeoutSeconds: 30, |
||||
|
maxRetries: 3, |
||||
|
backoffSeconds: 5, |
||||
|
maxBackoffSeconds: 30, |
||||
|
nWorkers: 5, |
||||
|
bufferSize: 10000, |
||||
|
}, |
||||
|
wantErr: true, |
||||
|
errMsg: "endpoint is required", |
||||
|
}, |
||||
|
{ |
||||
|
name: "invalid URL", |
||||
|
config: &config{ |
||||
|
endpoint: "://invalid-url", |
||||
|
timeoutSeconds: 30, |
||||
|
maxRetries: 3, |
||||
|
backoffSeconds: 5, |
||||
|
maxBackoffSeconds: 30, |
||||
|
nWorkers: 5, |
||||
|
bufferSize: 10000, |
||||
|
}, |
||||
|
wantErr: true, |
||||
|
errMsg: "invalid webhook endpoint", |
||||
|
}, |
||||
|
{ |
||||
|
name: "timeout too large", |
||||
|
config: &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
timeoutSeconds: 301, |
||||
|
maxRetries: 3, |
||||
|
backoffSeconds: 5, |
||||
|
maxBackoffSeconds: 30, |
||||
|
nWorkers: 5, |
||||
|
bufferSize: 10000, |
||||
|
}, |
||||
|
wantErr: true, |
||||
|
errMsg: "timeout must be between", |
||||
|
}, |
||||
|
{ |
||||
|
name: "too many retries", |
||||
|
config: &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
timeoutSeconds: 30, |
||||
|
maxRetries: 11, |
||||
|
backoffSeconds: 5, |
||||
|
maxBackoffSeconds: 30, |
||||
|
nWorkers: 5, |
||||
|
bufferSize: 10000, |
||||
|
}, |
||||
|
wantErr: true, |
||||
|
errMsg: "max retries must be between", |
||||
|
}, |
||||
|
{ |
||||
|
name: "too many workers", |
||||
|
config: &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
timeoutSeconds: 30, |
||||
|
maxRetries: 3, |
||||
|
backoffSeconds: 5, |
||||
|
maxBackoffSeconds: 30, |
||||
|
nWorkers: 101, |
||||
|
bufferSize: 10000, |
||||
|
}, |
||||
|
wantErr: true, |
||||
|
errMsg: "workers must be between", |
||||
|
}, |
||||
|
{ |
||||
|
name: "buffer too large", |
||||
|
config: &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
timeoutSeconds: 30, |
||||
|
maxRetries: 3, |
||||
|
backoffSeconds: 5, |
||||
|
maxBackoffSeconds: 30, |
||||
|
nWorkers: 5, |
||||
|
bufferSize: 1000001, |
||||
|
}, |
||||
|
wantErr: true, |
||||
|
errMsg: "buffer size must be between", |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
for _, tt := range tests { |
||||
|
t.Run(tt.name, func(t *testing.T) { |
||||
|
err := tt.config.validate() |
||||
|
if (err != nil) != tt.wantErr { |
||||
|
t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) |
||||
|
} |
||||
|
if err != nil && tt.errMsg != "" { |
||||
|
if err.Error() == "" || !strings.Contains(err.Error(), tt.errMsg) { |
||||
|
t.Errorf("validate() error message = %v, want to contain %v", err.Error(), tt.errMsg) |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestWebhookMessageSerialization(t *testing.T) { |
||||
|
msg := &filer_pb.EventNotification{ |
||||
|
OldEntry: nil, |
||||
|
NewEntry: &filer_pb.Entry{ |
||||
|
Name: "test.txt", |
||||
|
IsDirectory: false, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
webhookMsg := newWebhookMessage("/test/path", msg) |
||||
|
|
||||
|
wmMsg, err := webhookMsg.toWaterMillMessage() |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to convert to watermill message: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Unmarshal the protobuf payload directly
|
||||
|
var eventNotification filer_pb.EventNotification |
||||
|
err = proto.Unmarshal(wmMsg.Payload, &eventNotification) |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to unmarshal protobuf message: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Check metadata
|
||||
|
if wmMsg.Metadata.Get("key") != "/test/path" { |
||||
|
t.Errorf("Expected key '/test/path', got %v", wmMsg.Metadata.Get("key")) |
||||
|
} |
||||
|
|
||||
|
if wmMsg.Metadata.Get("event_type") != "create" { |
||||
|
t.Errorf("Expected event type 'create', got %v", wmMsg.Metadata.Get("event_type")) |
||||
|
} |
||||
|
|
||||
|
if eventNotification.NewEntry.Name != "test.txt" { |
||||
|
t.Errorf("Expected file name 'test.txt', got %v", eventNotification.NewEntry.Name) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestQueueInitialize(t *testing.T) { |
||||
|
cfg := &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
authBearerToken: "test-token", |
||||
|
timeoutSeconds: 10, |
||||
|
maxRetries: 3, |
||||
|
backoffSeconds: 3, |
||||
|
maxBackoffSeconds: 60, |
||||
|
nWorkers: 1, |
||||
|
bufferSize: 100, |
||||
|
} |
||||
|
|
||||
|
q := &Queue{} |
||||
|
err := q.initialize(cfg) |
||||
|
if err != nil { |
||||
|
t.Errorf("Initialize() error = %v", err) |
||||
|
} |
||||
|
|
||||
|
defer func() { |
||||
|
if q.cancel != nil { |
||||
|
q.cancel() |
||||
|
} |
||||
|
time.Sleep(100 * time.Millisecond) |
||||
|
if q.router != nil { |
||||
|
q.router.Close() |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
if q.router == nil { |
||||
|
t.Error("Expected router to be initialized") |
||||
|
} |
||||
|
if q.queueChannel == nil { |
||||
|
t.Error("Expected queueChannel to be initialized") |
||||
|
} |
||||
|
if q.client == nil { |
||||
|
t.Error("Expected client to be initialized") |
||||
|
} |
||||
|
if q.config == nil { |
||||
|
t.Error("Expected config to be initialized") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// TestQueueSendMessage test sending messages to the queue
|
||||
|
func TestQueueSendMessage(t *testing.T) { |
||||
|
cfg := &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
authBearerToken: "test-token", |
||||
|
timeoutSeconds: 1, |
||||
|
maxRetries: 1, |
||||
|
backoffSeconds: 1, |
||||
|
maxBackoffSeconds: 1, |
||||
|
nWorkers: 1, |
||||
|
bufferSize: 10, |
||||
|
} |
||||
|
|
||||
|
q := &Queue{} |
||||
|
err := q.initialize(cfg) |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to initialize queue: %v", err) |
||||
|
} |
||||
|
|
||||
|
defer func() { |
||||
|
if q.cancel != nil { |
||||
|
q.cancel() |
||||
|
} |
||||
|
time.Sleep(100 * time.Millisecond) |
||||
|
if q.router != nil { |
||||
|
q.router.Close() |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
msg := &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{ |
||||
|
Name: "test.txt", |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
err = q.SendMessage("/test/path", msg) |
||||
|
if err != nil { |
||||
|
t.Errorf("SendMessage() error = %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestQueueHandleWebhook(t *testing.T) { |
||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
|
w.WriteHeader(http.StatusOK) |
||||
|
})) |
||||
|
defer server.Close() |
||||
|
|
||||
|
cfg := &config{ |
||||
|
endpoint: server.URL, |
||||
|
authBearerToken: "test-token", |
||||
|
timeoutSeconds: 1, |
||||
|
maxRetries: 0, |
||||
|
backoffSeconds: 1, |
||||
|
maxBackoffSeconds: 1, |
||||
|
nWorkers: 1, |
||||
|
bufferSize: 10, |
||||
|
} |
||||
|
|
||||
|
client, _ := newHTTPClient(cfg) |
||||
|
q := &Queue{ |
||||
|
client: client, |
||||
|
} |
||||
|
|
||||
|
message := newWebhookMessage("/test/path", &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{ |
||||
|
Name: "test.txt", |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
wmMsg, err := message.toWaterMillMessage() |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to create watermill message: %v", err) |
||||
|
} |
||||
|
|
||||
|
err = q.handleWebhook(wmMsg) |
||||
|
if err != nil { |
||||
|
t.Errorf("handleWebhook() error = %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestQueueEndToEnd(t *testing.T) { |
||||
|
// Simplified test - just verify the queue can be created and message can be sent
|
||||
|
// without needing full end-to-end processing
|
||||
|
cfg := &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
authBearerToken: "test-token", |
||||
|
timeoutSeconds: 1, |
||||
|
maxRetries: 0, |
||||
|
backoffSeconds: 1, |
||||
|
maxBackoffSeconds: 1, |
||||
|
nWorkers: 1, |
||||
|
bufferSize: 10, |
||||
|
} |
||||
|
|
||||
|
q := &Queue{} |
||||
|
err := q.initialize(cfg) |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to initialize queue: %v", err) |
||||
|
} |
||||
|
|
||||
|
defer func() { |
||||
|
if q.cancel != nil { |
||||
|
q.cancel() |
||||
|
} |
||||
|
time.Sleep(100 * time.Millisecond) |
||||
|
if q.router != nil { |
||||
|
q.router.Close() |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
msg := &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{ |
||||
|
Name: "test.txt", |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
err = q.SendMessage("/test/path", msg) |
||||
|
if err != nil { |
||||
|
t.Errorf("SendMessage() error = %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestQueueRetryMechanism(t *testing.T) { |
||||
|
cfg := &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
authBearerToken: "test-token", |
||||
|
timeoutSeconds: 1, |
||||
|
maxRetries: 3, // Test that this config is used
|
||||
|
backoffSeconds: 2, |
||||
|
maxBackoffSeconds: 10, |
||||
|
nWorkers: 1, |
||||
|
bufferSize: 10, |
||||
|
} |
||||
|
|
||||
|
q := &Queue{} |
||||
|
err := q.initialize(cfg) |
||||
|
if err != nil { |
||||
|
t.Fatalf("Failed to initialize queue: %v", err) |
||||
|
} |
||||
|
|
||||
|
defer func() { |
||||
|
if q.cancel != nil { |
||||
|
q.cancel() |
||||
|
} |
||||
|
time.Sleep(100 * time.Millisecond) |
||||
|
if q.router != nil { |
||||
|
q.router.Close() |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
// Verify that the queue is properly configured for retries
|
||||
|
if q.config.maxRetries != 3 { |
||||
|
t.Errorf("Expected maxRetries=3, got %d", q.config.maxRetries) |
||||
|
} |
||||
|
|
||||
|
if q.config.backoffSeconds != 2 { |
||||
|
t.Errorf("Expected backoffSeconds=2, got %d", q.config.backoffSeconds) |
||||
|
} |
||||
|
|
||||
|
if q.config.maxBackoffSeconds != 10 { |
||||
|
t.Errorf("Expected maxBackoffSeconds=10, got %d", q.config.maxBackoffSeconds) |
||||
|
} |
||||
|
|
||||
|
// Test that we can send a message (retry behavior is handled by Watermill middleware)
|
||||
|
msg := &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "test.txt"}, |
||||
|
} |
||||
|
|
||||
|
err = q.SendMessage("/test/retry", msg) |
||||
|
if err != nil { |
||||
|
t.Errorf("SendMessage() error = %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestQueueSendMessageWithFilter(t *testing.T) { |
||||
|
tests := []struct { |
||||
|
name string |
||||
|
cfg *config |
||||
|
key string |
||||
|
notification *filer_pb.EventNotification |
||||
|
shouldPublish bool |
||||
|
}{ |
||||
|
{ |
||||
|
name: "allowed event type", |
||||
|
cfg: &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
timeoutSeconds: 10, |
||||
|
maxRetries: 1, |
||||
|
backoffSeconds: 1, |
||||
|
maxBackoffSeconds: 1, |
||||
|
nWorkers: 1, |
||||
|
bufferSize: 10, |
||||
|
eventTypes: []string{"create"}, |
||||
|
}, |
||||
|
key: "/data/file.txt", |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
||||
|
}, |
||||
|
shouldPublish: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "filtered event type", |
||||
|
cfg: &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
timeoutSeconds: 10, |
||||
|
maxRetries: 1, |
||||
|
backoffSeconds: 1, |
||||
|
maxBackoffSeconds: 1, |
||||
|
nWorkers: 1, |
||||
|
bufferSize: 10, |
||||
|
eventTypes: []string{"update", "rename"}, |
||||
|
}, |
||||
|
key: "/data/file.txt", |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
||||
|
}, |
||||
|
shouldPublish: false, |
||||
|
}, |
||||
|
{ |
||||
|
name: "allowed path prefix", |
||||
|
cfg: &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
timeoutSeconds: 10, |
||||
|
maxRetries: 1, |
||||
|
backoffSeconds: 1, |
||||
|
maxBackoffSeconds: 1, |
||||
|
nWorkers: 1, |
||||
|
bufferSize: 10, |
||||
|
pathPrefixes: []string{"/data/"}, |
||||
|
}, |
||||
|
key: "/data/file.txt", |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
||||
|
}, |
||||
|
shouldPublish: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "filtered path prefix", |
||||
|
cfg: &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
timeoutSeconds: 10, |
||||
|
maxRetries: 1, |
||||
|
backoffSeconds: 1, |
||||
|
maxBackoffSeconds: 1, |
||||
|
nWorkers: 1, |
||||
|
bufferSize: 10, |
||||
|
pathPrefixes: []string{"/logs/"}, |
||||
|
}, |
||||
|
key: "/data/file.txt", |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
||||
|
}, |
||||
|
shouldPublish: false, |
||||
|
}, |
||||
|
{ |
||||
|
name: "combined filters - both pass", |
||||
|
cfg: &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
timeoutSeconds: 10, |
||||
|
maxRetries: 1, |
||||
|
backoffSeconds: 1, |
||||
|
maxBackoffSeconds: 1, |
||||
|
nWorkers: 1, |
||||
|
bufferSize: 10, |
||||
|
eventTypes: []string{"create", "delete"}, |
||||
|
pathPrefixes: []string{"/data/", "/logs/"}, |
||||
|
}, |
||||
|
key: "/data/file.txt", |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
||||
|
}, |
||||
|
shouldPublish: true, |
||||
|
}, |
||||
|
{ |
||||
|
name: "combined filters - event fails", |
||||
|
cfg: &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
timeoutSeconds: 10, |
||||
|
maxRetries: 1, |
||||
|
backoffSeconds: 1, |
||||
|
maxBackoffSeconds: 1, |
||||
|
nWorkers: 1, |
||||
|
bufferSize: 10, |
||||
|
eventTypes: []string{"update", "delete"}, |
||||
|
pathPrefixes: []string{"/data/", "/logs/"}, |
||||
|
}, |
||||
|
key: "/data/file.txt", |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
||||
|
}, |
||||
|
shouldPublish: false, |
||||
|
}, |
||||
|
{ |
||||
|
name: "combined filters - path fails", |
||||
|
cfg: &config{ |
||||
|
endpoint: "https://example.com/webhook", |
||||
|
timeoutSeconds: 10, |
||||
|
maxRetries: 1, |
||||
|
backoffSeconds: 1, |
||||
|
maxBackoffSeconds: 1, |
||||
|
nWorkers: 1, |
||||
|
bufferSize: 10, |
||||
|
eventTypes: []string{"create", "delete"}, |
||||
|
pathPrefixes: []string{"/logs/"}, |
||||
|
}, |
||||
|
key: "/data/file.txt", |
||||
|
notification: &filer_pb.EventNotification{ |
||||
|
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
||||
|
}, |
||||
|
shouldPublish: false, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
for _, tt := range tests { |
||||
|
t.Run(tt.name, func(t *testing.T) { |
||||
|
shouldPublish := newFilter(tt.cfg).shouldPublish(tt.key, tt.notification) |
||||
|
if shouldPublish != tt.shouldPublish { |
||||
|
t.Errorf("Expected shouldPublish=%v, got %v", tt.shouldPublish, shouldPublish) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue