OP#792 initial commit after fork
This commit is contained in:
84
internal/event/arn.go
Normal file
84
internal/event/arn.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ARN - SQS resource name representation.
|
||||
type ARN struct {
|
||||
TargetID
|
||||
region string
|
||||
}
|
||||
|
||||
// String - returns string representation.
|
||||
func (arn ARN) String() string {
|
||||
if arn.ID == "" && arn.Name == "" && arn.region == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "arn:minio:sqs:" + arn.region + ":" + arn.TargetID.String()
|
||||
}
|
||||
|
||||
// MarshalXML - encodes to XML data.
|
||||
func (arn ARN) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
return e.EncodeElement(arn.String(), start)
|
||||
}
|
||||
|
||||
// UnmarshalXML - decodes XML data.
|
||||
func (arn *ARN) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
var s string
|
||||
if err := d.DecodeElement(&s, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsedARN, err := parseARN(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*arn = *parsedARN
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseARN - parses string to ARN.
|
||||
func parseARN(s string) (*ARN, error) {
|
||||
// ARN must be in the format of arn:minio:sqs:<REGION>:<ID>:<TYPE>
|
||||
if !strings.HasPrefix(s, "arn:minio:sqs:") {
|
||||
return nil, &ErrInvalidARN{s}
|
||||
}
|
||||
|
||||
tokens := strings.Split(s, ":")
|
||||
if len(tokens) != 6 {
|
||||
return nil, &ErrInvalidARN{s}
|
||||
}
|
||||
|
||||
if tokens[4] == "" || tokens[5] == "" {
|
||||
return nil, &ErrInvalidARN{s}
|
||||
}
|
||||
|
||||
return &ARN{
|
||||
region: tokens[3],
|
||||
TargetID: TargetID{
|
||||
ID: tokens[4],
|
||||
Name: tokens[5],
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
130
internal/event/arn_test.go
Normal file
130
internal/event/arn_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestARNString(t *testing.T) {
|
||||
testCases := []struct {
|
||||
arn ARN
|
||||
expectedResult string
|
||||
}{
|
||||
{ARN{}, ""},
|
||||
{ARN{TargetID{"1", "webhook"}, ""}, "arn:minio:sqs::1:webhook"},
|
||||
{ARN{TargetID{"1", "webhook"}, "us-east-1"}, "arn:minio:sqs:us-east-1:1:webhook"},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.arn.String()
|
||||
|
||||
if result != testCase.expectedResult {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestARNMarshalXML(t *testing.T) {
|
||||
testCases := []struct {
|
||||
arn ARN
|
||||
expectedData []byte
|
||||
expectErr bool
|
||||
}{
|
||||
{ARN{}, []byte("<ARN></ARN>"), false},
|
||||
{ARN{TargetID{"1", "webhook"}, ""}, []byte("<ARN>arn:minio:sqs::1:webhook</ARN>"), false},
|
||||
{ARN{TargetID{"1", "webhook"}, "us-east-1"}, []byte("<ARN>arn:minio:sqs:us-east-1:1:webhook</ARN>"), false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
data, err := xml.Marshal(testCase.arn)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
|
||||
if !testCase.expectErr {
|
||||
if !reflect.DeepEqual(data, testCase.expectedData) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, string(testCase.expectedData), string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestARNUnmarshalXML(t *testing.T) {
|
||||
testCases := []struct {
|
||||
data []byte
|
||||
expectedARN *ARN
|
||||
expectErr bool
|
||||
}{
|
||||
{[]byte("<ARN></ARN>"), nil, true},
|
||||
{[]byte("<ARN>arn:minio:sqs:::</ARN>"), nil, true},
|
||||
{[]byte("<ARN>arn:minio:sqs::1:webhook</ARN>"), &ARN{TargetID{"1", "webhook"}, ""}, false},
|
||||
{[]byte("<ARN>arn:minio:sqs:us-east-1:1:webhook</ARN>"), &ARN{TargetID{"1", "webhook"}, "us-east-1"}, false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
arn := &ARN{}
|
||||
err := xml.Unmarshal(testCase.data, &arn)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
|
||||
if !testCase.expectErr {
|
||||
if *arn != *testCase.expectedARN {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedARN, arn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseARN(t *testing.T) {
|
||||
testCases := []struct {
|
||||
s string
|
||||
expectedARN *ARN
|
||||
expectErr bool
|
||||
}{
|
||||
{"", nil, true},
|
||||
{"arn:minio:sqs:::", nil, true},
|
||||
{"arn:minio:sqs::1:webhook:remote", nil, true},
|
||||
{"arn:aws:sqs::1:webhook", nil, true},
|
||||
{"arn:minio:sns::1:webhook", nil, true},
|
||||
{"arn:minio:sqs::1:webhook", &ARN{TargetID{"1", "webhook"}, ""}, false},
|
||||
{"arn:minio:sqs:us-east-1:1:webhook", &ARN{TargetID{"1", "webhook"}, "us-east-1"}, false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
arn, err := parseARN(testCase.s)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
|
||||
if !testCase.expectErr {
|
||||
if *arn != *testCase.expectedARN {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedARN, arn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
326
internal/event/config.go
Normal file
326
internal/event/config.go
Normal file
@@ -0,0 +1,326 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/minio/minio-go/v7/pkg/set"
|
||||
)
|
||||
|
||||
// ValidateFilterRuleValue - checks if given value is filter rule value or not.
|
||||
func ValidateFilterRuleValue(value string) error {
|
||||
for segment := range strings.SplitSeq(value, "/") {
|
||||
if segment == "." || segment == ".." {
|
||||
return &ErrInvalidFilterValue{value}
|
||||
}
|
||||
}
|
||||
|
||||
if len(value) <= 1024 && utf8.ValidString(value) && !strings.Contains(value, `\`) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ErrInvalidFilterValue{value}
|
||||
}
|
||||
|
||||
// FilterRule - represents elements inside <FilterRule>...</FilterRule>
|
||||
type FilterRule struct {
|
||||
Name string `xml:"Name"`
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
|
||||
func (filter FilterRule) isEmpty() bool {
|
||||
return filter.Name == "" && filter.Value == ""
|
||||
}
|
||||
|
||||
// MarshalXML implements a custom marshaller to support `omitempty` feature.
|
||||
func (filter FilterRule) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
if filter.isEmpty() {
|
||||
return nil
|
||||
}
|
||||
type filterRuleWrapper FilterRule
|
||||
return e.EncodeElement(filterRuleWrapper(filter), start)
|
||||
}
|
||||
|
||||
// UnmarshalXML - decodes XML data.
|
||||
func (filter *FilterRule) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
// Make subtype to avoid recursive UnmarshalXML().
|
||||
type filterRule FilterRule
|
||||
rule := filterRule{}
|
||||
if err := d.DecodeElement(&rule, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rule.Name != "prefix" && rule.Name != "suffix" {
|
||||
return &ErrInvalidFilterName{rule.Name}
|
||||
}
|
||||
|
||||
if err := ValidateFilterRuleValue(filter.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*filter = FilterRule(rule)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilterRuleList - represents multiple <FilterRule>...</FilterRule>
|
||||
type FilterRuleList struct {
|
||||
Rules []FilterRule `xml:"FilterRule,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalXML - decodes XML data.
|
||||
func (ruleList *FilterRuleList) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
// Make subtype to avoid recursive UnmarshalXML().
|
||||
type filterRuleList FilterRuleList
|
||||
rules := filterRuleList{}
|
||||
if err := d.DecodeElement(&rules, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// FilterRuleList must have only one prefix and/or suffix.
|
||||
nameSet := set.NewStringSet()
|
||||
for _, rule := range rules.Rules {
|
||||
if nameSet.Contains(rule.Name) {
|
||||
if rule.Name == "prefix" {
|
||||
return &ErrFilterNamePrefix{}
|
||||
}
|
||||
|
||||
return &ErrFilterNameSuffix{}
|
||||
}
|
||||
|
||||
nameSet.Add(rule.Name)
|
||||
}
|
||||
|
||||
*ruleList = FilterRuleList(rules)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ruleList FilterRuleList) isEmpty() bool {
|
||||
return len(ruleList.Rules) == 0
|
||||
}
|
||||
|
||||
// Pattern - returns pattern using prefix and suffix values.
|
||||
func (ruleList FilterRuleList) Pattern() string {
|
||||
var prefix string
|
||||
var suffix string
|
||||
|
||||
for _, rule := range ruleList.Rules {
|
||||
switch rule.Name {
|
||||
case "prefix":
|
||||
prefix = rule.Value
|
||||
case "suffix":
|
||||
suffix = rule.Value
|
||||
}
|
||||
}
|
||||
|
||||
return NewPattern(prefix, suffix)
|
||||
}
|
||||
|
||||
// S3Key - represents elements inside <S3Key>...</S3Key>
|
||||
type S3Key struct {
|
||||
RuleList FilterRuleList `xml:"S3Key,omitempty" json:"S3Key"`
|
||||
}
|
||||
|
||||
// MarshalXML implements a custom marshaller to support `omitempty` feature.
|
||||
func (s3Key S3Key) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
if s3Key.RuleList.isEmpty() {
|
||||
return nil
|
||||
}
|
||||
type s3KeyWrapper S3Key
|
||||
return e.EncodeElement(s3KeyWrapper(s3Key), start)
|
||||
}
|
||||
|
||||
// common - represents common elements inside <QueueConfiguration>, <CloudFunctionConfiguration>
|
||||
// and <TopicConfiguration>
|
||||
type common struct {
|
||||
ID string `xml:"Id" json:"Id"`
|
||||
Filter S3Key `xml:"Filter" json:"Filter"`
|
||||
Events []Name `xml:"Event" json:"Event"`
|
||||
}
|
||||
|
||||
// Queue - represents elements inside <QueueConfiguration>
|
||||
type Queue struct {
|
||||
common
|
||||
ARN ARN `xml:"Queue"`
|
||||
}
|
||||
|
||||
// UnmarshalXML - decodes XML data.
|
||||
func (q *Queue) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
// Make subtype to avoid recursive UnmarshalXML().
|
||||
type queue Queue
|
||||
parsedQueue := queue{}
|
||||
if err := d.DecodeElement(&parsedQueue, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(parsedQueue.Events) == 0 {
|
||||
return errors.New("missing event name(s)")
|
||||
}
|
||||
|
||||
eventStringSet := set.NewStringSet()
|
||||
for _, eventName := range parsedQueue.Events {
|
||||
if eventStringSet.Contains(eventName.String()) {
|
||||
return &ErrDuplicateEventName{eventName}
|
||||
}
|
||||
|
||||
eventStringSet.Add(eventName.String())
|
||||
}
|
||||
|
||||
*q = Queue(parsedQueue)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate - checks whether queue has valid values or not.
|
||||
func (q Queue) Validate(region string, targetList *TargetList) error {
|
||||
if q.ARN.region == "" {
|
||||
if !targetList.Exists(q.ARN.TargetID) {
|
||||
return &ErrARNNotFound{q.ARN}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if region != "" && q.ARN.region != region {
|
||||
return &ErrUnknownRegion{q.ARN.region}
|
||||
}
|
||||
|
||||
if !targetList.Exists(q.ARN.TargetID) {
|
||||
return &ErrARNNotFound{q.ARN}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRegion - sets region value to queue's ARN.
|
||||
func (q *Queue) SetRegion(region string) {
|
||||
q.ARN.region = region
|
||||
}
|
||||
|
||||
// ToRulesMap - converts Queue to RulesMap
|
||||
func (q Queue) ToRulesMap() RulesMap {
|
||||
pattern := q.Filter.RuleList.Pattern()
|
||||
return NewRulesMap(q.Events, pattern, q.ARN.TargetID)
|
||||
}
|
||||
|
||||
// Unused. Available for completion.
|
||||
type lambda struct {
|
||||
ARN string `xml:"CloudFunction"`
|
||||
}
|
||||
|
||||
// Unused. Available for completion.
|
||||
type topic struct {
|
||||
ARN string `xml:"Topic" json:"Topic"`
|
||||
}
|
||||
|
||||
// Config - notification configuration described in
|
||||
// http://docs.aws.amazon.com/AmazonS3/latest/dev/NotificationHowTo.html
|
||||
type Config struct {
|
||||
XMLNS string `xml:"xmlns,attr,omitempty"`
|
||||
XMLName xml.Name `xml:"NotificationConfiguration"`
|
||||
QueueList []Queue `xml:"QueueConfiguration,omitempty"`
|
||||
LambdaList []lambda `xml:"CloudFunctionConfiguration,omitempty"`
|
||||
TopicList []topic `xml:"TopicConfiguration,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalXML - decodes XML data.
|
||||
func (conf *Config) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
// Make subtype to avoid recursive UnmarshalXML().
|
||||
type config Config
|
||||
parsedConfig := config{}
|
||||
if err := d.DecodeElement(&parsedConfig, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Empty queue list means user wants to delete the notification configuration.
|
||||
if len(parsedConfig.QueueList) > 0 {
|
||||
for i, q1 := range parsedConfig.QueueList[:len(parsedConfig.QueueList)-1] {
|
||||
for _, q2 := range parsedConfig.QueueList[i+1:] {
|
||||
// Removes the region from ARN if server region is not set
|
||||
if q2.ARN.region != "" && q1.ARN.region == "" {
|
||||
q2.ARN.region = ""
|
||||
}
|
||||
if reflect.DeepEqual(q1, q2) {
|
||||
return &ErrDuplicateQueueConfiguration{q1}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(parsedConfig.LambdaList) > 0 || len(parsedConfig.TopicList) > 0 {
|
||||
return &ErrUnsupportedConfiguration{}
|
||||
}
|
||||
|
||||
*conf = Config(parsedConfig)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate - checks whether config has valid values or not.
|
||||
func (conf Config) Validate(region string, targetList *TargetList) error {
|
||||
for _, queue := range conf.QueueList {
|
||||
if err := queue.Validate(region, targetList); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRegion - sets region to all queue configuration.
|
||||
func (conf *Config) SetRegion(region string) {
|
||||
for i := range conf.QueueList {
|
||||
conf.QueueList[i].SetRegion(region)
|
||||
}
|
||||
}
|
||||
|
||||
// ToRulesMap - converts all queue configuration to RulesMap.
|
||||
func (conf *Config) ToRulesMap() RulesMap {
|
||||
rulesMap := make(RulesMap)
|
||||
|
||||
for _, queue := range conf.QueueList {
|
||||
rulesMap.Add(queue.ToRulesMap())
|
||||
}
|
||||
|
||||
return rulesMap
|
||||
}
|
||||
|
||||
// ParseConfig - parses data in reader to notification configuration.
|
||||
func ParseConfig(reader io.Reader, region string, targetList *TargetList) (*Config, error) {
|
||||
var config Config
|
||||
|
||||
if err := xml.NewDecoder(reader).Decode(&config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := config.Validate(region, targetList); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.SetRegion(region)
|
||||
// If xml namespace is empty, set a default value before returning.
|
||||
if config.XMLNS == "" {
|
||||
config.XMLNS = "http://s3.amazonaws.com/doc/2006-03-01/"
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
961
internal/event/config_test.go
Normal file
961
internal/event/config_test.go
Normal file
@@ -0,0 +1,961 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateFilterRuleValue(t *testing.T) {
|
||||
testCases := []struct {
|
||||
value string
|
||||
expectErr bool
|
||||
}{
|
||||
{"foo/.", true},
|
||||
{"../foo", true},
|
||||
{`foo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/bazfoo/bar/baz`, true},
|
||||
{string([]byte{0xff, 0xfe, 0xfd}), true},
|
||||
{`foo\bar`, true},
|
||||
{"Hello/世界", false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
err := ValidateFilterRuleValue(testCase.value)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRuleUnmarshalXML(t *testing.T) {
|
||||
testCases := []struct {
|
||||
data []byte
|
||||
expectedResult *FilterRule
|
||||
expectErr bool
|
||||
}{
|
||||
{[]byte(`<FilterRule></FilterRule>`), nil, true},
|
||||
{[]byte(`<FilterRule><Name></Name></FilterRule>`), nil, true},
|
||||
{[]byte(`<FilterRule><Value></Value></FilterRule>`), nil, true},
|
||||
{[]byte(`<FilterRule><Name></Name><Value></Value></FilterRule>`), nil, true},
|
||||
{[]byte(`<FilterRule><Name>Prefix</Name><Value>Hello/世界</Value></FilterRule>`), nil, true},
|
||||
{[]byte(`<FilterRule><Name>ends</Name><Value>foo/bar</Value></FilterRule>`), nil, true},
|
||||
{[]byte(`<FilterRule><Name>prefix</Name><Value>Hello/世界</Value></FilterRule>`), &FilterRule{"prefix", "Hello/世界"}, false},
|
||||
{[]byte(`<FilterRule><Name>suffix</Name><Value>foo/bar</Value></FilterRule>`), &FilterRule{"suffix", "foo/bar"}, false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := &FilterRule{}
|
||||
err := xml.Unmarshal(testCase.data, result)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
|
||||
if !testCase.expectErr {
|
||||
if !reflect.DeepEqual(result, testCase.expectedResult) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRuleListUnmarshalXML(t *testing.T) {
|
||||
testCases := []struct {
|
||||
data []byte
|
||||
expectedResult *FilterRuleList
|
||||
expectErr bool
|
||||
}{
|
||||
{[]byte(`<S3Key><FilterRule><Name>suffix</Name><Value>Hello/世界</Value></FilterRule><FilterRule><Name>suffix</Name><Value>foo/bar</Value></FilterRule></S3Key>`), nil, true},
|
||||
{[]byte(`<S3Key><FilterRule><Name>prefix</Name><Value>Hello/世界</Value></FilterRule><FilterRule><Name>prefix</Name><Value>foo/bar</Value></FilterRule></S3Key>`), nil, true},
|
||||
{[]byte(`<S3Key><FilterRule><Name>prefix</Name><Value>Hello/世界</Value></FilterRule></S3Key>`), &FilterRuleList{[]FilterRule{{"prefix", "Hello/世界"}}}, false},
|
||||
{[]byte(`<S3Key><FilterRule><Name>suffix</Name><Value>foo/bar</Value></FilterRule></S3Key>`), &FilterRuleList{[]FilterRule{{"suffix", "foo/bar"}}}, false},
|
||||
{[]byte(`<S3Key><FilterRule><Name>prefix</Name><Value>Hello/世界</Value></FilterRule><FilterRule><Name>suffix</Name><Value>foo/bar</Value></FilterRule></S3Key>`), &FilterRuleList{[]FilterRule{{"prefix", "Hello/世界"}, {"suffix", "foo/bar"}}}, false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := &FilterRuleList{}
|
||||
err := xml.Unmarshal(testCase.data, result)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
|
||||
if !testCase.expectErr {
|
||||
if !reflect.DeepEqual(result, testCase.expectedResult) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRuleListPattern(t *testing.T) {
|
||||
testCases := []struct {
|
||||
filterRuleList FilterRuleList
|
||||
expectedResult string
|
||||
}{
|
||||
{FilterRuleList{}, ""},
|
||||
{FilterRuleList{[]FilterRule{{"prefix", "Hello/世界"}}}, "Hello/世界*"},
|
||||
{FilterRuleList{[]FilterRule{{"suffix", "foo/bar"}}}, "*foo/bar"},
|
||||
{FilterRuleList{[]FilterRule{{"prefix", "Hello/世界"}, {"suffix", "foo/bar"}}}, "Hello/世界*foo/bar"},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.filterRuleList.Pattern()
|
||||
|
||||
if result != testCase.expectedResult {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueUnmarshalXML(t *testing.T) {
|
||||
dataCase1 := []byte(`
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>`)
|
||||
|
||||
dataCase2 := []byte(`
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>`)
|
||||
|
||||
dataCase3 := []byte(`
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>`)
|
||||
|
||||
testCases := []struct {
|
||||
data []byte
|
||||
expectErr bool
|
||||
}{
|
||||
{dataCase1, false},
|
||||
{dataCase2, false},
|
||||
{dataCase3, true},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
err := xml.Unmarshal(testCase.data, &Queue{})
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueValidate(t *testing.T) {
|
||||
data := []byte(`
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>`)
|
||||
queue1 := &Queue{}
|
||||
if err := xml.Unmarshal(data, queue1); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data = []byte(`
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>`)
|
||||
queue2 := &Queue{}
|
||||
if err := xml.Unmarshal(data, queue2); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data = []byte(`
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:eu-west-2:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>`)
|
||||
queue3 := &Queue{}
|
||||
if err := xml.Unmarshal(data, queue3); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
targetList1 := NewTargetList(t.Context())
|
||||
|
||||
targetList2 := NewTargetList(t.Context())
|
||||
if err := targetList2.Add(&ExampleTarget{TargetID{"1", "webhook"}, false, false}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
queue *Queue
|
||||
region string
|
||||
targetList *TargetList
|
||||
expectErr bool
|
||||
}{
|
||||
{queue1, "eu-west-1", nil, true},
|
||||
{queue2, "us-east-1", targetList1, true},
|
||||
{queue3, "", targetList2, false},
|
||||
{queue2, "us-east-1", targetList2, false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
err := testCase.queue.Validate(testCase.region, testCase.targetList)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueSetRegion(t *testing.T) {
|
||||
data := []byte(`
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>`)
|
||||
queue1 := &Queue{}
|
||||
if err := xml.Unmarshal(data, queue1); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data = []byte(`
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs::1:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>`)
|
||||
queue2 := &Queue{}
|
||||
if err := xml.Unmarshal(data, queue2); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
queue *Queue
|
||||
region string
|
||||
expectedResult ARN
|
||||
}{
|
||||
{queue1, "eu-west-1", ARN{TargetID{"1", "webhook"}, "eu-west-1"}},
|
||||
{queue1, "", ARN{TargetID{"1", "webhook"}, ""}},
|
||||
{queue2, "us-east-1", ARN{TargetID{"1", "webhook"}, "us-east-1"}},
|
||||
{queue2, "", ARN{TargetID{"1", "webhook"}, ""}},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
testCase.queue.SetRegion(testCase.region)
|
||||
result := testCase.queue.ARN
|
||||
|
||||
if !reflect.DeepEqual(result, testCase.expectedResult) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueToRulesMap(t *testing.T) {
|
||||
data := []byte(`
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>`)
|
||||
queueCase1 := &Queue{}
|
||||
if err := xml.Unmarshal(data, queueCase1); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data = []byte(`
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>`)
|
||||
queueCase2 := &Queue{}
|
||||
if err := xml.Unmarshal(data, queueCase2); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
rulesMapCase1 := NewRulesMap([]Name{ObjectAccessedAll, ObjectCreatedAll, ObjectRemovedAll}, "*", TargetID{"1", "webhook"})
|
||||
rulesMapCase2 := NewRulesMap([]Name{ObjectCreatedPut}, "images/*jpg", TargetID{"1", "webhook"})
|
||||
|
||||
testCases := []struct {
|
||||
queue *Queue
|
||||
expectedResult RulesMap
|
||||
}{
|
||||
{queueCase1, rulesMapCase1},
|
||||
{queueCase2, rulesMapCase2},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.queue.ToRulesMap()
|
||||
|
||||
if !reflect.DeepEqual(result, testCase.expectedResult) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUnmarshalXML(t *testing.T) {
|
||||
dataCase1 := []byte(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
|
||||
dataCase2 := []byte(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
|
||||
dataCase3 := []byte(`
|
||||
<NotificationConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>2</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
|
||||
dataCase4 := []byte(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>
|
||||
<CloudFunctionConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>.jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Cloudcode>arn:aws:lambda:us-west-2:444455556666:cloud-function-A</Cloudcode>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</CloudFunctionConfiguration>
|
||||
<TopicConfiguration>
|
||||
<Topic>arn:aws:sns:us-west-2:444455556666:sns-notification-one</Topic>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
</TopicConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
|
||||
dataCase5 := []byte(`<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/" ></NotificationConfiguration>`)
|
||||
|
||||
testCases := []struct {
|
||||
data []byte
|
||||
expectErr bool
|
||||
}{
|
||||
{dataCase1, false},
|
||||
{dataCase2, false},
|
||||
{dataCase3, false},
|
||||
{dataCase4, true},
|
||||
// make sure we don't fail when queue is empty.
|
||||
{dataCase5, false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
err := xml.Unmarshal(testCase.data, &Config{})
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
data := []byte(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
config1 := &Config{}
|
||||
if err := xml.Unmarshal(data, config1); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data = []byte(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
config2 := &Config{}
|
||||
if err := xml.Unmarshal(data, config2); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data = []byte(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>2</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
config3 := &Config{}
|
||||
if err := xml.Unmarshal(data, config3); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
targetList1 := NewTargetList(t.Context())
|
||||
|
||||
targetList2 := NewTargetList(t.Context())
|
||||
if err := targetList2.Add(&ExampleTarget{TargetID{"1", "webhook"}, false, false}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
config *Config
|
||||
region string
|
||||
targetList *TargetList
|
||||
expectErr bool
|
||||
}{
|
||||
{config1, "eu-west-1", nil, true},
|
||||
{config2, "us-east-1", targetList1, true},
|
||||
{config3, "", targetList2, false},
|
||||
{config2, "us-east-1", targetList2, false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
err := testCase.config.Validate(testCase.region, testCase.targetList)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetRegion(t *testing.T) {
|
||||
data := []byte(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
config1 := &Config{}
|
||||
if err := xml.Unmarshal(data, config1); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data = []byte(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs::1:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
config2 := &Config{}
|
||||
if err := xml.Unmarshal(data, config2); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data = []byte(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>2</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:2:amqp</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
config3 := &Config{}
|
||||
if err := xml.Unmarshal(data, config3); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
config *Config
|
||||
region string
|
||||
expectedResult []ARN
|
||||
}{
|
||||
{config1, "eu-west-1", []ARN{{TargetID{"1", "webhook"}, "eu-west-1"}}},
|
||||
{config1, "", []ARN{{TargetID{"1", "webhook"}, ""}}},
|
||||
{config2, "us-east-1", []ARN{{TargetID{"1", "webhook"}, "us-east-1"}}},
|
||||
{config2, "", []ARN{{TargetID{"1", "webhook"}, ""}}},
|
||||
{config3, "us-east-1", []ARN{{TargetID{"1", "webhook"}, "us-east-1"}, {TargetID{"2", "amqp"}, "us-east-1"}}},
|
||||
{config3, "", []ARN{{TargetID{"1", "webhook"}, ""}, {TargetID{"2", "amqp"}, ""}}},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
testCase.config.SetRegion(testCase.region)
|
||||
result := []ARN{}
|
||||
for _, queue := range testCase.config.QueueList {
|
||||
result = append(result, queue.ARN)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(result, testCase.expectedResult) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigToRulesMap(t *testing.T) {
|
||||
data := []byte(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
config1 := &Config{}
|
||||
if err := xml.Unmarshal(data, config1); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data = []byte(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs::1:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
config2 := &Config{}
|
||||
if err := xml.Unmarshal(data, config2); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data = []byte(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>2</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:2:amqp</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
config3 := &Config{}
|
||||
if err := xml.Unmarshal(data, config3); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
rulesMapCase1 := NewRulesMap([]Name{ObjectAccessedAll, ObjectCreatedAll, ObjectRemovedAll}, "*", TargetID{"1", "webhook"})
|
||||
|
||||
rulesMapCase2 := NewRulesMap([]Name{ObjectCreatedPut}, "images/*jpg", TargetID{"1", "webhook"})
|
||||
|
||||
rulesMapCase3 := NewRulesMap([]Name{ObjectAccessedAll, ObjectCreatedAll, ObjectRemovedAll}, "*", TargetID{"1", "webhook"})
|
||||
rulesMapCase3.add([]Name{ObjectCreatedPut}, "images/*jpg", TargetID{"2", "amqp"})
|
||||
|
||||
testCases := []struct {
|
||||
config *Config
|
||||
expectedResult RulesMap
|
||||
}{
|
||||
{config1, rulesMapCase1},
|
||||
{config2, rulesMapCase2},
|
||||
{config3, rulesMapCase3},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.config.ToRulesMap()
|
||||
|
||||
if !reflect.DeepEqual(result, testCase.expectedResult) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfig(t *testing.T) {
|
||||
reader1 := strings.NewReader(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
|
||||
reader2 := strings.NewReader(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
|
||||
reader3 := strings.NewReader(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>
|
||||
<QueueConfiguration>
|
||||
<Id>2</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>prefix</Name>
|
||||
<Value>images/</Value>
|
||||
</FilterRule>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</QueueConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
|
||||
reader4 := strings.NewReader(`
|
||||
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<QueueConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter></Filter>
|
||||
<Queue>arn:minio:sqs:us-east-1:1:webhook</Queue>
|
||||
<Event>s3:ObjectAccessed:*</Event>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
<Event>s3:ObjectRemoved:*</Event>
|
||||
</QueueConfiguration>
|
||||
<CloudFunctionConfiguration>
|
||||
<Id>1</Id>
|
||||
<Filter>
|
||||
<S3Key>
|
||||
<FilterRule>
|
||||
<Name>suffix</Name>
|
||||
<Value>.jpg</Value>
|
||||
</FilterRule>
|
||||
</S3Key>
|
||||
</Filter>
|
||||
<Cloudcode>arn:aws:lambda:us-west-2:444455556666:cloud-function-A</Cloudcode>
|
||||
<Event>s3:ObjectCreated:Put</Event>
|
||||
</CloudFunctionConfiguration>
|
||||
<TopicConfiguration>
|
||||
<Topic>arn:aws:sns:us-west-2:444455556666:sns-notification-one</Topic>
|
||||
<Event>s3:ObjectCreated:*</Event>
|
||||
</TopicConfiguration>
|
||||
</NotificationConfiguration>
|
||||
`)
|
||||
|
||||
targetList1 := NewTargetList(t.Context())
|
||||
|
||||
targetList2 := NewTargetList(t.Context())
|
||||
if err := targetList2.Add(&ExampleTarget{TargetID{"1", "webhook"}, false, false}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
reader *strings.Reader
|
||||
region string
|
||||
targetList *TargetList
|
||||
expectErr bool
|
||||
}{
|
||||
{reader1, "eu-west-1", nil, true},
|
||||
{reader2, "us-east-1", targetList1, true},
|
||||
{reader4, "us-east-1", targetList1, true},
|
||||
{reader3, "", targetList2, false},
|
||||
{reader2, "us-east-1", targetList2, false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
if _, err := testCase.reader.Seek(0, 0); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_, err := ParseConfig(testCase.reader, testCase.region, testCase.targetList)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
153
internal/event/errors.go
Normal file
153
internal/event/errors.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// IsEventError - checks whether given error is event error or not.
|
||||
func IsEventError(err error) bool {
|
||||
switch err.(type) {
|
||||
case ErrInvalidFilterName, *ErrInvalidFilterName:
|
||||
return true
|
||||
case ErrFilterNamePrefix, *ErrFilterNamePrefix:
|
||||
return true
|
||||
case ErrFilterNameSuffix, *ErrFilterNameSuffix:
|
||||
return true
|
||||
case ErrInvalidFilterValue, *ErrInvalidFilterValue:
|
||||
return true
|
||||
case ErrDuplicateEventName, *ErrDuplicateEventName:
|
||||
return true
|
||||
case ErrUnsupportedConfiguration, *ErrUnsupportedConfiguration:
|
||||
return true
|
||||
case ErrDuplicateQueueConfiguration, *ErrDuplicateQueueConfiguration:
|
||||
return true
|
||||
case ErrUnknownRegion, *ErrUnknownRegion:
|
||||
return true
|
||||
case ErrARNNotFound, *ErrARNNotFound:
|
||||
return true
|
||||
case ErrInvalidARN, *ErrInvalidARN:
|
||||
return true
|
||||
case ErrInvalidEventName, *ErrInvalidEventName:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ErrInvalidFilterName - invalid filter name error.
|
||||
type ErrInvalidFilterName struct {
|
||||
FilterName string
|
||||
}
|
||||
|
||||
func (err ErrInvalidFilterName) Error() string {
|
||||
return fmt.Sprintf("invalid filter name '%v'", err.FilterName)
|
||||
}
|
||||
|
||||
// ErrFilterNamePrefix - more than one prefix usage error.
|
||||
type ErrFilterNamePrefix struct{}
|
||||
|
||||
func (err ErrFilterNamePrefix) Error() string {
|
||||
return "more than one prefix in filter rule"
|
||||
}
|
||||
|
||||
// ErrFilterNameSuffix - more than one suffix usage error.
|
||||
type ErrFilterNameSuffix struct{}
|
||||
|
||||
func (err ErrFilterNameSuffix) Error() string {
|
||||
return "more than one suffix in filter rule"
|
||||
}
|
||||
|
||||
// ErrInvalidFilterValue - invalid filter value error.
|
||||
type ErrInvalidFilterValue struct {
|
||||
FilterValue string
|
||||
}
|
||||
|
||||
func (err ErrInvalidFilterValue) Error() string {
|
||||
return fmt.Sprintf("invalid filter value '%v'", err.FilterValue)
|
||||
}
|
||||
|
||||
// ErrDuplicateEventName - duplicate event name error.
|
||||
type ErrDuplicateEventName struct {
|
||||
EventName Name
|
||||
}
|
||||
|
||||
func (err ErrDuplicateEventName) Error() string {
|
||||
return fmt.Sprintf("duplicate event name '%v' found", err.EventName)
|
||||
}
|
||||
|
||||
// ErrUnsupportedConfiguration - unsupported configuration error.
|
||||
type ErrUnsupportedConfiguration struct{}
|
||||
|
||||
func (err ErrUnsupportedConfiguration) Error() string {
|
||||
return "topic or cloud function configuration is not supported"
|
||||
}
|
||||
|
||||
// ErrDuplicateQueueConfiguration - duplicate queue configuration error.
|
||||
type ErrDuplicateQueueConfiguration struct {
|
||||
Queue Queue
|
||||
}
|
||||
|
||||
func (err ErrDuplicateQueueConfiguration) Error() string {
|
||||
var message string
|
||||
if data, xerr := xml.Marshal(err.Queue); xerr != nil {
|
||||
message = fmt.Sprintf("%+v", err.Queue)
|
||||
} else {
|
||||
message = string(data)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("duplicate queue configuration %v", message)
|
||||
}
|
||||
|
||||
// ErrUnknownRegion - unknown region error.
|
||||
type ErrUnknownRegion struct {
|
||||
Region string
|
||||
}
|
||||
|
||||
func (err ErrUnknownRegion) Error() string {
|
||||
return fmt.Sprintf("unknown region '%v'", err.Region)
|
||||
}
|
||||
|
||||
// ErrARNNotFound - ARN not found error.
|
||||
type ErrARNNotFound struct {
|
||||
ARN ARN
|
||||
}
|
||||
|
||||
func (err ErrARNNotFound) Error() string {
|
||||
return fmt.Sprintf("ARN '%v' not found", err.ARN)
|
||||
}
|
||||
|
||||
// ErrInvalidARN - invalid ARN error.
|
||||
type ErrInvalidARN struct {
|
||||
ARN string
|
||||
}
|
||||
|
||||
func (err ErrInvalidARN) Error() string {
|
||||
return fmt.Sprintf("invalid ARN '%v'", err.ARN)
|
||||
}
|
||||
|
||||
// ErrInvalidEventName - invalid event name error.
|
||||
type ErrInvalidEventName struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (err ErrInvalidEventName) Error() string {
|
||||
return fmt.Sprintf("invalid event name '%v'", err.Name)
|
||||
}
|
||||
102
internal/event/event.go
Normal file
102
internal/event/event.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/minio/madmin-go/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
// NamespaceFormat - namespace log format used in some event targets.
|
||||
NamespaceFormat = "namespace"
|
||||
|
||||
// AccessFormat - access log format used in some event targets.
|
||||
AccessFormat = "access"
|
||||
|
||||
// AMZTimeFormat - event time format.
|
||||
AMZTimeFormat = "2006-01-02T15:04:05.000Z"
|
||||
|
||||
// StoreExtension - file extension of an event file in store
|
||||
StoreExtension = ".event"
|
||||
)
|
||||
|
||||
// Identity represents access key who caused the event.
|
||||
type Identity struct {
|
||||
PrincipalID string `json:"principalId"`
|
||||
}
|
||||
|
||||
// Bucket represents bucket metadata of the event.
|
||||
type Bucket struct {
|
||||
Name string `json:"name"`
|
||||
OwnerIdentity Identity `json:"ownerIdentity"`
|
||||
ARN string `json:"arn"`
|
||||
}
|
||||
|
||||
// Object represents object metadata of the event.
|
||||
type Object struct {
|
||||
Key string `json:"key"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
ETag string `json:"eTag,omitempty"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
UserMetadata map[string]string `json:"userMetadata,omitempty"`
|
||||
VersionID string `json:"versionId,omitempty"`
|
||||
Sequencer string `json:"sequencer"`
|
||||
}
|
||||
|
||||
// Metadata represents event metadata.
|
||||
type Metadata struct {
|
||||
SchemaVersion string `json:"s3SchemaVersion"`
|
||||
ConfigurationID string `json:"configurationId"`
|
||||
Bucket Bucket `json:"bucket"`
|
||||
Object Object `json:"object"`
|
||||
}
|
||||
|
||||
// Source represents client information who triggered the event.
|
||||
type Source struct {
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
}
|
||||
|
||||
// Event represents event notification information defined in
|
||||
// http://docs.aws.amazon.com/AmazonS3/latest/dev/notification-content-structure.html.
|
||||
type Event struct {
|
||||
EventVersion string `json:"eventVersion"`
|
||||
EventSource string `json:"eventSource"`
|
||||
AwsRegion string `json:"awsRegion"`
|
||||
EventTime string `json:"eventTime"`
|
||||
EventName Name `json:"eventName"`
|
||||
UserIdentity Identity `json:"userIdentity"`
|
||||
RequestParameters map[string]string `json:"requestParameters"`
|
||||
ResponseElements map[string]string `json:"responseElements"`
|
||||
S3 Metadata `json:"s3"`
|
||||
Source Source `json:"source"`
|
||||
Type madmin.TraceType `json:"-"`
|
||||
}
|
||||
|
||||
// Mask returns the type as mask.
|
||||
func (e Event) Mask() uint64 {
|
||||
return e.EventName.Mask()
|
||||
}
|
||||
|
||||
// Log represents event information for some event targets.
|
||||
type Log struct {
|
||||
EventName Name
|
||||
Key string
|
||||
Records []Event
|
||||
}
|
||||
364
internal/event/name.go
Normal file
364
internal/event/name.go
Normal file
@@ -0,0 +1,364 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
)
|
||||
|
||||
// Name - event type enum.
|
||||
// Refer http://docs.aws.amazon.com/AmazonS3/latest/dev/NotificationHowTo.html#notification-how-to-event-types-and-destinations
|
||||
// for most basic values we have since extend this and its not really much applicable other than a reference point.
|
||||
// "s3:Replication:OperationCompletedReplication" is a MinIO extension.
|
||||
type Name int
|
||||
|
||||
// Values of event Name
|
||||
const (
|
||||
// Single event types (does not require expansion)
|
||||
|
||||
ObjectAccessedGet Name = 1 + iota
|
||||
ObjectAccessedGetRetention
|
||||
ObjectAccessedGetLegalHold
|
||||
ObjectAccessedHead
|
||||
ObjectAccessedAttributes
|
||||
ObjectCreatedCompleteMultipartUpload
|
||||
ObjectCreatedCopy
|
||||
ObjectCreatedPost
|
||||
ObjectCreatedPut
|
||||
ObjectCreatedPutRetention
|
||||
ObjectCreatedPutLegalHold
|
||||
ObjectCreatedPutTagging
|
||||
ObjectCreatedDeleteTagging
|
||||
ObjectRemovedDelete
|
||||
ObjectRemovedDeleteMarkerCreated
|
||||
ObjectRemovedDeleteAllVersions
|
||||
ObjectRemovedNoOP
|
||||
BucketCreated
|
||||
BucketRemoved
|
||||
ObjectReplicationFailed
|
||||
ObjectReplicationComplete
|
||||
ObjectReplicationMissedThreshold
|
||||
ObjectReplicationReplicatedAfterThreshold
|
||||
ObjectReplicationNotTracked
|
||||
ObjectRestorePost
|
||||
ObjectRestoreCompleted
|
||||
ObjectTransitionFailed
|
||||
ObjectTransitionComplete
|
||||
ObjectManyVersions
|
||||
ObjectLargeVersions
|
||||
PrefixManyFolders
|
||||
ILMDelMarkerExpirationDelete
|
||||
|
||||
objectSingleTypesEnd
|
||||
// Start Compound types that require expansion:
|
||||
|
||||
ObjectAccessedAll
|
||||
ObjectCreatedAll
|
||||
ObjectRemovedAll
|
||||
ObjectReplicationAll
|
||||
ObjectRestoreAll
|
||||
ObjectTransitionAll
|
||||
ObjectScannerAll
|
||||
Everything
|
||||
)
|
||||
|
||||
// The number of single names should not exceed 64.
|
||||
// This will break masking. Use bit 63 as extension.
|
||||
var _ = uint64(1 << objectSingleTypesEnd)
|
||||
|
||||
// Expand - returns expanded values of abbreviated event type.
|
||||
func (name Name) Expand() []Name {
|
||||
switch name {
|
||||
case ObjectAccessedAll:
|
||||
return []Name{
|
||||
ObjectAccessedGet, ObjectAccessedHead,
|
||||
ObjectAccessedGetRetention, ObjectAccessedGetLegalHold, ObjectAccessedAttributes,
|
||||
}
|
||||
case ObjectCreatedAll:
|
||||
return []Name{
|
||||
ObjectCreatedCompleteMultipartUpload, ObjectCreatedCopy,
|
||||
ObjectCreatedPost, ObjectCreatedPut,
|
||||
ObjectCreatedPutRetention, ObjectCreatedPutLegalHold,
|
||||
ObjectCreatedPutTagging, ObjectCreatedDeleteTagging,
|
||||
}
|
||||
case ObjectRemovedAll:
|
||||
return []Name{
|
||||
ObjectRemovedDelete,
|
||||
ObjectRemovedDeleteMarkerCreated,
|
||||
ObjectRemovedNoOP,
|
||||
ObjectRemovedDeleteAllVersions,
|
||||
}
|
||||
case ObjectReplicationAll:
|
||||
return []Name{
|
||||
ObjectReplicationFailed,
|
||||
ObjectReplicationComplete,
|
||||
ObjectReplicationNotTracked,
|
||||
ObjectReplicationMissedThreshold,
|
||||
ObjectReplicationReplicatedAfterThreshold,
|
||||
}
|
||||
case ObjectRestoreAll:
|
||||
return []Name{
|
||||
ObjectRestorePost,
|
||||
ObjectRestoreCompleted,
|
||||
}
|
||||
case ObjectTransitionAll:
|
||||
return []Name{
|
||||
ObjectTransitionFailed,
|
||||
ObjectTransitionComplete,
|
||||
}
|
||||
case ObjectScannerAll:
|
||||
return []Name{
|
||||
ObjectManyVersions,
|
||||
ObjectLargeVersions,
|
||||
PrefixManyFolders,
|
||||
}
|
||||
case Everything:
|
||||
res := make([]Name, objectSingleTypesEnd-1)
|
||||
for i := range res {
|
||||
res[i] = Name(i + 1)
|
||||
}
|
||||
return res
|
||||
default:
|
||||
return []Name{name}
|
||||
}
|
||||
}
|
||||
|
||||
// Mask returns the type as mask.
|
||||
// Compound "All" types are expanded.
|
||||
func (name Name) Mask() uint64 {
|
||||
if name < objectSingleTypesEnd {
|
||||
return 1 << (name - 1)
|
||||
}
|
||||
var mask uint64
|
||||
for _, n := range name.Expand() {
|
||||
mask |= 1 << (n - 1)
|
||||
}
|
||||
return mask
|
||||
}
|
||||
|
||||
// String - returns string representation of event type.
|
||||
func (name Name) String() string {
|
||||
switch name {
|
||||
case BucketCreated:
|
||||
return "s3:BucketCreated:*"
|
||||
case BucketRemoved:
|
||||
return "s3:BucketRemoved:*"
|
||||
case ObjectAccessedAll:
|
||||
return "s3:ObjectAccessed:*"
|
||||
case ObjectAccessedGet:
|
||||
return "s3:ObjectAccessed:Get"
|
||||
case ObjectAccessedGetRetention:
|
||||
return "s3:ObjectAccessed:GetRetention"
|
||||
case ObjectAccessedGetLegalHold:
|
||||
return "s3:ObjectAccessed:GetLegalHold"
|
||||
case ObjectAccessedHead:
|
||||
return "s3:ObjectAccessed:Head"
|
||||
case ObjectAccessedAttributes:
|
||||
return "s3:ObjectAccessed:Attributes"
|
||||
case ObjectCreatedAll:
|
||||
return "s3:ObjectCreated:*"
|
||||
case ObjectCreatedCompleteMultipartUpload:
|
||||
return "s3:ObjectCreated:CompleteMultipartUpload"
|
||||
case ObjectCreatedCopy:
|
||||
return "s3:ObjectCreated:Copy"
|
||||
case ObjectCreatedPost:
|
||||
return "s3:ObjectCreated:Post"
|
||||
case ObjectCreatedPut:
|
||||
return "s3:ObjectCreated:Put"
|
||||
case ObjectCreatedPutTagging:
|
||||
return "s3:ObjectCreated:PutTagging"
|
||||
case ObjectCreatedDeleteTagging:
|
||||
return "s3:ObjectCreated:DeleteTagging"
|
||||
case ObjectCreatedPutRetention:
|
||||
return "s3:ObjectCreated:PutRetention"
|
||||
case ObjectCreatedPutLegalHold:
|
||||
return "s3:ObjectCreated:PutLegalHold"
|
||||
case ObjectRemovedAll:
|
||||
return "s3:ObjectRemoved:*"
|
||||
case ObjectRemovedDelete:
|
||||
return "s3:ObjectRemoved:Delete"
|
||||
case ObjectRemovedDeleteMarkerCreated:
|
||||
return "s3:ObjectRemoved:DeleteMarkerCreated"
|
||||
case ObjectRemovedNoOP:
|
||||
return "s3:ObjectRemoved:NoOP"
|
||||
case ObjectRemovedDeleteAllVersions:
|
||||
return "s3:ObjectRemoved:DeleteAllVersions"
|
||||
case ILMDelMarkerExpirationDelete:
|
||||
return "s3:LifecycleDelMarkerExpiration:Delete"
|
||||
case ObjectReplicationAll:
|
||||
return "s3:Replication:*"
|
||||
case ObjectReplicationFailed:
|
||||
return "s3:Replication:OperationFailedReplication"
|
||||
case ObjectReplicationComplete:
|
||||
return "s3:Replication:OperationCompletedReplication"
|
||||
case ObjectReplicationNotTracked:
|
||||
return "s3:Replication:OperationNotTracked"
|
||||
case ObjectReplicationMissedThreshold:
|
||||
return "s3:Replication:OperationMissedThreshold"
|
||||
case ObjectReplicationReplicatedAfterThreshold:
|
||||
return "s3:Replication:OperationReplicatedAfterThreshold"
|
||||
case ObjectRestoreAll:
|
||||
return "s3:ObjectRestore:*"
|
||||
case ObjectRestorePost:
|
||||
return "s3:ObjectRestore:Post"
|
||||
case ObjectRestoreCompleted:
|
||||
return "s3:ObjectRestore:Completed"
|
||||
case ObjectTransitionAll:
|
||||
return "s3:ObjectTransition:*"
|
||||
case ObjectTransitionFailed:
|
||||
return "s3:ObjectTransition:Failed"
|
||||
case ObjectTransitionComplete:
|
||||
return "s3:ObjectTransition:Complete"
|
||||
case ObjectManyVersions:
|
||||
return "s3:Scanner:ManyVersions"
|
||||
case ObjectLargeVersions:
|
||||
return "s3:Scanner:LargeVersions"
|
||||
|
||||
case PrefixManyFolders:
|
||||
return "s3:Scanner:BigPrefix"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// MarshalXML - encodes to XML data.
|
||||
func (name Name) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
return e.EncodeElement(name.String(), start)
|
||||
}
|
||||
|
||||
// UnmarshalXML - decodes XML data.
|
||||
func (name *Name) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
var s string
|
||||
if err := d.DecodeElement(&s, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventName, err := ParseName(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*name = eventName
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON - encodes to JSON data.
|
||||
func (name Name) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(name.String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON - decodes JSON data.
|
||||
func (name *Name) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventName, err := ParseName(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*name = eventName
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseName - parses string to Name.
|
||||
func ParseName(s string) (Name, error) {
|
||||
switch s {
|
||||
case "s3:BucketCreated:*":
|
||||
return BucketCreated, nil
|
||||
case "s3:BucketRemoved:*":
|
||||
return BucketRemoved, nil
|
||||
case "s3:ObjectAccessed:*":
|
||||
return ObjectAccessedAll, nil
|
||||
case "s3:ObjectAccessed:Get":
|
||||
return ObjectAccessedGet, nil
|
||||
case "s3:ObjectAccessed:GetRetention":
|
||||
return ObjectAccessedGetRetention, nil
|
||||
case "s3:ObjectAccessed:GetLegalHold":
|
||||
return ObjectAccessedGetLegalHold, nil
|
||||
case "s3:ObjectAccessed:Head":
|
||||
return ObjectAccessedHead, nil
|
||||
case "s3:ObjectAccessed:Attributes":
|
||||
return ObjectAccessedAttributes, nil
|
||||
case "s3:ObjectCreated:*":
|
||||
return ObjectCreatedAll, nil
|
||||
case "s3:ObjectCreated:CompleteMultipartUpload":
|
||||
return ObjectCreatedCompleteMultipartUpload, nil
|
||||
case "s3:ObjectCreated:Copy":
|
||||
return ObjectCreatedCopy, nil
|
||||
case "s3:ObjectCreated:Post":
|
||||
return ObjectCreatedPost, nil
|
||||
case "s3:ObjectCreated:Put":
|
||||
return ObjectCreatedPut, nil
|
||||
case "s3:ObjectCreated:PutRetention":
|
||||
return ObjectCreatedPutRetention, nil
|
||||
case "s3:ObjectCreated:PutLegalHold":
|
||||
return ObjectCreatedPutLegalHold, nil
|
||||
case "s3:ObjectCreated:PutTagging":
|
||||
return ObjectCreatedPutTagging, nil
|
||||
case "s3:ObjectCreated:DeleteTagging":
|
||||
return ObjectCreatedDeleteTagging, nil
|
||||
case "s3:ObjectRemoved:*":
|
||||
return ObjectRemovedAll, nil
|
||||
case "s3:ObjectRemoved:Delete":
|
||||
return ObjectRemovedDelete, nil
|
||||
case "s3:ObjectRemoved:DeleteMarkerCreated":
|
||||
return ObjectRemovedDeleteMarkerCreated, nil
|
||||
case "s3:ObjectRemoved:NoOP":
|
||||
return ObjectRemovedNoOP, nil
|
||||
case "s3:ObjectRemoved:DeleteAllVersions":
|
||||
return ObjectRemovedDeleteAllVersions, nil
|
||||
case "s3:LifecycleDelMarkerExpiration:Delete":
|
||||
return ILMDelMarkerExpirationDelete, nil
|
||||
case "s3:Replication:*":
|
||||
return ObjectReplicationAll, nil
|
||||
case "s3:Replication:OperationFailedReplication":
|
||||
return ObjectReplicationFailed, nil
|
||||
case "s3:Replication:OperationCompletedReplication":
|
||||
return ObjectReplicationComplete, nil
|
||||
case "s3:Replication:OperationMissedThreshold":
|
||||
return ObjectReplicationMissedThreshold, nil
|
||||
case "s3:Replication:OperationReplicatedAfterThreshold":
|
||||
return ObjectReplicationReplicatedAfterThreshold, nil
|
||||
case "s3:Replication:OperationNotTracked":
|
||||
return ObjectReplicationNotTracked, nil
|
||||
case "s3:ObjectRestore:*":
|
||||
return ObjectRestoreAll, nil
|
||||
case "s3:ObjectRestore:Post":
|
||||
return ObjectRestorePost, nil
|
||||
case "s3:ObjectRestore:Completed":
|
||||
return ObjectRestoreCompleted, nil
|
||||
case "s3:ObjectTransition:Failed":
|
||||
return ObjectTransitionFailed, nil
|
||||
case "s3:ObjectTransition:Complete":
|
||||
return ObjectTransitionComplete, nil
|
||||
case "s3:ObjectTransition:*":
|
||||
return ObjectTransitionAll, nil
|
||||
case "s3:Scanner:ManyVersions":
|
||||
return ObjectManyVersions, nil
|
||||
case "s3:Scanner:LargeVersions":
|
||||
return ObjectLargeVersions, nil
|
||||
case "s3:Scanner:BigPrefix":
|
||||
return PrefixManyFolders, nil
|
||||
default:
|
||||
return 0, &ErrInvalidEventName{s}
|
||||
}
|
||||
}
|
||||
242
internal/event/name_test.go
Normal file
242
internal/event/name_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNameExpand(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name Name
|
||||
expectedResult []Name
|
||||
}{
|
||||
{BucketCreated, []Name{BucketCreated}},
|
||||
{BucketRemoved, []Name{BucketRemoved}},
|
||||
{ObjectAccessedAll, []Name{ObjectAccessedGet, ObjectAccessedHead, ObjectAccessedGetRetention, ObjectAccessedGetLegalHold, ObjectAccessedAttributes}},
|
||||
{ObjectCreatedAll, []Name{
|
||||
ObjectCreatedCompleteMultipartUpload, ObjectCreatedCopy, ObjectCreatedPost, ObjectCreatedPut,
|
||||
ObjectCreatedPutRetention, ObjectCreatedPutLegalHold, ObjectCreatedPutTagging, ObjectCreatedDeleteTagging,
|
||||
}},
|
||||
{ObjectRemovedAll, []Name{ObjectRemovedDelete, ObjectRemovedDeleteMarkerCreated, ObjectRemovedNoOP, ObjectRemovedDeleteAllVersions}},
|
||||
{ObjectAccessedHead, []Name{ObjectAccessedHead}},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.name.Expand()
|
||||
|
||||
if !reflect.DeepEqual(result, testCase.expectedResult) {
|
||||
t.Errorf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameString(t *testing.T) {
|
||||
var blankName Name
|
||||
|
||||
testCases := []struct {
|
||||
name Name
|
||||
expectedResult string
|
||||
}{
|
||||
{BucketCreated, "s3:BucketCreated:*"},
|
||||
{BucketRemoved, "s3:BucketRemoved:*"},
|
||||
{ObjectAccessedAll, "s3:ObjectAccessed:*"},
|
||||
{ObjectAccessedGet, "s3:ObjectAccessed:Get"},
|
||||
{ObjectAccessedHead, "s3:ObjectAccessed:Head"},
|
||||
{ObjectCreatedAll, "s3:ObjectCreated:*"},
|
||||
{ObjectCreatedCompleteMultipartUpload, "s3:ObjectCreated:CompleteMultipartUpload"},
|
||||
{ObjectCreatedCopy, "s3:ObjectCreated:Copy"},
|
||||
{ObjectCreatedPost, "s3:ObjectCreated:Post"},
|
||||
{ObjectCreatedPut, "s3:ObjectCreated:Put"},
|
||||
{ObjectRemovedAll, "s3:ObjectRemoved:*"},
|
||||
{ObjectRemovedDelete, "s3:ObjectRemoved:Delete"},
|
||||
{ObjectRemovedDeleteAllVersions, "s3:ObjectRemoved:DeleteAllVersions"},
|
||||
{ILMDelMarkerExpirationDelete, "s3:LifecycleDelMarkerExpiration:Delete"},
|
||||
{ObjectRemovedNoOP, "s3:ObjectRemoved:NoOP"},
|
||||
{ObjectCreatedPutRetention, "s3:ObjectCreated:PutRetention"},
|
||||
{ObjectCreatedPutLegalHold, "s3:ObjectCreated:PutLegalHold"},
|
||||
{ObjectAccessedGetRetention, "s3:ObjectAccessed:GetRetention"},
|
||||
{ObjectAccessedGetLegalHold, "s3:ObjectAccessed:GetLegalHold"},
|
||||
|
||||
{blankName, ""},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.name.String()
|
||||
|
||||
if result != testCase.expectedResult {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameMarshalXML(t *testing.T) {
|
||||
var blankName Name
|
||||
|
||||
testCases := []struct {
|
||||
name Name
|
||||
expectedData []byte
|
||||
expectErr bool
|
||||
}{
|
||||
{ObjectAccessedAll, []byte("<Name>s3:ObjectAccessed:*</Name>"), false},
|
||||
{ObjectRemovedDelete, []byte("<Name>s3:ObjectRemoved:Delete</Name>"), false},
|
||||
{ObjectRemovedNoOP, []byte("<Name>s3:ObjectRemoved:NoOP</Name>"), false},
|
||||
{blankName, []byte("<Name></Name>"), false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
data, err := xml.Marshal(testCase.name)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
|
||||
if !testCase.expectErr {
|
||||
if !reflect.DeepEqual(data, testCase.expectedData) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, string(testCase.expectedData), string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameUnmarshalXML(t *testing.T) {
|
||||
var blankName Name
|
||||
|
||||
testCases := []struct {
|
||||
data []byte
|
||||
expectedName Name
|
||||
expectErr bool
|
||||
}{
|
||||
{[]byte("<Name>s3:ObjectAccessed:*</Name>"), ObjectAccessedAll, false},
|
||||
{[]byte("<Name>s3:ObjectRemoved:Delete</Name>"), ObjectRemovedDelete, false},
|
||||
{[]byte("<Name>s3:ObjectRemoved:NoOP</Name>"), ObjectRemovedNoOP, false},
|
||||
{[]byte("<Name></Name>"), blankName, true},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
var name Name
|
||||
err := xml.Unmarshal(testCase.data, &name)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
|
||||
if !testCase.expectErr {
|
||||
if !reflect.DeepEqual(name, testCase.expectedName) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedName, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameMarshalJSON(t *testing.T) {
|
||||
var blankName Name
|
||||
|
||||
testCases := []struct {
|
||||
name Name
|
||||
expectedData []byte
|
||||
expectErr bool
|
||||
}{
|
||||
{ObjectAccessedAll, []byte(`"s3:ObjectAccessed:*"`), false},
|
||||
{ObjectRemovedDelete, []byte(`"s3:ObjectRemoved:Delete"`), false},
|
||||
{ObjectRemovedNoOP, []byte(`"s3:ObjectRemoved:NoOP"`), false},
|
||||
{blankName, []byte(`""`), false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
data, err := json.Marshal(testCase.name)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
|
||||
if !testCase.expectErr {
|
||||
if !reflect.DeepEqual(data, testCase.expectedData) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, string(testCase.expectedData), string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameUnmarshalJSON(t *testing.T) {
|
||||
var blankName Name
|
||||
|
||||
testCases := []struct {
|
||||
data []byte
|
||||
expectedName Name
|
||||
expectErr bool
|
||||
}{
|
||||
{[]byte(`"s3:ObjectAccessed:*"`), ObjectAccessedAll, false},
|
||||
{[]byte(`"s3:ObjectRemoved:Delete"`), ObjectRemovedDelete, false},
|
||||
{[]byte(`"s3:ObjectRemoved:NoOP"`), ObjectRemovedNoOP, false},
|
||||
{[]byte(`""`), blankName, true},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
var name Name
|
||||
err := json.Unmarshal(testCase.data, &name)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
|
||||
if !testCase.expectErr {
|
||||
if !reflect.DeepEqual(name, testCase.expectedName) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedName, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseName(t *testing.T) {
|
||||
var blankName Name
|
||||
|
||||
testCases := []struct {
|
||||
s string
|
||||
expectedName Name
|
||||
expectErr bool
|
||||
}{
|
||||
{"s3:ObjectAccessed:*", ObjectAccessedAll, false},
|
||||
{"s3:ObjectRemoved:Delete", ObjectRemovedDelete, false},
|
||||
{"s3:ObjectRemoved:NoOP", ObjectRemovedNoOP, false},
|
||||
{"s3:LifecycleDelMarkerExpiration:Delete", ILMDelMarkerExpirationDelete, false},
|
||||
{"", blankName, true},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
name, err := ParseName(testCase.s)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
|
||||
if !testCase.expectErr {
|
||||
if !reflect.DeepEqual(name, testCase.expectedName) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedName, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
internal/event/rules.go
Normal file
113
internal/event/rules.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/minio/pkg/v3/wildcard"
|
||||
)
|
||||
|
||||
// NewPattern - create new pattern for prefix/suffix.
|
||||
func NewPattern(prefix, suffix string) (pattern string) {
|
||||
if prefix != "" {
|
||||
if !strings.HasSuffix(prefix, "*") {
|
||||
prefix += "*"
|
||||
}
|
||||
|
||||
pattern = prefix
|
||||
}
|
||||
|
||||
if suffix != "" {
|
||||
if !strings.HasPrefix(suffix, "*") {
|
||||
suffix = "*" + suffix
|
||||
}
|
||||
|
||||
pattern += suffix
|
||||
}
|
||||
|
||||
pattern = strings.ReplaceAll(pattern, "**", "*")
|
||||
|
||||
return pattern
|
||||
}
|
||||
|
||||
// Rules - event rules
|
||||
type Rules map[string]TargetIDSet
|
||||
|
||||
// Add - adds pattern and target ID.
|
||||
func (rules Rules) Add(pattern string, targetID TargetID) {
|
||||
rules[pattern] = NewTargetIDSet(targetID).Union(rules[pattern])
|
||||
}
|
||||
|
||||
// MatchSimple - returns true one of the matching object name in rules.
|
||||
func (rules Rules) MatchSimple(objectName string) bool {
|
||||
for pattern := range rules {
|
||||
if wildcard.MatchSimple(pattern, objectName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Match - returns TargetIDSet matching object name in rules.
|
||||
func (rules Rules) Match(objectName string) TargetIDSet {
|
||||
targetIDs := NewTargetIDSet()
|
||||
|
||||
for pattern, targetIDSet := range rules {
|
||||
if wildcard.MatchSimple(pattern, objectName) {
|
||||
targetIDs = targetIDs.Union(targetIDSet)
|
||||
}
|
||||
}
|
||||
|
||||
return targetIDs
|
||||
}
|
||||
|
||||
// Clone - returns copy of this rules.
|
||||
func (rules Rules) Clone() Rules {
|
||||
rulesCopy := make(Rules)
|
||||
|
||||
for pattern, targetIDSet := range rules {
|
||||
rulesCopy[pattern] = targetIDSet.Clone()
|
||||
}
|
||||
|
||||
return rulesCopy
|
||||
}
|
||||
|
||||
// Union - returns union with given rules as new rules.
|
||||
func (rules Rules) Union(rules2 Rules) Rules {
|
||||
nrules := rules.Clone()
|
||||
|
||||
for pattern, targetIDSet := range rules2 {
|
||||
nrules[pattern] = nrules[pattern].Union(targetIDSet)
|
||||
}
|
||||
|
||||
return nrules
|
||||
}
|
||||
|
||||
// Difference - returns difference with given rules as new rules.
|
||||
func (rules Rules) Difference(rules2 Rules) Rules {
|
||||
nrules := make(Rules)
|
||||
|
||||
for pattern, targetIDSet := range rules {
|
||||
if nv := targetIDSet.Difference(rules2[pattern]); len(nv) > 0 {
|
||||
nrules[pattern] = nv
|
||||
}
|
||||
}
|
||||
|
||||
return nrules
|
||||
}
|
||||
276
internal/event/rules_test.go
Normal file
276
internal/event/rules_test.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewPattern(t *testing.T) {
|
||||
testCases := []struct {
|
||||
prefix string
|
||||
suffix string
|
||||
expectedResult string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"*", "", "*"},
|
||||
{"", "*", "*"},
|
||||
{"images/", "", "images/*"},
|
||||
{"images/*", "", "images/*"},
|
||||
{"", "jpg", "*jpg"},
|
||||
{"", "*jpg", "*jpg"},
|
||||
{"images/", "jpg", "images/*jpg"},
|
||||
{"images/*", "jpg", "images/*jpg"},
|
||||
{"images/", "*jpg", "images/*jpg"},
|
||||
{"images/*", "*jpg", "images/*jpg"},
|
||||
{"201*/images/", "jpg", "201*/images/*jpg"},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := NewPattern(testCase.prefix, testCase.suffix)
|
||||
|
||||
if result != testCase.expectedResult {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesAdd(t *testing.T) {
|
||||
rulesCase1 := make(Rules)
|
||||
|
||||
rulesCase2 := make(Rules)
|
||||
rulesCase2.Add(NewPattern("2010*", ""), TargetID{"1", "webhook"})
|
||||
|
||||
rulesCase3 := make(Rules)
|
||||
rulesCase3.Add(NewPattern("2010*", ""), TargetID{"1", "webhook"})
|
||||
|
||||
rulesCase4 := make(Rules)
|
||||
rulesCase4.Add(NewPattern("", "*.jpg"), TargetID{"1", "webhook"})
|
||||
|
||||
rulesCase5 := make(Rules)
|
||||
|
||||
rulesCase6 := make(Rules)
|
||||
rulesCase6.Add(NewPattern("", "*.jpg"), TargetID{"1", "webhook"})
|
||||
|
||||
rulesCase7 := make(Rules)
|
||||
rulesCase7.Add(NewPattern("", "*.jpg"), TargetID{"1", "webhook"})
|
||||
|
||||
rulesCase8 := make(Rules)
|
||||
rulesCase8.Add(NewPattern("2010*", ""), TargetID{"1", "webhook"})
|
||||
|
||||
testCases := []struct {
|
||||
rules Rules
|
||||
pattern string
|
||||
targetID TargetID
|
||||
expectedResult int
|
||||
}{
|
||||
{rulesCase1, NewPattern("*", ""), TargetID{"1", "webhook"}, 1},
|
||||
{rulesCase2, NewPattern("*", ""), TargetID{"2", "amqp"}, 2},
|
||||
{rulesCase3, NewPattern("2010*", ""), TargetID{"1", "webhook"}, 1},
|
||||
{rulesCase4, NewPattern("*", ""), TargetID{"1", "webhook"}, 2},
|
||||
{rulesCase5, NewPattern("", "*.jpg"), TargetID{"1", "webhook"}, 1},
|
||||
{rulesCase6, NewPattern("", "*"), TargetID{"2", "amqp"}, 2},
|
||||
{rulesCase7, NewPattern("", "*.jpg"), TargetID{"1", "webhook"}, 1},
|
||||
{rulesCase8, NewPattern("", "*.jpg"), TargetID{"1", "webhook"}, 2},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
testCase.rules.Add(testCase.pattern, testCase.targetID)
|
||||
result := len(testCase.rules)
|
||||
|
||||
if result != testCase.expectedResult {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesMatch(t *testing.T) {
|
||||
rulesCase1 := make(Rules)
|
||||
|
||||
rulesCase2 := make(Rules)
|
||||
rulesCase2.Add(NewPattern("*", "*"), TargetID{"1", "webhook"})
|
||||
|
||||
rulesCase3 := make(Rules)
|
||||
rulesCase3.Add(NewPattern("2010*", ""), TargetID{"1", "webhook"})
|
||||
rulesCase3.Add(NewPattern("", "*.png"), TargetID{"2", "amqp"})
|
||||
|
||||
rulesCase4 := make(Rules)
|
||||
rulesCase4.Add(NewPattern("2010*", ""), TargetID{"1", "webhook"})
|
||||
|
||||
testCases := []struct {
|
||||
rules Rules
|
||||
objectName string
|
||||
expectedResult TargetIDSet
|
||||
}{
|
||||
{rulesCase1, "photos.jpg", NewTargetIDSet()},
|
||||
{rulesCase2, "photos.jpg", NewTargetIDSet(TargetID{"1", "webhook"})},
|
||||
{rulesCase3, "2010/photos.jpg", NewTargetIDSet(TargetID{"1", "webhook"})},
|
||||
{rulesCase4, "2000/photos.jpg", NewTargetIDSet()},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.rules.Match(testCase.objectName)
|
||||
|
||||
if !reflect.DeepEqual(testCase.expectedResult, result) {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesClone(t *testing.T) {
|
||||
rulesCase1 := make(Rules)
|
||||
|
||||
rulesCase2 := make(Rules)
|
||||
rulesCase2.Add(NewPattern("2010*", ""), TargetID{"1", "webhook"})
|
||||
|
||||
rulesCase3 := make(Rules)
|
||||
rulesCase3.Add(NewPattern("", "*.jpg"), TargetID{"1", "webhook"})
|
||||
|
||||
testCases := []struct {
|
||||
rules Rules
|
||||
prefix string
|
||||
targetID TargetID
|
||||
}{
|
||||
{rulesCase1, "2010*", TargetID{"1", "webhook"}},
|
||||
{rulesCase2, "2000*", TargetID{"2", "amqp"}},
|
||||
{rulesCase3, "2010*", TargetID{"1", "webhook"}},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.rules.Clone()
|
||||
|
||||
if !reflect.DeepEqual(result, testCase.rules) {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.rules, result)
|
||||
}
|
||||
|
||||
result.Add(NewPattern(testCase.prefix, ""), testCase.targetID)
|
||||
if reflect.DeepEqual(result, testCase.rules) {
|
||||
t.Fatalf("test %v: result: expected: not equal, got: equal", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesUnion(t *testing.T) {
|
||||
rulesCase1 := make(Rules)
|
||||
rules2Case1 := make(Rules)
|
||||
expectedResultCase1 := make(Rules)
|
||||
|
||||
rulesCase2 := make(Rules)
|
||||
rules2Case2 := make(Rules)
|
||||
rules2Case2.Add(NewPattern("*", ""), TargetID{"1", "webhook"})
|
||||
expectedResultCase2 := make(Rules)
|
||||
expectedResultCase2.Add(NewPattern("*", ""), TargetID{"1", "webhook"})
|
||||
|
||||
rulesCase3 := make(Rules)
|
||||
rulesCase3.Add(NewPattern("", "*"), TargetID{"1", "webhook"})
|
||||
rules2Case3 := make(Rules)
|
||||
expectedResultCase3 := make(Rules)
|
||||
expectedResultCase3.Add(NewPattern("", "*"), TargetID{"1", "webhook"})
|
||||
|
||||
rulesCase4 := make(Rules)
|
||||
rulesCase4.Add(NewPattern("2010*", ""), TargetID{"1", "webhook"})
|
||||
rules2Case4 := make(Rules)
|
||||
rules2Case4.Add(NewPattern("2010*", ""), TargetID{"1", "webhook"})
|
||||
expectedResultCase4 := make(Rules)
|
||||
expectedResultCase4.Add(NewPattern("2010*", ""), TargetID{"1", "webhook"})
|
||||
|
||||
rulesCase5 := make(Rules)
|
||||
rulesCase5.Add(NewPattern("2010*", ""), TargetID{"1", "webhook"})
|
||||
rulesCase5.Add(NewPattern("", "*.png"), TargetID{"2", "amqp"})
|
||||
rules2Case5 := make(Rules)
|
||||
rules2Case5.Add(NewPattern("*", ""), TargetID{"1", "webhook"})
|
||||
expectedResultCase5 := make(Rules)
|
||||
expectedResultCase5.Add(NewPattern("2010*", ""), TargetID{"1", "webhook"})
|
||||
expectedResultCase5.Add(NewPattern("", "*.png"), TargetID{"2", "amqp"})
|
||||
expectedResultCase5.Add(NewPattern("*", ""), TargetID{"1", "webhook"})
|
||||
|
||||
testCases := []struct {
|
||||
rules Rules
|
||||
rules2 Rules
|
||||
expectedResult Rules
|
||||
}{
|
||||
{rulesCase1, rules2Case1, expectedResultCase1},
|
||||
{rulesCase2, rules2Case2, expectedResultCase2},
|
||||
{rulesCase3, rules2Case3, expectedResultCase3},
|
||||
{rulesCase4, rules2Case4, expectedResultCase4},
|
||||
{rulesCase5, rules2Case5, expectedResultCase5},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.rules.Union(testCase.rules2)
|
||||
|
||||
if !reflect.DeepEqual(testCase.expectedResult, result) {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesDifference(t *testing.T) {
|
||||
rulesCase1 := make(Rules)
|
||||
rules2Case1 := make(Rules)
|
||||
expectedResultCase1 := make(Rules)
|
||||
|
||||
rulesCase2 := make(Rules)
|
||||
rules2Case2 := make(Rules)
|
||||
rules2Case2.Add(NewPattern("*", "*"), TargetID{"1", "webhook"})
|
||||
expectedResultCase2 := make(Rules)
|
||||
|
||||
rulesCase3 := make(Rules)
|
||||
rulesCase3.Add(NewPattern("*", "*"), TargetID{"1", "webhook"})
|
||||
rules2Case3 := make(Rules)
|
||||
expectedResultCase3 := make(Rules)
|
||||
expectedResultCase3.Add(NewPattern("*", "*"), TargetID{"1", "webhook"})
|
||||
|
||||
rulesCase4 := make(Rules)
|
||||
rulesCase4.Add(NewPattern("*", "*"), TargetID{"1", "webhook"})
|
||||
rules2Case4 := make(Rules)
|
||||
rules2Case4.Add(NewPattern("2010*", ""), TargetID{"1", "webhook"})
|
||||
rules2Case4.Add(NewPattern("", "*.png"), TargetID{"2", "amqp"})
|
||||
expectedResultCase4 := make(Rules)
|
||||
expectedResultCase4.Add(NewPattern("*", "*"), TargetID{"1", "webhook"})
|
||||
|
||||
rulesCase5 := make(Rules)
|
||||
rulesCase5.Add(NewPattern("*", ""), TargetID{"1", "webhook"})
|
||||
rulesCase5.Add(NewPattern("", "*"), TargetID{"2", "amqp"})
|
||||
rules2Case5 := make(Rules)
|
||||
rules2Case5.Add(NewPattern("2010*", ""), TargetID{"1", "webhook"})
|
||||
rules2Case5.Add(NewPattern("", "*"), TargetID{"2", "amqp"})
|
||||
expectedResultCase5 := make(Rules)
|
||||
expectedResultCase5.Add(NewPattern("*", ""), TargetID{"1", "webhook"})
|
||||
|
||||
testCases := []struct {
|
||||
rules Rules
|
||||
rules2 Rules
|
||||
expectedResult Rules
|
||||
}{
|
||||
{rulesCase1, rules2Case1, expectedResultCase1},
|
||||
{rulesCase2, rules2Case2, expectedResultCase2},
|
||||
{rulesCase3, rules2Case3, expectedResultCase3},
|
||||
{rulesCase4, rules2Case4, expectedResultCase4},
|
||||
{rulesCase5, rules2Case5, expectedResultCase5},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.rules.Difference(testCase.rules2)
|
||||
|
||||
if !reflect.DeepEqual(testCase.expectedResult, result) {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
84
internal/event/rulesmap.go
Normal file
84
internal/event/rulesmap.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
// RulesMap - map of rules for every event name.
|
||||
type RulesMap map[Name]Rules
|
||||
|
||||
// add - adds event names, prefixes, suffixes and target ID to rules map.
|
||||
func (rulesMap RulesMap) add(eventNames []Name, pattern string, targetID TargetID) {
|
||||
rules := make(Rules)
|
||||
rules.Add(pattern, targetID)
|
||||
|
||||
for _, eventName := range eventNames {
|
||||
for _, name := range eventName.Expand() {
|
||||
rulesMap[name] = rulesMap[name].Union(rules)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clone - returns copy of this rules map.
|
||||
func (rulesMap RulesMap) Clone() RulesMap {
|
||||
rulesMapCopy := make(RulesMap)
|
||||
|
||||
for eventName, rules := range rulesMap {
|
||||
rulesMapCopy[eventName] = rules.Clone()
|
||||
}
|
||||
|
||||
return rulesMapCopy
|
||||
}
|
||||
|
||||
// Add - adds given rules map.
|
||||
func (rulesMap RulesMap) Add(rulesMap2 RulesMap) {
|
||||
for eventName, rules := range rulesMap2 {
|
||||
rulesMap[eventName] = rules.Union(rulesMap[eventName])
|
||||
}
|
||||
}
|
||||
|
||||
// Remove - removes given rules map.
|
||||
func (rulesMap RulesMap) Remove(rulesMap2 RulesMap) {
|
||||
for eventName, rules := range rulesMap {
|
||||
if nr := rules.Difference(rulesMap2[eventName]); len(nr) != 0 {
|
||||
rulesMap[eventName] = nr
|
||||
} else {
|
||||
delete(rulesMap, eventName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MatchSimple - returns true if matching object name and event name in rules map.
|
||||
func (rulesMap RulesMap) MatchSimple(eventName Name, objectName string) bool {
|
||||
return rulesMap[eventName].MatchSimple(objectName)
|
||||
}
|
||||
|
||||
// Match - returns TargetIDSet matching object name and event name in rules map.
|
||||
func (rulesMap RulesMap) Match(eventName Name, objectName string) TargetIDSet {
|
||||
return rulesMap[eventName].Match(objectName)
|
||||
}
|
||||
|
||||
// NewRulesMap - creates new rules map with given values.
|
||||
func NewRulesMap(eventNames []Name, pattern string, targetID TargetID) RulesMap {
|
||||
// If pattern is empty, add '*' wildcard to match all.
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
|
||||
rulesMap := make(RulesMap)
|
||||
rulesMap.add(eventNames, pattern, targetID)
|
||||
return rulesMap
|
||||
}
|
||||
187
internal/event/rulesmap_test.go
Normal file
187
internal/event/rulesmap_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRulesMapClone(t *testing.T) {
|
||||
rulesMapCase1 := make(RulesMap)
|
||||
rulesMapToAddCase1 := NewRulesMap([]Name{ObjectCreatedAll}, "*", TargetID{"1", "webhook"})
|
||||
|
||||
rulesMapCase2 := NewRulesMap([]Name{ObjectCreatedAll}, "*", TargetID{"1", "webhook"})
|
||||
rulesMapToAddCase2 := NewRulesMap([]Name{ObjectCreatedAll}, "2010*.jpg", TargetID{"1", "webhook"})
|
||||
|
||||
rulesMapCase3 := NewRulesMap([]Name{ObjectCreatedAll}, "2010*.jpg", TargetID{"1", "webhook"})
|
||||
rulesMapToAddCase3 := NewRulesMap([]Name{ObjectCreatedAll}, "*", TargetID{"1", "webhook"})
|
||||
|
||||
testCases := []struct {
|
||||
rulesMap RulesMap
|
||||
rulesMapToAdd RulesMap
|
||||
}{
|
||||
{rulesMapCase1, rulesMapToAddCase1},
|
||||
{rulesMapCase2, rulesMapToAddCase2},
|
||||
{rulesMapCase3, rulesMapToAddCase3},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.rulesMap.Clone()
|
||||
|
||||
if !reflect.DeepEqual(result, testCase.rulesMap) {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.rulesMap, result)
|
||||
}
|
||||
|
||||
result.Add(testCase.rulesMapToAdd)
|
||||
if reflect.DeepEqual(result, testCase.rulesMap) {
|
||||
t.Fatalf("test %v: result: expected: not equal, got: equal", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesMapAdd(t *testing.T) {
|
||||
rulesMapCase1 := make(RulesMap)
|
||||
rulesMapToAddCase1 := make(RulesMap)
|
||||
expectedResultCase1 := make(RulesMap)
|
||||
|
||||
rulesMapCase2 := make(RulesMap)
|
||||
rulesMapToAddCase2 := NewRulesMap([]Name{ObjectCreatedAll}, "*", TargetID{"1", "webhook"})
|
||||
expectedResultCase2 := NewRulesMap([]Name{ObjectCreatedAll}, "*", TargetID{"1", "webhook"})
|
||||
|
||||
rulesMapCase3 := NewRulesMap([]Name{ObjectCreatedAll}, "*", TargetID{"1", "webhook"})
|
||||
rulesMapToAddCase3 := NewRulesMap([]Name{ObjectCreatedAll}, "2010*.jpg", TargetID{"1", "webhook"})
|
||||
expectedResultCase3 := NewRulesMap([]Name{ObjectCreatedAll}, "2010*.jpg", TargetID{"1", "webhook"})
|
||||
expectedResultCase3.add([]Name{ObjectCreatedAll}, "*", TargetID{"1", "webhook"})
|
||||
|
||||
testCases := []struct {
|
||||
rulesMap RulesMap
|
||||
rulesMapToAdd RulesMap
|
||||
expectedResult RulesMap
|
||||
}{
|
||||
{rulesMapCase1, rulesMapToAddCase1, expectedResultCase1},
|
||||
{rulesMapCase2, rulesMapToAddCase2, expectedResultCase2},
|
||||
{rulesMapCase3, rulesMapToAddCase3, expectedResultCase3},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
testCase.rulesMap.Add(testCase.rulesMapToAdd)
|
||||
|
||||
if !reflect.DeepEqual(testCase.rulesMap, testCase.expectedResult) {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, testCase.rulesMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesMapRemove(t *testing.T) {
|
||||
rulesMapCase1 := make(RulesMap)
|
||||
rulesMapToAddCase1 := make(RulesMap)
|
||||
expectedResultCase1 := make(RulesMap)
|
||||
|
||||
rulesMapCase2 := NewRulesMap([]Name{ObjectCreatedAll}, "*", TargetID{"1", "webhook"})
|
||||
rulesMapToAddCase2 := NewRulesMap([]Name{ObjectCreatedAll}, "*", TargetID{"1", "webhook"})
|
||||
expectedResultCase2 := make(RulesMap)
|
||||
|
||||
rulesMapCase3 := NewRulesMap([]Name{ObjectCreatedAll}, "2010*.jpg", TargetID{"1", "webhook"})
|
||||
rulesMapCase3.add([]Name{ObjectCreatedAll}, "*", TargetID{"1", "webhook"})
|
||||
rulesMapToAddCase3 := NewRulesMap([]Name{ObjectCreatedAll}, "2010*.jpg", TargetID{"1", "webhook"})
|
||||
expectedResultCase3 := NewRulesMap([]Name{ObjectCreatedAll}, "*", TargetID{"1", "webhook"})
|
||||
|
||||
testCases := []struct {
|
||||
rulesMap RulesMap
|
||||
rulesMapToAdd RulesMap
|
||||
expectedResult RulesMap
|
||||
}{
|
||||
{rulesMapCase1, rulesMapToAddCase1, expectedResultCase1},
|
||||
{rulesMapCase2, rulesMapToAddCase2, expectedResultCase2},
|
||||
{rulesMapCase3, rulesMapToAddCase3, expectedResultCase3},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
testCase.rulesMap.Remove(testCase.rulesMapToAdd)
|
||||
|
||||
if !reflect.DeepEqual(testCase.rulesMap, testCase.expectedResult) {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, testCase.rulesMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesMapMatch(t *testing.T) {
|
||||
rulesMapCase1 := make(RulesMap)
|
||||
|
||||
rulesMapCase2 := NewRulesMap([]Name{ObjectCreatedAll}, "*", TargetID{"1", "webhook"})
|
||||
|
||||
rulesMapCase3 := NewRulesMap([]Name{ObjectCreatedAll}, "2010*.jpg", TargetID{"1", "webhook"})
|
||||
|
||||
rulesMapCase4 := NewRulesMap([]Name{ObjectCreatedAll}, "2010*.jpg", TargetID{"1", "webhook"})
|
||||
rulesMapCase4.add([]Name{ObjectCreatedAll}, "*", TargetID{"2", "amqp"})
|
||||
|
||||
testCases := []struct {
|
||||
rulesMap RulesMap
|
||||
eventName Name
|
||||
objectName string
|
||||
expectedResult TargetIDSet
|
||||
}{
|
||||
{rulesMapCase1, ObjectCreatedPut, "2010/photo.jpg", NewTargetIDSet()},
|
||||
{rulesMapCase2, ObjectCreatedPut, "2010/photo.jpg", NewTargetIDSet(TargetID{"1", "webhook"})},
|
||||
{rulesMapCase3, ObjectCreatedPut, "2000/photo.png", NewTargetIDSet()},
|
||||
{rulesMapCase4, ObjectCreatedPut, "2000/photo.png", NewTargetIDSet(TargetID{"2", "amqp"})},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.rulesMap.Match(testCase.eventName, testCase.objectName)
|
||||
|
||||
if !reflect.DeepEqual(result, testCase.expectedResult) {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRulesMap(t *testing.T) {
|
||||
rulesMapCase1 := make(RulesMap)
|
||||
rulesMapCase1.add([]Name{ObjectAccessedGet, ObjectAccessedHead, ObjectAccessedGetRetention, ObjectAccessedGetLegalHold, ObjectAccessedAttributes},
|
||||
"*", TargetID{"1", "webhook"})
|
||||
|
||||
rulesMapCase2 := make(RulesMap)
|
||||
rulesMapCase2.add([]Name{
|
||||
ObjectAccessedGet, ObjectAccessedHead, ObjectAccessedAttributes,
|
||||
ObjectCreatedPut, ObjectAccessedGetRetention, ObjectAccessedGetLegalHold,
|
||||
}, "*", TargetID{"1", "webhook"})
|
||||
|
||||
rulesMapCase3 := make(RulesMap)
|
||||
rulesMapCase3.add([]Name{ObjectRemovedDelete}, "2010*.jpg", TargetID{"1", "webhook"})
|
||||
|
||||
testCases := []struct {
|
||||
eventNames []Name
|
||||
pattern string
|
||||
targetID TargetID
|
||||
expectedResult RulesMap
|
||||
}{
|
||||
{[]Name{ObjectAccessedAll}, "", TargetID{"1", "webhook"}, rulesMapCase1},
|
||||
{[]Name{ObjectAccessedAll, ObjectCreatedPut}, "", TargetID{"1", "webhook"}, rulesMapCase2},
|
||||
{[]Name{ObjectRemovedDelete}, "2010*.jpg", TargetID{"1", "webhook"}, rulesMapCase3},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := NewRulesMap(testCase.eventNames, testCase.pattern, testCase.targetID)
|
||||
|
||||
if !reflect.DeepEqual(result, testCase.expectedResult) {
|
||||
t.Errorf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
376
internal/event/target/amqp.go
Normal file
376
internal/event/target/amqp.go
Normal file
@@ -0,0 +1,376 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/minio/internal/once"
|
||||
"github.com/minio/minio/internal/store"
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
"github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// AMQPArgs - AMQP target arguments.
|
||||
type AMQPArgs struct {
|
||||
Enable bool `json:"enable"`
|
||||
URL amqp091.URI `json:"url"`
|
||||
Exchange string `json:"exchange"`
|
||||
RoutingKey string `json:"routingKey"`
|
||||
ExchangeType string `json:"exchangeType"`
|
||||
DeliveryMode uint8 `json:"deliveryMode"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
Immediate bool `json:"immediate"`
|
||||
Durable bool `json:"durable"`
|
||||
Internal bool `json:"internal"`
|
||||
NoWait bool `json:"noWait"`
|
||||
AutoDeleted bool `json:"autoDeleted"`
|
||||
PublisherConfirms bool `json:"publisherConfirms"`
|
||||
QueueDir string `json:"queueDir"`
|
||||
QueueLimit uint64 `json:"queueLimit"`
|
||||
}
|
||||
|
||||
// AMQP input constants.
|
||||
//
|
||||
// ST1003 We cannot change these exported names.
|
||||
//
|
||||
//nolint:staticcheck
|
||||
const (
|
||||
AmqpQueueDir = "queue_dir"
|
||||
AmqpQueueLimit = "queue_limit"
|
||||
|
||||
AmqpURL = "url"
|
||||
AmqpExchange = "exchange"
|
||||
AmqpRoutingKey = "routing_key"
|
||||
AmqpExchangeType = "exchange_type"
|
||||
AmqpDeliveryMode = "delivery_mode"
|
||||
AmqpMandatory = "mandatory"
|
||||
AmqpImmediate = "immediate"
|
||||
AmqpDurable = "durable"
|
||||
AmqpInternal = "internal"
|
||||
AmqpNoWait = "no_wait"
|
||||
AmqpAutoDeleted = "auto_deleted"
|
||||
AmqpArguments = "arguments"
|
||||
AmqpPublisherConfirms = "publisher_confirms"
|
||||
|
||||
EnvAMQPEnable = "MINIO_NOTIFY_AMQP_ENABLE"
|
||||
EnvAMQPURL = "MINIO_NOTIFY_AMQP_URL"
|
||||
EnvAMQPExchange = "MINIO_NOTIFY_AMQP_EXCHANGE"
|
||||
EnvAMQPRoutingKey = "MINIO_NOTIFY_AMQP_ROUTING_KEY"
|
||||
EnvAMQPExchangeType = "MINIO_NOTIFY_AMQP_EXCHANGE_TYPE"
|
||||
EnvAMQPDeliveryMode = "MINIO_NOTIFY_AMQP_DELIVERY_MODE"
|
||||
EnvAMQPMandatory = "MINIO_NOTIFY_AMQP_MANDATORY"
|
||||
EnvAMQPImmediate = "MINIO_NOTIFY_AMQP_IMMEDIATE"
|
||||
EnvAMQPDurable = "MINIO_NOTIFY_AMQP_DURABLE"
|
||||
EnvAMQPInternal = "MINIO_NOTIFY_AMQP_INTERNAL"
|
||||
EnvAMQPNoWait = "MINIO_NOTIFY_AMQP_NO_WAIT"
|
||||
EnvAMQPAutoDeleted = "MINIO_NOTIFY_AMQP_AUTO_DELETED"
|
||||
EnvAMQPArguments = "MINIO_NOTIFY_AMQP_ARGUMENTS"
|
||||
EnvAMQPPublisherConfirms = "MINIO_NOTIFY_AMQP_PUBLISHING_CONFIRMS"
|
||||
EnvAMQPQueueDir = "MINIO_NOTIFY_AMQP_QUEUE_DIR"
|
||||
EnvAMQPQueueLimit = "MINIO_NOTIFY_AMQP_QUEUE_LIMIT"
|
||||
)
|
||||
|
||||
// Validate AMQP arguments
|
||||
func (a *AMQPArgs) Validate() error {
|
||||
if !a.Enable {
|
||||
return nil
|
||||
}
|
||||
if _, err := amqp091.ParseURI(a.URL.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.QueueDir != "" {
|
||||
if !filepath.IsAbs(a.QueueDir) {
|
||||
return errors.New("queueDir path should be absolute")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AMQPTarget - AMQP target
|
||||
type AMQPTarget struct {
|
||||
initOnce once.Init
|
||||
|
||||
id event.TargetID
|
||||
args AMQPArgs
|
||||
conn *amqp091.Connection
|
||||
connMutex sync.Mutex
|
||||
store store.Store[event.Event]
|
||||
loggerOnce logger.LogOnce
|
||||
|
||||
quitCh chan struct{}
|
||||
}
|
||||
|
||||
// ID - returns TargetID.
|
||||
func (target *AMQPTarget) ID() event.TargetID {
|
||||
return target.id
|
||||
}
|
||||
|
||||
// Name - returns the Name of the target.
|
||||
func (target *AMQPTarget) Name() string {
|
||||
return target.ID().String()
|
||||
}
|
||||
|
||||
// Store returns any underlying store if set.
|
||||
func (target *AMQPTarget) Store() event.TargetStore {
|
||||
return target.store
|
||||
}
|
||||
|
||||
// IsActive - Return true if target is up and active
|
||||
func (target *AMQPTarget) IsActive() (bool, error) {
|
||||
if err := target.init(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return target.isActive()
|
||||
}
|
||||
|
||||
func (target *AMQPTarget) isActive() (bool, error) {
|
||||
ch, _, err := target.channel()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
ch.Close()
|
||||
}()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (target *AMQPTarget) channel() (*amqp091.Channel, chan amqp091.Confirmation, error) {
|
||||
var err error
|
||||
var conn *amqp091.Connection
|
||||
var ch *amqp091.Channel
|
||||
|
||||
isAMQPClosedErr := func(err error) bool {
|
||||
if err == amqp091.ErrClosed {
|
||||
return true
|
||||
}
|
||||
|
||||
if nerr, ok := err.(*net.OpError); ok {
|
||||
return (nerr.Err.Error() == "use of closed network connection")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
target.connMutex.Lock()
|
||||
defer target.connMutex.Unlock()
|
||||
|
||||
if target.conn != nil {
|
||||
ch, err = target.conn.Channel()
|
||||
if err == nil {
|
||||
if target.args.PublisherConfirms {
|
||||
confirms := ch.NotifyPublish(make(chan amqp091.Confirmation, 1))
|
||||
if err := ch.Confirm(false); err != nil {
|
||||
ch.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return ch, confirms, nil
|
||||
}
|
||||
return ch, nil, nil
|
||||
}
|
||||
|
||||
if !isAMQPClosedErr(err) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// close when we know this is a network error.
|
||||
target.conn.Close()
|
||||
}
|
||||
|
||||
conn, err = amqp091.Dial(target.args.URL.String())
|
||||
if err != nil {
|
||||
if xnet.IsConnRefusedErr(err) {
|
||||
return nil, nil, store.ErrNotConnected
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ch, err = conn.Channel()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
target.conn = conn
|
||||
|
||||
if target.args.PublisherConfirms {
|
||||
confirms := ch.NotifyPublish(make(chan amqp091.Confirmation, 1))
|
||||
if err := ch.Confirm(false); err != nil {
|
||||
ch.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return ch, confirms, nil
|
||||
}
|
||||
|
||||
return ch, nil, nil
|
||||
}
|
||||
|
||||
// send - sends an event to the AMQP091.
|
||||
func (target *AMQPTarget) send(eventData event.Event, ch *amqp091.Channel, confirms chan amqp091.Confirmation) error {
|
||||
objectName, err := url.QueryUnescape(eventData.S3.Object.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := eventData.S3.Bucket.Name + "/" + objectName
|
||||
|
||||
data, err := json.Marshal(event.Log{EventName: eventData.EventName, Key: key, Records: []event.Event{eventData}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
headers := make(amqp091.Table)
|
||||
// Add more information here as required, but be aware to not overload headers
|
||||
headers["minio-bucket"] = eventData.S3.Bucket.Name
|
||||
headers["minio-event"] = eventData.EventName.String()
|
||||
|
||||
if err = ch.ExchangeDeclare(target.args.Exchange, target.args.ExchangeType, target.args.Durable,
|
||||
target.args.AutoDeleted, target.args.Internal, target.args.NoWait, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = ch.Publish(target.args.Exchange, target.args.RoutingKey, target.args.Mandatory,
|
||||
target.args.Immediate, amqp091.Publishing{
|
||||
Headers: headers,
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: target.args.DeliveryMode,
|
||||
Body: data,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check for publisher confirms only if its enabled
|
||||
if target.args.PublisherConfirms {
|
||||
confirmed := <-confirms
|
||||
if !confirmed.Ack {
|
||||
return fmt.Errorf("failed delivery of delivery tag: %d", confirmed.DeliveryTag)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save - saves the events to the store which will be replayed when the amqp connection is active.
|
||||
func (target *AMQPTarget) Save(eventData event.Event) error {
|
||||
if target.store != nil {
|
||||
_, err := target.store.Put(eventData)
|
||||
return err
|
||||
}
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
ch, confirms, err := target.channel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ch.Close()
|
||||
|
||||
return target.send(eventData, ch, confirms)
|
||||
}
|
||||
|
||||
// SendFromStore - reads an event from store and sends it to AMQP091.
|
||||
func (target *AMQPTarget) SendFromStore(key store.Key) error {
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ch, confirms, err := target.channel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ch.Close()
|
||||
|
||||
eventData, eErr := target.store.Get(key)
|
||||
if eErr != nil {
|
||||
// The last event key in a successful batch will be sent in the channel atmost once by the replayEvents()
|
||||
// Such events will not exist and wouldve been already been sent successfully.
|
||||
if os.IsNotExist(eErr) {
|
||||
return nil
|
||||
}
|
||||
return eErr
|
||||
}
|
||||
|
||||
if err := target.send(eventData, ch, confirms); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the event from store.
|
||||
return target.store.Del(key)
|
||||
}
|
||||
|
||||
// Close - does nothing and available for interface compatibility.
|
||||
func (target *AMQPTarget) Close() error {
|
||||
close(target.quitCh)
|
||||
if target.conn != nil {
|
||||
return target.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *AMQPTarget) init() error {
|
||||
return target.initOnce.Do(target.initAMQP)
|
||||
}
|
||||
|
||||
func (target *AMQPTarget) initAMQP() error {
|
||||
conn, err := amqp091.Dial(target.args.URL.String())
|
||||
if err != nil {
|
||||
if xnet.IsConnRefusedErr(err) || xnet.IsConnResetErr(err) {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
}
|
||||
return err
|
||||
}
|
||||
target.conn = conn
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewAMQPTarget - creates new AMQP target.
|
||||
func NewAMQPTarget(id string, args AMQPArgs, loggerOnce logger.LogOnce) (*AMQPTarget, error) {
|
||||
var queueStore store.Store[event.Event]
|
||||
if args.QueueDir != "" {
|
||||
queueDir := filepath.Join(args.QueueDir, storePrefix+"-amqp-"+id)
|
||||
queueStore = store.NewQueueStore[event.Event](queueDir, args.QueueLimit, event.StoreExtension)
|
||||
if err := queueStore.Open(); err != nil {
|
||||
return nil, fmt.Errorf("unable to initialize the queue store of AMQP `%s`: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
target := &AMQPTarget{
|
||||
id: event.TargetID{ID: id, Name: "amqp"},
|
||||
args: args,
|
||||
loggerOnce: loggerOnce,
|
||||
store: queueStore,
|
||||
quitCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
if target.store != nil {
|
||||
store.StreamItems(target.store, target, target.quitCh, target.loggerOnce)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
584
internal/event/target/elasticsearch.go
Normal file
584
internal/event/target/elasticsearch.go
Normal file
@@ -0,0 +1,584 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
elasticsearch7 "github.com/elastic/go-elasticsearch/v7"
|
||||
"github.com/minio/highwayhash"
|
||||
"github.com/minio/minio/internal/event"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/minio/internal/once"
|
||||
"github.com/minio/minio/internal/store"
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Elastic constants
|
||||
const (
|
||||
ElasticFormat = "format"
|
||||
ElasticURL = "url"
|
||||
ElasticIndex = "index"
|
||||
ElasticQueueDir = "queue_dir"
|
||||
ElasticQueueLimit = "queue_limit"
|
||||
ElasticUsername = "username"
|
||||
ElasticPassword = "password"
|
||||
|
||||
EnvElasticEnable = "MINIO_NOTIFY_ELASTICSEARCH_ENABLE"
|
||||
EnvElasticFormat = "MINIO_NOTIFY_ELASTICSEARCH_FORMAT"
|
||||
EnvElasticURL = "MINIO_NOTIFY_ELASTICSEARCH_URL"
|
||||
EnvElasticIndex = "MINIO_NOTIFY_ELASTICSEARCH_INDEX"
|
||||
EnvElasticQueueDir = "MINIO_NOTIFY_ELASTICSEARCH_QUEUE_DIR"
|
||||
EnvElasticQueueLimit = "MINIO_NOTIFY_ELASTICSEARCH_QUEUE_LIMIT"
|
||||
EnvElasticUsername = "MINIO_NOTIFY_ELASTICSEARCH_USERNAME"
|
||||
EnvElasticPassword = "MINIO_NOTIFY_ELASTICSEARCH_PASSWORD"
|
||||
)
|
||||
|
||||
// ESSupportStatus is a typed string representing the support status for
|
||||
// Elasticsearch
|
||||
type ESSupportStatus string
|
||||
|
||||
const (
|
||||
// ESSUnknown is default value
|
||||
ESSUnknown ESSupportStatus = "ESSUnknown"
|
||||
// ESSDeprecated -> support will be removed in future
|
||||
ESSDeprecated ESSupportStatus = "ESSDeprecated"
|
||||
// ESSUnsupported -> we won't work with this ES server
|
||||
ESSUnsupported ESSupportStatus = "ESSUnsupported"
|
||||
// ESSSupported -> all good!
|
||||
ESSSupported ESSupportStatus = "ESSSupported"
|
||||
)
|
||||
|
||||
func getESVersionSupportStatus(version string) (res ESSupportStatus, err error) {
|
||||
parts := strings.Split(version, ".")
|
||||
if len(parts) < 1 {
|
||||
err = fmt.Errorf("bad ES version string: %s", version)
|
||||
return res, err
|
||||
}
|
||||
|
||||
majorVersion, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
err = fmt.Errorf("bad ES version string: %s", version)
|
||||
return res, err
|
||||
}
|
||||
|
||||
switch {
|
||||
case majorVersion <= 6:
|
||||
res = ESSUnsupported
|
||||
default:
|
||||
res = ESSSupported
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// magic HH-256 key as HH-256 hash of the first 100 decimals of π as utf-8 string with a zero key.
|
||||
var magicHighwayHash256Key = []byte("\x4b\xe7\x34\xfa\x8e\x23\x8a\xcd\x26\x3e\x83\xe6\xbb\x96\x85\x52\x04\x0f\x93\x5d\xa3\x9f\x44\x14\x97\xe0\x9d\x13\x22\xde\x36\xa0")
|
||||
|
||||
// Interface for elasticsearch client objects
|
||||
type esClient interface {
|
||||
isAtleastV7() bool
|
||||
createIndex(ElasticsearchArgs) error
|
||||
ping(context.Context, ElasticsearchArgs) (bool, error)
|
||||
stop()
|
||||
entryExists(context.Context, string, string) (bool, error)
|
||||
removeEntry(context.Context, string, string) error
|
||||
updateEntry(context.Context, string, string, event.Event) error
|
||||
addEntry(context.Context, string, event.Event) error
|
||||
}
|
||||
|
||||
// ElasticsearchArgs - Elasticsearch target arguments.
|
||||
type ElasticsearchArgs struct {
|
||||
Enable bool `json:"enable"`
|
||||
Format string `json:"format"`
|
||||
URL xnet.URL `json:"url"`
|
||||
Index string `json:"index"`
|
||||
QueueDir string `json:"queueDir"`
|
||||
QueueLimit uint64 `json:"queueLimit"`
|
||||
Transport *http.Transport `json:"-"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// Validate ElasticsearchArgs fields
|
||||
func (a ElasticsearchArgs) Validate() error {
|
||||
if !a.Enable {
|
||||
return nil
|
||||
}
|
||||
if a.URL.IsEmpty() {
|
||||
return errors.New("empty URL")
|
||||
}
|
||||
if a.Format != "" {
|
||||
f := strings.ToLower(a.Format)
|
||||
if f != event.NamespaceFormat && f != event.AccessFormat {
|
||||
return errors.New("format value unrecognized")
|
||||
}
|
||||
}
|
||||
if a.Index == "" {
|
||||
return errors.New("empty index value")
|
||||
}
|
||||
|
||||
if (a.Username == "" && a.Password != "") || (a.Username != "" && a.Password == "") {
|
||||
return errors.New("username and password should be set in pairs")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ElasticsearchTarget - Elasticsearch target.
|
||||
type ElasticsearchTarget struct {
|
||||
initOnce once.Init
|
||||
|
||||
id event.TargetID
|
||||
args ElasticsearchArgs
|
||||
client esClient
|
||||
store store.Store[event.Event]
|
||||
loggerOnce logger.LogOnce
|
||||
quitCh chan struct{}
|
||||
}
|
||||
|
||||
// ID - returns target ID.
|
||||
func (target *ElasticsearchTarget) ID() event.TargetID {
|
||||
return target.id
|
||||
}
|
||||
|
||||
// Name - returns the Name of the target.
|
||||
func (target *ElasticsearchTarget) Name() string {
|
||||
return target.ID().String()
|
||||
}
|
||||
|
||||
// Store returns any underlying store if set.
|
||||
func (target *ElasticsearchTarget) Store() event.TargetStore {
|
||||
return target.store
|
||||
}
|
||||
|
||||
// IsActive - Return true if target is up and active
|
||||
func (target *ElasticsearchTarget) IsActive() (bool, error) {
|
||||
if err := target.init(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return target.isActive()
|
||||
}
|
||||
|
||||
func (target *ElasticsearchTarget) isActive() (bool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := target.checkAndInitClient(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return target.client.ping(ctx, target.args)
|
||||
}
|
||||
|
||||
// Save - saves the events to the store if queuestore is configured, which will be replayed when the elasticsearch connection is active.
|
||||
func (target *ElasticsearchTarget) Save(eventData event.Event) error {
|
||||
if target.store != nil {
|
||||
_, err := target.store.Put(eventData)
|
||||
return err
|
||||
}
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := target.checkAndInitClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = target.send(eventData)
|
||||
if xnet.IsNetworkOrHostDown(err, false) {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// send - sends the event to the target.
|
||||
func (target *ElasticsearchTarget) send(eventData event.Event) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if target.args.Format == event.NamespaceFormat {
|
||||
objectName, err := url.QueryUnescape(eventData.S3.Object.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate a hash of the key for the id of the ES document.
|
||||
// Id's are limited to 512 bytes in V7+, so we need to do this.
|
||||
var keyHash string
|
||||
{
|
||||
key := eventData.S3.Bucket.Name + "/" + objectName
|
||||
if target.client.isAtleastV7() {
|
||||
hh, _ := highwayhash.New(magicHighwayHash256Key) // New will never return error since key is 256 bit
|
||||
hh.Write([]byte(key))
|
||||
hashBytes := hh.Sum(nil)
|
||||
keyHash = base64.URLEncoding.EncodeToString(hashBytes)
|
||||
} else {
|
||||
keyHash = key
|
||||
}
|
||||
}
|
||||
|
||||
if eventData.EventName == event.ObjectRemovedDelete {
|
||||
err = target.client.removeEntry(ctx, target.args.Index, keyHash)
|
||||
} else {
|
||||
err = target.client.updateEntry(ctx, target.args.Index, keyHash, eventData)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if target.args.Format == event.AccessFormat {
|
||||
return target.client.addEntry(ctx, target.args.Index, eventData)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendFromStore - reads an event from store and sends it to Elasticsearch.
|
||||
func (target *ElasticsearchTarget) SendFromStore(key store.Key) error {
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := target.checkAndInitClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventData, eErr := target.store.Get(key)
|
||||
if eErr != nil {
|
||||
// The last event key in a successful batch will be sent in the channel atmost once by the replayEvents()
|
||||
// Such events will not exist and wouldve been already been sent successfully.
|
||||
if os.IsNotExist(eErr) {
|
||||
return nil
|
||||
}
|
||||
return eErr
|
||||
}
|
||||
|
||||
if err := target.send(eventData); err != nil {
|
||||
if xnet.IsNetworkOrHostDown(err, false) {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the event from store.
|
||||
return target.store.Del(key)
|
||||
}
|
||||
|
||||
// Close - does nothing and available for interface compatibility.
|
||||
func (target *ElasticsearchTarget) Close() error {
|
||||
close(target.quitCh)
|
||||
if target.client != nil {
|
||||
// Stops the background processes that the client is running.
|
||||
target.client.stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *ElasticsearchTarget) checkAndInitClient(ctx context.Context) error {
|
||||
if target.client != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
clientV7, err := newClientV7(target.args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check es version to confirm if it is supported.
|
||||
serverSupportStatus, version, err := clientV7.getServerSupportStatus(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch serverSupportStatus {
|
||||
case ESSUnknown:
|
||||
return errors.New("unable to determine support status of ES (should not happen)")
|
||||
|
||||
case ESSDeprecated:
|
||||
return errors.New("there is no currently deprecated version of ES in MinIO")
|
||||
|
||||
case ESSSupported:
|
||||
target.client = clientV7
|
||||
|
||||
default:
|
||||
// ESSUnsupported case
|
||||
return fmt.Errorf("Elasticsearch version '%s' is not supported! Please use at least version 7.x.", version)
|
||||
}
|
||||
|
||||
target.client.createIndex(target.args)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *ElasticsearchTarget) init() error {
|
||||
return target.initOnce.Do(target.initElasticsearch)
|
||||
}
|
||||
|
||||
func (target *ElasticsearchTarget) initElasticsearch() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := target.checkAndInitClient(ctx)
|
||||
if err != nil {
|
||||
if err != store.ErrNotConnected {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewElasticsearchTarget - creates new Elasticsearch target.
|
||||
func NewElasticsearchTarget(id string, args ElasticsearchArgs, loggerOnce logger.LogOnce) (*ElasticsearchTarget, error) {
|
||||
var queueStore store.Store[event.Event]
|
||||
if args.QueueDir != "" {
|
||||
queueDir := filepath.Join(args.QueueDir, storePrefix+"-elasticsearch-"+id)
|
||||
queueStore = store.NewQueueStore[event.Event](queueDir, args.QueueLimit, event.StoreExtension)
|
||||
if err := queueStore.Open(); err != nil {
|
||||
return nil, fmt.Errorf("unable to initialize the queue store of Elasticsearch `%s`: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
target := &ElasticsearchTarget{
|
||||
id: event.TargetID{ID: id, Name: "elasticsearch"},
|
||||
args: args,
|
||||
store: queueStore,
|
||||
loggerOnce: loggerOnce,
|
||||
quitCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
if target.store != nil {
|
||||
store.StreamItems(target.store, target, target.quitCh, target.loggerOnce)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// ES Client definitions and methods
|
||||
|
||||
type esClientV7 struct {
|
||||
*elasticsearch7.Client
|
||||
}
|
||||
|
||||
func newClientV7(args ElasticsearchArgs) (*esClientV7, error) {
|
||||
// Client options
|
||||
elasticConfig := elasticsearch7.Config{
|
||||
Addresses: []string{args.URL.String()},
|
||||
Transport: args.Transport,
|
||||
MaxRetries: 10,
|
||||
}
|
||||
// Set basic auth
|
||||
if args.Username != "" && args.Password != "" {
|
||||
elasticConfig.Username = args.Username
|
||||
elasticConfig.Password = args.Password
|
||||
}
|
||||
// Create a client
|
||||
client, err := elasticsearch7.NewClient(elasticConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientV7 := &esClientV7{client}
|
||||
return clientV7, nil
|
||||
}
|
||||
|
||||
func (c *esClientV7) getServerSupportStatus(ctx context.Context) (ESSupportStatus, string, error) {
|
||||
resp, err := c.Info(
|
||||
c.Info.WithContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return ESSUnknown, "", store.ErrNotConnected
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
m := make(map[string]any)
|
||||
err = json.NewDecoder(resp.Body).Decode(&m)
|
||||
if err != nil {
|
||||
return ESSUnknown, "", fmt.Errorf("unable to get ES Server version - json parse error: %v", err)
|
||||
}
|
||||
|
||||
if v, ok := m["version"].(map[string]any); ok {
|
||||
if ver, ok := v["number"].(string); ok {
|
||||
status, err := getESVersionSupportStatus(ver)
|
||||
return status, ver, err
|
||||
}
|
||||
}
|
||||
return ESSUnknown, "", fmt.Errorf("Unable to get ES Server Version - got INFO response: %v", m)
|
||||
}
|
||||
|
||||
func (c *esClientV7) isAtleastV7() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// createIndex - creates the index if it does not exist.
|
||||
func (c *esClientV7) createIndex(args ElasticsearchArgs) error {
|
||||
res, err := c.Indices.ResolveIndex([]string{args.Index})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var v map[string]any
|
||||
found := false
|
||||
if err := json.NewDecoder(res.Body).Decode(&v); err != nil {
|
||||
return fmt.Errorf("Error parsing response body: %v", err)
|
||||
}
|
||||
|
||||
indices, ok := v["indices"].([]any)
|
||||
if ok {
|
||||
for _, index := range indices {
|
||||
if name, ok := index.(map[string]any); ok && name["name"] == args.Index {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
resp, err := c.Indices.Create(args.Index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer xhttp.DrainBody(resp.Body)
|
||||
if resp.IsError() {
|
||||
return fmt.Errorf("Create index err: %v", res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *esClientV7) ping(ctx context.Context, _ ElasticsearchArgs) (bool, error) {
|
||||
resp, err := c.Ping(
|
||||
c.Ping.WithContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return false, store.ErrNotConnected
|
||||
}
|
||||
xhttp.DrainBody(resp.Body)
|
||||
return !resp.IsError(), nil
|
||||
}
|
||||
|
||||
func (c *esClientV7) entryExists(ctx context.Context, index string, key string) (bool, error) {
|
||||
res, err := c.Exists(
|
||||
index,
|
||||
key,
|
||||
c.Exists.WithContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
xhttp.DrainBody(res.Body)
|
||||
return !res.IsError(), nil
|
||||
}
|
||||
|
||||
func (c *esClientV7) removeEntry(ctx context.Context, index string, key string) error {
|
||||
exists, err := c.entryExists(ctx, index, key)
|
||||
if err == nil && exists {
|
||||
res, err := c.Delete(
|
||||
index,
|
||||
key,
|
||||
c.Delete.WithContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer xhttp.DrainBody(res.Body)
|
||||
if res.IsError() {
|
||||
return fmt.Errorf("Delete err: %s", res.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *esClientV7) updateEntry(ctx context.Context, index string, key string, eventData event.Event) error {
|
||||
doc := map[string]any{
|
||||
"Records": []event.Event{eventData},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
err := enc.Encode(doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := c.Index(
|
||||
index,
|
||||
&buf,
|
||||
c.Index.WithDocumentID(key),
|
||||
c.Index.WithContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer xhttp.DrainBody(res.Body)
|
||||
if res.IsError() {
|
||||
return fmt.Errorf("Update err: %s", res.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *esClientV7) addEntry(ctx context.Context, index string, eventData event.Event) error {
|
||||
doc := map[string]any{
|
||||
"Records": []event.Event{eventData},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
err := enc.Encode(doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := c.Index(
|
||||
index,
|
||||
&buf,
|
||||
c.Index.WithContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer xhttp.DrainBody(res.Body)
|
||||
if res.IsError() {
|
||||
return fmt.Errorf("Add err: %s", res.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *esClientV7) stop() {
|
||||
}
|
||||
465
internal/event/target/kafka.go
Normal file
465
internal/event/target/kafka.go
Normal file
@@ -0,0 +1,465 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/minio/internal/once"
|
||||
"github.com/minio/minio/internal/store"
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
|
||||
"github.com/IBM/sarama"
|
||||
saramatls "github.com/IBM/sarama/tools/tls"
|
||||
)
|
||||
|
||||
// Kafka input constants
|
||||
const (
|
||||
KafkaBrokers = "brokers"
|
||||
KafkaTopic = "topic"
|
||||
KafkaQueueDir = "queue_dir"
|
||||
KafkaQueueLimit = "queue_limit"
|
||||
KafkaTLS = "tls"
|
||||
KafkaTLSSkipVerify = "tls_skip_verify"
|
||||
KafkaTLSClientAuth = "tls_client_auth"
|
||||
KafkaSASL = "sasl"
|
||||
KafkaSASLUsername = "sasl_username"
|
||||
KafkaSASLPassword = "sasl_password"
|
||||
KafkaSASLMechanism = "sasl_mechanism"
|
||||
KafkaClientTLSCert = "client_tls_cert"
|
||||
KafkaClientTLSKey = "client_tls_key"
|
||||
KafkaVersion = "version"
|
||||
KafkaBatchSize = "batch_size"
|
||||
KafkaBatchCommitTimeout = "batch_commit_timeout"
|
||||
KafkaCompressionCodec = "compression_codec"
|
||||
KafkaCompressionLevel = "compression_level"
|
||||
|
||||
EnvKafkaEnable = "MINIO_NOTIFY_KAFKA_ENABLE"
|
||||
EnvKafkaBrokers = "MINIO_NOTIFY_KAFKA_BROKERS"
|
||||
EnvKafkaTopic = "MINIO_NOTIFY_KAFKA_TOPIC"
|
||||
EnvKafkaQueueDir = "MINIO_NOTIFY_KAFKA_QUEUE_DIR"
|
||||
EnvKafkaQueueLimit = "MINIO_NOTIFY_KAFKA_QUEUE_LIMIT"
|
||||
EnvKafkaTLS = "MINIO_NOTIFY_KAFKA_TLS"
|
||||
EnvKafkaTLSSkipVerify = "MINIO_NOTIFY_KAFKA_TLS_SKIP_VERIFY"
|
||||
EnvKafkaTLSClientAuth = "MINIO_NOTIFY_KAFKA_TLS_CLIENT_AUTH"
|
||||
EnvKafkaSASLEnable = "MINIO_NOTIFY_KAFKA_SASL"
|
||||
EnvKafkaSASLUsername = "MINIO_NOTIFY_KAFKA_SASL_USERNAME"
|
||||
EnvKafkaSASLPassword = "MINIO_NOTIFY_KAFKA_SASL_PASSWORD"
|
||||
EnvKafkaSASLMechanism = "MINIO_NOTIFY_KAFKA_SASL_MECHANISM"
|
||||
EnvKafkaClientTLSCert = "MINIO_NOTIFY_KAFKA_CLIENT_TLS_CERT"
|
||||
EnvKafkaClientTLSKey = "MINIO_NOTIFY_KAFKA_CLIENT_TLS_KEY"
|
||||
EnvKafkaVersion = "MINIO_NOTIFY_KAFKA_VERSION"
|
||||
EnvKafkaBatchSize = "MINIO_NOTIFY_KAFKA_BATCH_SIZE"
|
||||
EnvKafkaBatchCommitTimeout = "MINIO_NOTIFY_KAFKA_BATCH_COMMIT_TIMEOUT"
|
||||
EnvKafkaProducerCompressionCodec = "MINIO_NOTIFY_KAFKA_PRODUCER_COMPRESSION_CODEC"
|
||||
EnvKafkaProducerCompressionLevel = "MINIO_NOTIFY_KAFKA_PRODUCER_COMPRESSION_LEVEL"
|
||||
)
|
||||
|
||||
var codecs = map[string]sarama.CompressionCodec{
|
||||
"none": sarama.CompressionNone,
|
||||
"gzip": sarama.CompressionGZIP,
|
||||
"snappy": sarama.CompressionSnappy,
|
||||
"lz4": sarama.CompressionLZ4,
|
||||
"zstd": sarama.CompressionZSTD,
|
||||
}
|
||||
|
||||
// KafkaArgs - Kafka target arguments.
|
||||
type KafkaArgs struct {
|
||||
Enable bool `json:"enable"`
|
||||
Brokers []xnet.Host `json:"brokers"`
|
||||
Topic string `json:"topic"`
|
||||
QueueDir string `json:"queueDir"`
|
||||
QueueLimit uint64 `json:"queueLimit"`
|
||||
Version string `json:"version"`
|
||||
BatchSize uint32 `json:"batchSize"`
|
||||
BatchCommitTimeout time.Duration `json:"batchCommitTimeout"`
|
||||
TLS struct {
|
||||
Enable bool `json:"enable"`
|
||||
RootCAs *x509.CertPool `json:"-"`
|
||||
SkipVerify bool `json:"skipVerify"`
|
||||
ClientAuth tls.ClientAuthType `json:"clientAuth"`
|
||||
ClientTLSCert string `json:"clientTLSCert"`
|
||||
ClientTLSKey string `json:"clientTLSKey"`
|
||||
} `json:"tls"`
|
||||
SASL struct {
|
||||
Enable bool `json:"enable"`
|
||||
User string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Mechanism string `json:"mechanism"`
|
||||
} `json:"sasl"`
|
||||
Producer struct {
|
||||
Compression string `json:"compression"`
|
||||
CompressionLevel int `json:"compressionLevel"`
|
||||
} `json:"producer"`
|
||||
}
|
||||
|
||||
// Validate KafkaArgs fields
|
||||
func (k KafkaArgs) Validate() error {
|
||||
if !k.Enable {
|
||||
return nil
|
||||
}
|
||||
if len(k.Brokers) == 0 {
|
||||
return errors.New("no broker address found")
|
||||
}
|
||||
for _, b := range k.Brokers {
|
||||
if _, err := xnet.ParseHost(b.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if k.QueueDir != "" {
|
||||
if !filepath.IsAbs(k.QueueDir) {
|
||||
return errors.New("queueDir path should be absolute")
|
||||
}
|
||||
}
|
||||
if k.Version != "" {
|
||||
if _, err := sarama.ParseKafkaVersion(k.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if k.BatchSize > 1 {
|
||||
if k.QueueDir == "" {
|
||||
return errors.New("batch should be enabled only if queue dir is enabled")
|
||||
}
|
||||
}
|
||||
if k.BatchCommitTimeout > 0 {
|
||||
if k.QueueDir == "" || k.BatchSize <= 1 {
|
||||
return errors.New("batch commit timeout should be set only if queue dir is enabled and batch size > 1")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KafkaTarget - Kafka target.
|
||||
type KafkaTarget struct {
|
||||
initOnce once.Init
|
||||
|
||||
id event.TargetID
|
||||
args KafkaArgs
|
||||
client sarama.Client
|
||||
producer sarama.SyncProducer
|
||||
config *sarama.Config
|
||||
store store.Store[event.Event]
|
||||
batch *store.Batch[event.Event]
|
||||
loggerOnce logger.LogOnce
|
||||
quitCh chan struct{}
|
||||
}
|
||||
|
||||
// ID - returns target ID.
|
||||
func (target *KafkaTarget) ID() event.TargetID {
|
||||
return target.id
|
||||
}
|
||||
|
||||
// Name - returns the Name of the target.
|
||||
func (target *KafkaTarget) Name() string {
|
||||
return target.ID().String()
|
||||
}
|
||||
|
||||
// Store returns any underlying store if set.
|
||||
func (target *KafkaTarget) Store() event.TargetStore {
|
||||
return target.store
|
||||
}
|
||||
|
||||
// IsActive - Return true if target is up and active
|
||||
func (target *KafkaTarget) IsActive() (bool, error) {
|
||||
if err := target.init(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return target.isActive()
|
||||
}
|
||||
|
||||
func (target *KafkaTarget) isActive() (bool, error) {
|
||||
// Refer https://github.com/IBM/sarama/issues/1341
|
||||
brokers := target.client.Brokers()
|
||||
if len(brokers) == 0 {
|
||||
return false, store.ErrNotConnected
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Save - saves the events to the store which will be replayed when the Kafka connection is active.
|
||||
func (target *KafkaTarget) Save(eventData event.Event) error {
|
||||
if target.store != nil {
|
||||
if target.batch != nil {
|
||||
return target.batch.Add(eventData)
|
||||
}
|
||||
_, err := target.store.Put(eventData)
|
||||
return err
|
||||
}
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
return target.send(eventData)
|
||||
}
|
||||
|
||||
// send - sends an event to the kafka.
|
||||
func (target *KafkaTarget) send(eventData event.Event) error {
|
||||
if target.producer == nil {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
msg, err := target.toProducerMessage(eventData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err = target.producer.SendMessage(msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// sendMultiple sends multiple messages to the kafka.
|
||||
func (target *KafkaTarget) sendMultiple(events []event.Event) error {
|
||||
if target.producer == nil {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
var msgs []*sarama.ProducerMessage
|
||||
for _, event := range events {
|
||||
msg, err := target.toProducerMessage(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
return target.producer.SendMessages(msgs)
|
||||
}
|
||||
|
||||
// SendFromStore - reads an event from store and sends it to Kafka.
|
||||
func (target *KafkaTarget) SendFromStore(key store.Key) (err error) {
|
||||
if err = target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case key.ItemCount == 1:
|
||||
var event event.Event
|
||||
event, err = target.store.Get(key)
|
||||
if err != nil {
|
||||
// The last event key in a successful batch will be sent in the channel atmost once by the replayEvents()
|
||||
// Such events will not exist and wouldve been already been sent successfully.
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
err = target.send(event)
|
||||
case key.ItemCount > 1:
|
||||
var events []event.Event
|
||||
events, err = target.store.GetMultiple(key)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
err = target.sendMultiple(events)
|
||||
}
|
||||
if err != nil {
|
||||
if isKafkaConnErr(err) {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Delete the event from store.
|
||||
return target.store.Del(key)
|
||||
}
|
||||
|
||||
func (target *KafkaTarget) toProducerMessage(eventData event.Event) (*sarama.ProducerMessage, error) {
|
||||
objectName, err := url.QueryUnescape(eventData.S3.Object.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := eventData.S3.Bucket.Name + "/" + objectName
|
||||
data, err := json.Marshal(event.Log{EventName: eventData.EventName, Key: key, Records: []event.Event{eventData}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &sarama.ProducerMessage{
|
||||
Topic: target.args.Topic,
|
||||
Key: sarama.StringEncoder(key),
|
||||
Value: sarama.ByteEncoder(data),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close - closes underneath kafka connection.
|
||||
func (target *KafkaTarget) Close() error {
|
||||
close(target.quitCh)
|
||||
|
||||
if target.batch != nil {
|
||||
target.batch.Close()
|
||||
}
|
||||
|
||||
if target.producer != nil {
|
||||
if target.store != nil {
|
||||
// It is safe to abort the current transaction if
|
||||
// queue_dir is configured
|
||||
target.producer.AbortTxn()
|
||||
} else {
|
||||
target.producer.CommitTxn()
|
||||
}
|
||||
target.producer.Close()
|
||||
return target.client.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *KafkaTarget) init() error {
|
||||
return target.initOnce.Do(target.initKafka)
|
||||
}
|
||||
|
||||
func (target *KafkaTarget) initKafka() error {
|
||||
if os.Getenv("_MINIO_KAFKA_DEBUG") != "" {
|
||||
sarama.DebugLogger = log.Default()
|
||||
}
|
||||
|
||||
args := target.args
|
||||
|
||||
config := sarama.NewConfig()
|
||||
if args.Version != "" {
|
||||
kafkaVersion, err := sarama.ParseKafkaVersion(args.Version)
|
||||
if err != nil {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
return err
|
||||
}
|
||||
config.Version = kafkaVersion
|
||||
}
|
||||
|
||||
config.Net.KeepAlive = 60 * time.Second
|
||||
config.Net.SASL.User = args.SASL.User
|
||||
config.Net.SASL.Password = args.SASL.Password
|
||||
initScramClient(args, config) // initializes configured scram client.
|
||||
config.Net.SASL.Enable = args.SASL.Enable
|
||||
|
||||
tlsConfig, err := saramatls.NewConfig(args.TLS.ClientTLSCert, args.TLS.ClientTLSKey)
|
||||
if err != nil {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
return err
|
||||
}
|
||||
|
||||
config.Net.TLS.Enable = args.TLS.Enable
|
||||
config.Net.TLS.Config = tlsConfig
|
||||
config.Net.TLS.Config.InsecureSkipVerify = args.TLS.SkipVerify
|
||||
config.Net.TLS.Config.ClientAuth = args.TLS.ClientAuth
|
||||
config.Net.TLS.Config.RootCAs = args.TLS.RootCAs
|
||||
|
||||
// These settings are needed to ensure that kafka client doesn't hang on brokers
|
||||
// refer https://github.com/IBM/sarama/issues/765#issuecomment-254333355
|
||||
config.Producer.Retry.Max = 2
|
||||
config.Producer.Retry.Backoff = (1 * time.Second)
|
||||
config.Producer.Return.Successes = true
|
||||
config.Producer.Return.Errors = true
|
||||
config.Producer.RequiredAcks = 1
|
||||
config.Producer.Timeout = (5 * time.Second)
|
||||
// Set Producer Compression
|
||||
cc, ok := codecs[strings.ToLower(args.Producer.Compression)]
|
||||
if ok {
|
||||
config.Producer.Compression = cc
|
||||
config.Producer.CompressionLevel = args.Producer.CompressionLevel
|
||||
}
|
||||
|
||||
config.Net.ReadTimeout = (5 * time.Second)
|
||||
config.Net.DialTimeout = (5 * time.Second)
|
||||
config.Net.WriteTimeout = (5 * time.Second)
|
||||
config.Metadata.Retry.Max = 1
|
||||
config.Metadata.Retry.Backoff = (1 * time.Second)
|
||||
config.Metadata.RefreshFrequency = (15 * time.Minute)
|
||||
|
||||
target.config = config
|
||||
|
||||
brokers := []string{}
|
||||
for _, broker := range args.Brokers {
|
||||
brokers = append(brokers, broker.String())
|
||||
}
|
||||
|
||||
client, err := sarama.NewClient(brokers, config)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sarama.ErrOutOfBrokers) {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
producer, err := sarama.NewSyncProducerFromClient(client)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sarama.ErrOutOfBrokers) {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
}
|
||||
return err
|
||||
}
|
||||
target.client = client
|
||||
target.producer = producer
|
||||
|
||||
yes, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewKafkaTarget - creates new Kafka target with auth credentials.
|
||||
func NewKafkaTarget(id string, args KafkaArgs, loggerOnce logger.LogOnce) (*KafkaTarget, error) {
|
||||
var queueStore store.Store[event.Event]
|
||||
if args.QueueDir != "" {
|
||||
queueDir := filepath.Join(args.QueueDir, storePrefix+"-kafka-"+id)
|
||||
queueStore = store.NewQueueStore[event.Event](queueDir, args.QueueLimit, event.StoreExtension)
|
||||
if err := queueStore.Open(); err != nil {
|
||||
return nil, fmt.Errorf("unable to initialize the queue store of Kafka `%s`: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
target := &KafkaTarget{
|
||||
id: event.TargetID{ID: id, Name: "kafka"},
|
||||
args: args,
|
||||
store: queueStore,
|
||||
loggerOnce: loggerOnce,
|
||||
quitCh: make(chan struct{}),
|
||||
}
|
||||
if target.store != nil {
|
||||
if args.BatchSize > 1 {
|
||||
target.batch = store.NewBatch[event.Event](store.BatchConfig[event.Event]{
|
||||
Limit: args.BatchSize,
|
||||
Log: loggerOnce,
|
||||
Store: queueStore,
|
||||
CommitTimeout: args.BatchCommitTimeout,
|
||||
})
|
||||
}
|
||||
store.StreamItems(target.store, target, target.quitCh, target.loggerOnce)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func isKafkaConnErr(err error) bool {
|
||||
// Sarama opens the circuit breaker after 3 consecutive connection failures.
|
||||
return err == sarama.ErrLeaderNotAvailable || err.Error() == "circuit breaker is open"
|
||||
}
|
||||
86
internal/event/target/kafka_scram_client_contrib.go
Normal file
86
internal/event/target/kafka_scram_client_contrib.go
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* MinIO Object Storage (c) 2021-2023 MinIO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"strings"
|
||||
|
||||
"github.com/IBM/sarama"
|
||||
"github.com/xdg/scram"
|
||||
|
||||
"github.com/minio/minio/internal/hash/sha256"
|
||||
)
|
||||
|
||||
func initScramClient(args KafkaArgs, config *sarama.Config) {
|
||||
switch strings.ToLower(args.SASL.Mechanism) {
|
||||
case "sha512":
|
||||
config.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient { return &XDGSCRAMClient{HashGeneratorFcn: KafkaSHA512} }
|
||||
config.Net.SASL.Mechanism = sarama.SASLMechanism(sarama.SASLTypeSCRAMSHA512)
|
||||
case "sha256":
|
||||
config.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient { return &XDGSCRAMClient{HashGeneratorFcn: KafkaSHA256} }
|
||||
config.Net.SASL.Mechanism = sarama.SASLMechanism(sarama.SASLTypeSCRAMSHA256)
|
||||
default:
|
||||
// default to PLAIN
|
||||
config.Net.SASL.Mechanism = sarama.SASLMechanism(sarama.SASLTypePlaintext)
|
||||
}
|
||||
}
|
||||
|
||||
// KafkaSHA256 is a function that returns a crypto/sha256 hasher and should be used
|
||||
// to create Client objects configured for SHA-256 hashing.
|
||||
var KafkaSHA256 scram.HashGeneratorFcn = sha256.New
|
||||
|
||||
// KafkaSHA512 is a function that returns a crypto/sha512 hasher and should be used
|
||||
// to create Client objects configured for SHA-512 hashing.
|
||||
var KafkaSHA512 scram.HashGeneratorFcn = sha512.New
|
||||
|
||||
// XDGSCRAMClient implements the client-side of an authentication
|
||||
// conversation with a server. A new conversation must be created for
|
||||
// each authentication attempt.
|
||||
type XDGSCRAMClient struct {
|
||||
*scram.Client
|
||||
*scram.ClientConversation
|
||||
scram.HashGeneratorFcn
|
||||
}
|
||||
|
||||
// Begin constructs a SCRAM client component based on a given hash.Hash
|
||||
// factory receiver. This constructor will normalize the username, password
|
||||
// and authzID via the SASLprep algorithm, as recommended by RFC-5802. If
|
||||
// SASLprep fails, the method returns an error.
|
||||
func (x *XDGSCRAMClient) Begin(userName, password, authzID string) (err error) {
|
||||
x.Client, err = x.NewClient(userName, password, authzID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
x.ClientConversation = x.NewConversation()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step takes a string provided from a server (or just an empty string for the
|
||||
// very first conversation step) and attempts to move the authentication
|
||||
// conversation forward. It returns a string to be sent to the server or an
|
||||
// error if the server message is invalid. Calling Step after a conversation
|
||||
// completes is also an error.
|
||||
func (x *XDGSCRAMClient) Step(challenge string) (response string, err error) {
|
||||
response, err = x.ClientConversation.Step(challenge)
|
||||
return response, err
|
||||
}
|
||||
|
||||
// Done returns true if the conversation is completed or has errored.
|
||||
func (x *XDGSCRAMClient) Done() bool {
|
||||
return x.ClientConversation.Done()
|
||||
}
|
||||
308
internal/event/target/mqtt.go
Normal file
308
internal/event/target/mqtt.go
Normal file
@@ -0,0 +1,308 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/minio/internal/once"
|
||||
"github.com/minio/minio/internal/store"
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
)
|
||||
|
||||
const (
|
||||
reconnectInterval = 5 * time.Second
|
||||
storePrefix = "minio"
|
||||
)
|
||||
|
||||
// MQTT input constants
|
||||
const (
|
||||
MqttBroker = "broker"
|
||||
MqttTopic = "topic"
|
||||
MqttQoS = "qos"
|
||||
MqttUsername = "username"
|
||||
MqttPassword = "password"
|
||||
MqttReconnectInterval = "reconnect_interval"
|
||||
MqttKeepAliveInterval = "keep_alive_interval"
|
||||
MqttQueueDir = "queue_dir"
|
||||
MqttQueueLimit = "queue_limit"
|
||||
|
||||
EnvMQTTEnable = "MINIO_NOTIFY_MQTT_ENABLE"
|
||||
EnvMQTTBroker = "MINIO_NOTIFY_MQTT_BROKER"
|
||||
EnvMQTTTopic = "MINIO_NOTIFY_MQTT_TOPIC"
|
||||
EnvMQTTQoS = "MINIO_NOTIFY_MQTT_QOS"
|
||||
EnvMQTTUsername = "MINIO_NOTIFY_MQTT_USERNAME"
|
||||
EnvMQTTPassword = "MINIO_NOTIFY_MQTT_PASSWORD"
|
||||
EnvMQTTReconnectInterval = "MINIO_NOTIFY_MQTT_RECONNECT_INTERVAL"
|
||||
EnvMQTTKeepAliveInterval = "MINIO_NOTIFY_MQTT_KEEP_ALIVE_INTERVAL"
|
||||
EnvMQTTQueueDir = "MINIO_NOTIFY_MQTT_QUEUE_DIR"
|
||||
EnvMQTTQueueLimit = "MINIO_NOTIFY_MQTT_QUEUE_LIMIT"
|
||||
)
|
||||
|
||||
// MQTTArgs - MQTT target arguments.
|
||||
type MQTTArgs struct {
|
||||
Enable bool `json:"enable"`
|
||||
Broker xnet.URL `json:"broker"`
|
||||
Topic string `json:"topic"`
|
||||
QoS byte `json:"qos"`
|
||||
User string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
MaxReconnectInterval time.Duration `json:"reconnectInterval"`
|
||||
KeepAlive time.Duration `json:"keepAliveInterval"`
|
||||
RootCAs *x509.CertPool `json:"-"`
|
||||
QueueDir string `json:"queueDir"`
|
||||
QueueLimit uint64 `json:"queueLimit"`
|
||||
}
|
||||
|
||||
// Validate MQTTArgs fields
|
||||
func (m MQTTArgs) Validate() error {
|
||||
if !m.Enable {
|
||||
return nil
|
||||
}
|
||||
u, err := xnet.ParseURL(m.Broker.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "ws", "wss", "tcp", "ssl", "tls", "tcps":
|
||||
default:
|
||||
return errors.New("unknown protocol in broker address")
|
||||
}
|
||||
if m.QueueDir != "" {
|
||||
if !filepath.IsAbs(m.QueueDir) {
|
||||
return errors.New("queueDir path should be absolute")
|
||||
}
|
||||
if m.QoS == 0 {
|
||||
return errors.New("qos should be set to 1 or 2 if queueDir is set")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MQTTTarget - MQTT target.
|
||||
type MQTTTarget struct {
|
||||
initOnce once.Init
|
||||
|
||||
id event.TargetID
|
||||
args MQTTArgs
|
||||
client mqtt.Client
|
||||
store store.Store[event.Event]
|
||||
quitCh chan struct{}
|
||||
loggerOnce logger.LogOnce
|
||||
}
|
||||
|
||||
// ID - returns target ID.
|
||||
func (target *MQTTTarget) ID() event.TargetID {
|
||||
return target.id
|
||||
}
|
||||
|
||||
// Name - returns the Name of the target.
|
||||
func (target *MQTTTarget) Name() string {
|
||||
return target.ID().String()
|
||||
}
|
||||
|
||||
// Store returns any underlying store if set.
|
||||
func (target *MQTTTarget) Store() event.TargetStore {
|
||||
return target.store
|
||||
}
|
||||
|
||||
// IsActive - Return true if target is up and active
|
||||
func (target *MQTTTarget) IsActive() (bool, error) {
|
||||
if err := target.init(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return target.isActive()
|
||||
}
|
||||
|
||||
func (target *MQTTTarget) isActive() (bool, error) {
|
||||
if !target.client.IsConnectionOpen() {
|
||||
return false, store.ErrNotConnected
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// send - sends an event to the mqtt.
|
||||
func (target *MQTTTarget) send(eventData event.Event) error {
|
||||
objectName, err := url.QueryUnescape(eventData.S3.Object.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := eventData.S3.Bucket.Name + "/" + objectName
|
||||
|
||||
data, err := json.Marshal(event.Log{EventName: eventData.EventName, Key: key, Records: []event.Event{eventData}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token := target.client.Publish(target.args.Topic, target.args.QoS, false, string(data))
|
||||
if !token.WaitTimeout(reconnectInterval) {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
return token.Error()
|
||||
}
|
||||
|
||||
// SendFromStore - reads an event from store and sends it to MQTT.
|
||||
func (target *MQTTTarget) SendFromStore(key store.Key) error {
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Do not send if the connection is not active.
|
||||
_, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventData, err := target.store.Get(key)
|
||||
if err != nil {
|
||||
// The last event key in a successful batch will be sent in the channel atmost once by the replayEvents()
|
||||
// Such events will not exist and wouldve been already been sent successfully.
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err = target.send(eventData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the event from store.
|
||||
return target.store.Del(key)
|
||||
}
|
||||
|
||||
// Save - saves the events to the store if queuestore is configured, which will
|
||||
// be replayed when the mqtt connection is active.
|
||||
func (target *MQTTTarget) Save(eventData event.Event) error {
|
||||
if target.store != nil {
|
||||
_, err := target.store.Put(eventData)
|
||||
return err
|
||||
}
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Do not send if the connection is not active.
|
||||
_, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return target.send(eventData)
|
||||
}
|
||||
|
||||
// Close - does nothing and available for interface compatibility.
|
||||
func (target *MQTTTarget) Close() error {
|
||||
if target.client != nil {
|
||||
target.client.Disconnect(100)
|
||||
}
|
||||
close(target.quitCh)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *MQTTTarget) init() error {
|
||||
return target.initOnce.Do(target.initMQTT)
|
||||
}
|
||||
|
||||
func (target *MQTTTarget) initMQTT() error {
|
||||
args := target.args
|
||||
|
||||
// Using hex here, to make sure we avoid 23
|
||||
// character limit on client_id according to
|
||||
// MQTT spec.
|
||||
clientID := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
|
||||
options := mqtt.NewClientOptions().
|
||||
SetClientID(clientID).
|
||||
SetCleanSession(true).
|
||||
SetUsername(args.User).
|
||||
SetPassword(args.Password).
|
||||
SetMaxReconnectInterval(args.MaxReconnectInterval).
|
||||
SetKeepAlive(args.KeepAlive).
|
||||
SetTLSConfig(&tls.Config{RootCAs: args.RootCAs}).
|
||||
AddBroker(args.Broker.String())
|
||||
|
||||
target.client = mqtt.NewClient(options)
|
||||
|
||||
token := target.client.Connect()
|
||||
ok := token.WaitTimeout(reconnectInterval)
|
||||
if !ok {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
if token.Error() != nil {
|
||||
return token.Error()
|
||||
}
|
||||
|
||||
yes, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewMQTTTarget - creates new MQTT target.
|
||||
func NewMQTTTarget(id string, args MQTTArgs, loggerOnce logger.LogOnce) (*MQTTTarget, error) {
|
||||
if args.MaxReconnectInterval == 0 {
|
||||
// Default interval
|
||||
// https://github.com/eclipse/paho.mqtt.golang/blob/master/options.go#L115
|
||||
args.MaxReconnectInterval = 10 * time.Minute
|
||||
}
|
||||
|
||||
if args.KeepAlive == 0 {
|
||||
args.KeepAlive = 10 * time.Second
|
||||
}
|
||||
|
||||
var queueStore store.Store[event.Event]
|
||||
if args.QueueDir != "" {
|
||||
queueDir := filepath.Join(args.QueueDir, storePrefix+"-mqtt-"+id)
|
||||
queueStore = store.NewQueueStore[event.Event](queueDir, args.QueueLimit, event.StoreExtension)
|
||||
if err := queueStore.Open(); err != nil {
|
||||
return nil, fmt.Errorf("unable to initialize the queue store of MQTT `%s`: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
target := &MQTTTarget{
|
||||
id: event.TargetID{ID: id, Name: "mqtt"},
|
||||
args: args,
|
||||
store: queueStore,
|
||||
quitCh: make(chan struct{}),
|
||||
loggerOnce: loggerOnce,
|
||||
}
|
||||
|
||||
if target.store != nil {
|
||||
store.StreamItems(target.store, target, target.quitCh, target.loggerOnce)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
444
internal/event/target/mysql.go
Normal file
444
internal/event/target/mysql.go
Normal file
@@ -0,0 +1,444 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/minio/internal/once"
|
||||
"github.com/minio/minio/internal/store"
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
)
|
||||
|
||||
const (
|
||||
mysqlTableExists = `SELECT 1 FROM %s;`
|
||||
// Some MySQL has a 3072 byte limit on key sizes.
|
||||
mysqlCreateNamespaceTable = `CREATE TABLE %s (
|
||||
key_name VARCHAR(3072) NOT NULL,
|
||||
key_hash CHAR(64) GENERATED ALWAYS AS (SHA2(key_name, 256)) STORED NOT NULL PRIMARY KEY,
|
||||
value JSON)
|
||||
CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;`
|
||||
mysqlCreateAccessTable = `CREATE TABLE %s (event_time DATETIME NOT NULL, event_data JSON)
|
||||
ROW_FORMAT = Dynamic;`
|
||||
|
||||
mysqlUpdateRow = `INSERT INTO %s (key_name, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value=VALUES(value);`
|
||||
mysqlDeleteRow = `DELETE FROM %s WHERE key_hash = SHA2(?, 256);`
|
||||
mysqlInsertRow = `INSERT INTO %s (event_time, event_data) VALUES (?, ?);`
|
||||
)
|
||||
|
||||
// MySQL related constants
|
||||
const (
|
||||
MySQLFormat = "format"
|
||||
MySQLDSNString = "dsn_string"
|
||||
MySQLTable = "table"
|
||||
MySQLHost = "host"
|
||||
MySQLPort = "port"
|
||||
MySQLUsername = "username"
|
||||
MySQLPassword = "password"
|
||||
MySQLDatabase = "database"
|
||||
MySQLQueueLimit = "queue_limit"
|
||||
MySQLQueueDir = "queue_dir"
|
||||
MySQLMaxOpenConnections = "max_open_connections"
|
||||
|
||||
EnvMySQLEnable = "MINIO_NOTIFY_MYSQL_ENABLE"
|
||||
EnvMySQLFormat = "MINIO_NOTIFY_MYSQL_FORMAT"
|
||||
EnvMySQLDSNString = "MINIO_NOTIFY_MYSQL_DSN_STRING"
|
||||
EnvMySQLTable = "MINIO_NOTIFY_MYSQL_TABLE"
|
||||
EnvMySQLHost = "MINIO_NOTIFY_MYSQL_HOST"
|
||||
EnvMySQLPort = "MINIO_NOTIFY_MYSQL_PORT"
|
||||
EnvMySQLUsername = "MINIO_NOTIFY_MYSQL_USERNAME"
|
||||
EnvMySQLPassword = "MINIO_NOTIFY_MYSQL_PASSWORD"
|
||||
EnvMySQLDatabase = "MINIO_NOTIFY_MYSQL_DATABASE"
|
||||
EnvMySQLQueueLimit = "MINIO_NOTIFY_MYSQL_QUEUE_LIMIT"
|
||||
EnvMySQLQueueDir = "MINIO_NOTIFY_MYSQL_QUEUE_DIR"
|
||||
EnvMySQLMaxOpenConnections = "MINIO_NOTIFY_MYSQL_MAX_OPEN_CONNECTIONS"
|
||||
)
|
||||
|
||||
// MySQLArgs - MySQL target arguments.
|
||||
type MySQLArgs struct {
|
||||
Enable bool `json:"enable"`
|
||||
Format string `json:"format"`
|
||||
DSN string `json:"dsnString"`
|
||||
Table string `json:"table"`
|
||||
Host xnet.URL `json:"host"`
|
||||
Port string `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
QueueDir string `json:"queueDir"`
|
||||
QueueLimit uint64 `json:"queueLimit"`
|
||||
MaxOpenConnections int `json:"maxOpenConnections"`
|
||||
}
|
||||
|
||||
// Validate MySQLArgs fields
|
||||
func (m MySQLArgs) Validate() error {
|
||||
if !m.Enable {
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.Format != "" {
|
||||
f := strings.ToLower(m.Format)
|
||||
if f != event.NamespaceFormat && f != event.AccessFormat {
|
||||
return fmt.Errorf("unrecognized format")
|
||||
}
|
||||
}
|
||||
|
||||
if m.Table == "" {
|
||||
return fmt.Errorf("table unspecified")
|
||||
}
|
||||
|
||||
if m.DSN != "" {
|
||||
if _, err := mysql.ParseDSN(m.DSN); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Some fields need to be specified when DSN is unspecified
|
||||
if m.Port == "" {
|
||||
return fmt.Errorf("unspecified port")
|
||||
}
|
||||
if _, err := strconv.Atoi(m.Port); err != nil {
|
||||
return fmt.Errorf("invalid port")
|
||||
}
|
||||
if m.Database == "" {
|
||||
return fmt.Errorf("database unspecified")
|
||||
}
|
||||
}
|
||||
|
||||
if m.QueueDir != "" {
|
||||
if !filepath.IsAbs(m.QueueDir) {
|
||||
return errors.New("queueDir path should be absolute")
|
||||
}
|
||||
}
|
||||
|
||||
if m.MaxOpenConnections < 0 {
|
||||
return errors.New("maxOpenConnections cannot be less than zero")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MySQLTarget - MySQL target.
|
||||
type MySQLTarget struct {
|
||||
initOnce once.Init
|
||||
|
||||
id event.TargetID
|
||||
args MySQLArgs
|
||||
updateStmt *sql.Stmt
|
||||
deleteStmt *sql.Stmt
|
||||
insertStmt *sql.Stmt
|
||||
db *sql.DB
|
||||
store store.Store[event.Event]
|
||||
firstPing bool
|
||||
loggerOnce logger.LogOnce
|
||||
|
||||
quitCh chan struct{}
|
||||
}
|
||||
|
||||
// ID - returns target ID.
|
||||
func (target *MySQLTarget) ID() event.TargetID {
|
||||
return target.id
|
||||
}
|
||||
|
||||
// Name - returns the Name of the target.
|
||||
func (target *MySQLTarget) Name() string {
|
||||
return target.ID().String()
|
||||
}
|
||||
|
||||
// Store returns any underlying store if set.
|
||||
func (target *MySQLTarget) Store() event.TargetStore {
|
||||
return target.store
|
||||
}
|
||||
|
||||
// IsActive - Return true if target is up and active
|
||||
func (target *MySQLTarget) IsActive() (bool, error) {
|
||||
if err := target.init(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return target.isActive()
|
||||
}
|
||||
|
||||
func (target *MySQLTarget) isActive() (bool, error) {
|
||||
if err := target.db.Ping(); err != nil {
|
||||
if IsConnErr(err) {
|
||||
return false, store.ErrNotConnected
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Save - saves the events to the store which will be replayed when the SQL connection is active.
|
||||
func (target *MySQLTarget) Save(eventData event.Event) error {
|
||||
if target.store != nil {
|
||||
_, err := target.store.Put(eventData)
|
||||
return err
|
||||
}
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return target.send(eventData)
|
||||
}
|
||||
|
||||
// send - sends an event to the mysql.
|
||||
func (target *MySQLTarget) send(eventData event.Event) error {
|
||||
if target.args.Format == event.NamespaceFormat {
|
||||
objectName, err := url.QueryUnescape(eventData.S3.Object.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := eventData.S3.Bucket.Name + "/" + objectName
|
||||
|
||||
if eventData.EventName == event.ObjectRemovedDelete {
|
||||
_, err = target.deleteStmt.Exec(key)
|
||||
} else {
|
||||
var data []byte
|
||||
if data, err = json.Marshal(struct{ Records []event.Event }{[]event.Event{eventData}}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = target.updateStmt.Exec(key, data)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if target.args.Format == event.AccessFormat {
|
||||
eventTime, err := time.Parse(event.AMZTimeFormat, eventData.EventTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(struct{ Records []event.Event }{[]event.Event{eventData}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = target.insertStmt.Exec(eventTime, data)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendFromStore - reads an event from store and sends it to MySQL.
|
||||
func (target *MySQLTarget) SendFromStore(key store.Key) error {
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !target.firstPing {
|
||||
if err := target.executeStmts(); err != nil {
|
||||
if IsConnErr(err) {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
eventData, eErr := target.store.Get(key)
|
||||
if eErr != nil {
|
||||
// The last event key in a successful batch will be sent in the channel atmost once by the replayEvents()
|
||||
// Such events will not exist and wouldve been already been sent successfully.
|
||||
if os.IsNotExist(eErr) {
|
||||
return nil
|
||||
}
|
||||
return eErr
|
||||
}
|
||||
|
||||
if err := target.send(eventData); err != nil {
|
||||
if IsConnErr(err) {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the event from store.
|
||||
return target.store.Del(key)
|
||||
}
|
||||
|
||||
// Close - closes underneath connections to MySQL database.
|
||||
func (target *MySQLTarget) Close() error {
|
||||
close(target.quitCh)
|
||||
if target.updateStmt != nil {
|
||||
// FIXME: log returned error. ignore time being.
|
||||
_ = target.updateStmt.Close()
|
||||
}
|
||||
|
||||
if target.deleteStmt != nil {
|
||||
// FIXME: log returned error. ignore time being.
|
||||
_ = target.deleteStmt.Close()
|
||||
}
|
||||
|
||||
if target.insertStmt != nil {
|
||||
// FIXME: log returned error. ignore time being.
|
||||
_ = target.insertStmt.Close()
|
||||
}
|
||||
|
||||
if target.db != nil {
|
||||
return target.db.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Executes the table creation statements.
|
||||
func (target *MySQLTarget) executeStmts() error {
|
||||
_, err := target.db.Exec(fmt.Sprintf(mysqlTableExists, target.args.Table))
|
||||
if err != nil {
|
||||
createStmt := mysqlCreateNamespaceTable
|
||||
if target.args.Format == event.AccessFormat {
|
||||
createStmt = mysqlCreateAccessTable
|
||||
}
|
||||
|
||||
if _, dbErr := target.db.Exec(fmt.Sprintf(createStmt, target.args.Table)); dbErr != nil {
|
||||
return dbErr
|
||||
}
|
||||
}
|
||||
|
||||
switch target.args.Format {
|
||||
case event.NamespaceFormat:
|
||||
// insert or update statement
|
||||
if target.updateStmt, err = target.db.Prepare(fmt.Sprintf(mysqlUpdateRow, target.args.Table)); err != nil {
|
||||
return err
|
||||
}
|
||||
// delete statement
|
||||
if target.deleteStmt, err = target.db.Prepare(fmt.Sprintf(mysqlDeleteRow, target.args.Table)); err != nil {
|
||||
return err
|
||||
}
|
||||
case event.AccessFormat:
|
||||
// insert statement
|
||||
if target.insertStmt, err = target.db.Prepare(fmt.Sprintf(mysqlInsertRow, target.args.Table)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *MySQLTarget) init() error {
|
||||
return target.initOnce.Do(target.initMySQL)
|
||||
}
|
||||
|
||||
func (target *MySQLTarget) initMySQL() error {
|
||||
args := target.args
|
||||
|
||||
db, err := sql.Open("mysql", args.DSN)
|
||||
if err != nil {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
return err
|
||||
}
|
||||
target.db = db
|
||||
|
||||
if args.MaxOpenConnections > 0 {
|
||||
// Set the maximum connections limit
|
||||
target.db.SetMaxOpenConns(args.MaxOpenConnections)
|
||||
}
|
||||
|
||||
err = target.db.Ping()
|
||||
if err != nil {
|
||||
if !xnet.IsConnRefusedErr(err) && !xnet.IsConnResetErr(err) {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
}
|
||||
} else {
|
||||
if err = target.executeStmts(); err != nil {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
} else {
|
||||
target.firstPing = true
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
target.db.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
yes, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewMySQLTarget - creates new MySQL target.
|
||||
func NewMySQLTarget(id string, args MySQLArgs, loggerOnce logger.LogOnce) (*MySQLTarget, error) {
|
||||
var queueStore store.Store[event.Event]
|
||||
if args.QueueDir != "" {
|
||||
queueDir := filepath.Join(args.QueueDir, storePrefix+"-mysql-"+id)
|
||||
queueStore = store.NewQueueStore[event.Event](queueDir, args.QueueLimit, event.StoreExtension)
|
||||
if err := queueStore.Open(); err != nil {
|
||||
return nil, fmt.Errorf("unable to initialize the queue store of MySQL `%s`: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
if args.DSN == "" {
|
||||
config := mysql.Config{
|
||||
User: args.User,
|
||||
Passwd: args.Password,
|
||||
Net: "tcp",
|
||||
Addr: args.Host.String() + ":" + args.Port,
|
||||
DBName: args.Database,
|
||||
AllowNativePasswords: true,
|
||||
CheckConnLiveness: true,
|
||||
}
|
||||
|
||||
args.DSN = config.FormatDSN()
|
||||
}
|
||||
|
||||
target := &MySQLTarget{
|
||||
id: event.TargetID{ID: id, Name: "mysql"},
|
||||
args: args,
|
||||
firstPing: false,
|
||||
store: queueStore,
|
||||
loggerOnce: loggerOnce,
|
||||
quitCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
if target.store != nil {
|
||||
store.StreamItems(target.store, target, target.quitCh, target.loggerOnce)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
36
internal/event/target/mysql_test.go
Normal file
36
internal/event/target/mysql_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPostgreSQLRegistration checks if sql driver
|
||||
// is registered and fails otherwise.
|
||||
func TestMySQLRegistration(t *testing.T) {
|
||||
var found bool
|
||||
if slices.Contains(sql.Drivers(), "mysql") {
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("mysql driver not registered")
|
||||
}
|
||||
}
|
||||
480
internal/event/target/nats.go
Normal file
480
internal/event/target/nats.go
Normal file
@@ -0,0 +1,480 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/minio/internal/once"
|
||||
"github.com/minio/minio/internal/store"
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/stan.go"
|
||||
)
|
||||
|
||||
// NATS related constants
|
||||
const (
|
||||
NATSAddress = "address"
|
||||
NATSSubject = "subject"
|
||||
NATSUsername = "username"
|
||||
NATSPassword = "password"
|
||||
NATSToken = "token"
|
||||
NATSNKeySeed = "nkey_seed"
|
||||
NATSTLS = "tls"
|
||||
NATSTLSSkipVerify = "tls_skip_verify"
|
||||
NATSTLSHandshakeFirst = "tls_handshake_first"
|
||||
NATSPingInterval = "ping_interval"
|
||||
NATSQueueDir = "queue_dir"
|
||||
NATSQueueLimit = "queue_limit"
|
||||
NATSCertAuthority = "cert_authority"
|
||||
NATSClientCert = "client_cert"
|
||||
NATSClientKey = "client_key"
|
||||
|
||||
// Streaming constants - deprecated
|
||||
NATSStreaming = "streaming"
|
||||
NATSStreamingClusterID = "streaming_cluster_id"
|
||||
NATSStreamingAsync = "streaming_async"
|
||||
NATSStreamingMaxPubAcksInFlight = "streaming_max_pub_acks_in_flight"
|
||||
|
||||
// JetStream constants
|
||||
NATSJetStream = "jetstream"
|
||||
|
||||
EnvNATSEnable = "MINIO_NOTIFY_NATS_ENABLE"
|
||||
EnvNATSAddress = "MINIO_NOTIFY_NATS_ADDRESS"
|
||||
EnvNATSSubject = "MINIO_NOTIFY_NATS_SUBJECT"
|
||||
EnvNATSUsername = "MINIO_NOTIFY_NATS_USERNAME"
|
||||
NATSUserCredentials = "MINIO_NOTIFY_NATS_USER_CREDENTIALS"
|
||||
EnvNATSPassword = "MINIO_NOTIFY_NATS_PASSWORD"
|
||||
EnvNATSToken = "MINIO_NOTIFY_NATS_TOKEN"
|
||||
EnvNATSNKeySeed = "MINIO_NOTIFY_NATS_NKEY_SEED"
|
||||
EnvNATSTLS = "MINIO_NOTIFY_NATS_TLS"
|
||||
EnvNATSTLSSkipVerify = "MINIO_NOTIFY_NATS_TLS_SKIP_VERIFY"
|
||||
EnvNatsTLSHandshakeFirst = "MINIO_NOTIFY_NATS_TLS_HANDSHAKE_FIRST"
|
||||
EnvNATSPingInterval = "MINIO_NOTIFY_NATS_PING_INTERVAL"
|
||||
EnvNATSQueueDir = "MINIO_NOTIFY_NATS_QUEUE_DIR"
|
||||
EnvNATSQueueLimit = "MINIO_NOTIFY_NATS_QUEUE_LIMIT"
|
||||
EnvNATSCertAuthority = "MINIO_NOTIFY_NATS_CERT_AUTHORITY"
|
||||
EnvNATSClientCert = "MINIO_NOTIFY_NATS_CLIENT_CERT"
|
||||
EnvNATSClientKey = "MINIO_NOTIFY_NATS_CLIENT_KEY"
|
||||
|
||||
// Streaming constants - deprecated
|
||||
EnvNATSStreaming = "MINIO_NOTIFY_NATS_STREAMING"
|
||||
EnvNATSStreamingClusterID = "MINIO_NOTIFY_NATS_STREAMING_CLUSTER_ID"
|
||||
EnvNATSStreamingAsync = "MINIO_NOTIFY_NATS_STREAMING_ASYNC"
|
||||
EnvNATSStreamingMaxPubAcksInFlight = "MINIO_NOTIFY_NATS_STREAMING_MAX_PUB_ACKS_IN_FLIGHT"
|
||||
|
||||
// Jetstream constants
|
||||
EnvNATSJetStream = "MINIO_NOTIFY_NATS_JETSTREAM"
|
||||
)
|
||||
|
||||
// NATSArgs - NATS target arguments.
|
||||
type NATSArgs struct {
|
||||
Enable bool `json:"enable"`
|
||||
Address xnet.Host `json:"address"`
|
||||
Subject string `json:"subject"`
|
||||
Username string `json:"username"`
|
||||
UserCredentials string `json:"userCredentials"`
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
NKeySeed string `json:"nKeySeed"`
|
||||
TLS bool `json:"tls"`
|
||||
TLSSkipVerify bool `json:"tlsSkipVerify"`
|
||||
TLSHandshakeFirst bool `json:"tlsHandshakeFirst"`
|
||||
Secure bool `json:"secure"`
|
||||
CertAuthority string `json:"certAuthority"`
|
||||
ClientCert string `json:"clientCert"`
|
||||
ClientKey string `json:"clientKey"`
|
||||
PingInterval int64 `json:"pingInterval"`
|
||||
QueueDir string `json:"queueDir"`
|
||||
QueueLimit uint64 `json:"queueLimit"`
|
||||
JetStream struct {
|
||||
Enable bool `json:"enable"`
|
||||
} `json:"jetStream"`
|
||||
Streaming struct {
|
||||
Enable bool `json:"enable"`
|
||||
ClusterID string `json:"clusterID"`
|
||||
Async bool `json:"async"`
|
||||
MaxPubAcksInflight int `json:"maxPubAcksInflight"`
|
||||
} `json:"streaming"`
|
||||
|
||||
RootCAs *x509.CertPool `json:"-"`
|
||||
}
|
||||
|
||||
// Validate NATSArgs fields
|
||||
func (n NATSArgs) Validate() error {
|
||||
if !n.Enable {
|
||||
return nil
|
||||
}
|
||||
|
||||
if n.Address.IsEmpty() {
|
||||
return errors.New("empty address")
|
||||
}
|
||||
|
||||
if n.Subject == "" {
|
||||
return errors.New("empty subject")
|
||||
}
|
||||
|
||||
if n.ClientCert != "" && n.ClientKey == "" || n.ClientCert == "" && n.ClientKey != "" {
|
||||
return errors.New("cert and key must be specified as a pair")
|
||||
}
|
||||
|
||||
if n.Username != "" && n.Password == "" || n.Username == "" && n.Password != "" {
|
||||
return errors.New("username and password must be specified as a pair")
|
||||
}
|
||||
|
||||
if n.Streaming.Enable {
|
||||
if n.Streaming.ClusterID == "" {
|
||||
return errors.New("empty cluster id")
|
||||
}
|
||||
}
|
||||
|
||||
if n.JetStream.Enable {
|
||||
if n.Subject == "" {
|
||||
return errors.New("empty subject")
|
||||
}
|
||||
}
|
||||
|
||||
if n.QueueDir != "" {
|
||||
if !filepath.IsAbs(n.QueueDir) {
|
||||
return errors.New("queueDir path should be absolute")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// To obtain a nats connection from args.
|
||||
func (n NATSArgs) connectNats() (*nats.Conn, error) {
|
||||
connOpts := []nats.Option{nats.Name("Minio Notification"), nats.MaxReconnects(-1)}
|
||||
if n.Username != "" && n.Password != "" {
|
||||
connOpts = append(connOpts, nats.UserInfo(n.Username, n.Password))
|
||||
}
|
||||
if n.UserCredentials != "" {
|
||||
connOpts = append(connOpts, nats.UserCredentials(n.UserCredentials))
|
||||
}
|
||||
if n.Token != "" {
|
||||
connOpts = append(connOpts, nats.Token(n.Token))
|
||||
}
|
||||
if n.NKeySeed != "" {
|
||||
nkeyOpt, err := nats.NkeyOptionFromSeed(n.NKeySeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
connOpts = append(connOpts, nkeyOpt)
|
||||
}
|
||||
if n.Secure || n.TLS && n.TLSSkipVerify {
|
||||
connOpts = append(connOpts, nats.Secure(nil))
|
||||
} else if n.TLS {
|
||||
connOpts = append(connOpts, nats.Secure(&tls.Config{RootCAs: n.RootCAs}))
|
||||
}
|
||||
if n.TLSHandshakeFirst {
|
||||
connOpts = append(connOpts, nats.TLSHandshakeFirst())
|
||||
}
|
||||
if n.CertAuthority != "" {
|
||||
connOpts = append(connOpts, nats.RootCAs(n.CertAuthority))
|
||||
}
|
||||
if n.ClientCert != "" && n.ClientKey != "" {
|
||||
connOpts = append(connOpts, nats.ClientCert(n.ClientCert, n.ClientKey))
|
||||
}
|
||||
return nats.Connect(n.Address.String(), connOpts...)
|
||||
}
|
||||
|
||||
// To obtain a streaming connection from args.
|
||||
func (n NATSArgs) connectStan() (stan.Conn, error) {
|
||||
scheme := "nats"
|
||||
if n.Secure {
|
||||
scheme = "tls"
|
||||
}
|
||||
|
||||
var addressURL string
|
||||
//nolint:gocritic
|
||||
if n.Username != "" && n.Password != "" {
|
||||
addressURL = scheme + "://" + n.Username + ":" + n.Password + "@" + n.Address.String()
|
||||
} else if n.Token != "" {
|
||||
addressURL = scheme + "://" + n.Token + "@" + n.Address.String()
|
||||
} else {
|
||||
addressURL = scheme + "://" + n.Address.String()
|
||||
}
|
||||
|
||||
u, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientID := u.String()
|
||||
|
||||
connOpts := []stan.Option{stan.NatsURL(addressURL)}
|
||||
if n.Streaming.MaxPubAcksInflight > 0 {
|
||||
connOpts = append(connOpts, stan.MaxPubAcksInflight(n.Streaming.MaxPubAcksInflight))
|
||||
}
|
||||
if n.UserCredentials != "" {
|
||||
connOpts = append(connOpts, stan.NatsOptions(nats.UserCredentials(n.UserCredentials)))
|
||||
}
|
||||
|
||||
return stan.Connect(n.Streaming.ClusterID, clientID, connOpts...)
|
||||
}
|
||||
|
||||
// NATSTarget - NATS target.
|
||||
type NATSTarget struct {
|
||||
initOnce once.Init
|
||||
|
||||
id event.TargetID
|
||||
args NATSArgs
|
||||
natsConn *nats.Conn
|
||||
stanConn stan.Conn
|
||||
jstream nats.JetStream
|
||||
store store.Store[event.Event]
|
||||
loggerOnce logger.LogOnce
|
||||
quitCh chan struct{}
|
||||
}
|
||||
|
||||
// ID - returns target ID.
|
||||
func (target *NATSTarget) ID() event.TargetID {
|
||||
return target.id
|
||||
}
|
||||
|
||||
// Name - returns the Name of the target.
|
||||
func (target *NATSTarget) Name() string {
|
||||
return target.ID().String()
|
||||
}
|
||||
|
||||
// Store returns any underlying store if set.
|
||||
func (target *NATSTarget) Store() event.TargetStore {
|
||||
return target.store
|
||||
}
|
||||
|
||||
// IsActive - Return true if target is up and active
|
||||
func (target *NATSTarget) IsActive() (bool, error) {
|
||||
if err := target.init(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return target.isActive()
|
||||
}
|
||||
|
||||
func (target *NATSTarget) isActive() (bool, error) {
|
||||
var connErr error
|
||||
if target.args.Streaming.Enable {
|
||||
if target.stanConn == nil || target.stanConn.NatsConn() == nil {
|
||||
target.stanConn, connErr = target.args.connectStan()
|
||||
} else if !target.stanConn.NatsConn().IsConnected() {
|
||||
return false, store.ErrNotConnected
|
||||
}
|
||||
} else {
|
||||
if target.natsConn == nil {
|
||||
target.natsConn, connErr = target.args.connectNats()
|
||||
} else if !target.natsConn.IsConnected() {
|
||||
return false, store.ErrNotConnected
|
||||
}
|
||||
}
|
||||
|
||||
if connErr != nil {
|
||||
if connErr.Error() == nats.ErrNoServers.Error() {
|
||||
return false, store.ErrNotConnected
|
||||
}
|
||||
return false, connErr
|
||||
}
|
||||
|
||||
if target.natsConn != nil && target.args.JetStream.Enable {
|
||||
target.jstream, connErr = target.natsConn.JetStream()
|
||||
if connErr != nil {
|
||||
if connErr.Error() == nats.ErrNoServers.Error() {
|
||||
return false, store.ErrNotConnected
|
||||
}
|
||||
return false, connErr
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Save - saves the events to the store which will be replayed when the Nats connection is active.
|
||||
func (target *NATSTarget) Save(eventData event.Event) error {
|
||||
if target.store != nil {
|
||||
_, err := target.store.Put(eventData)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return target.send(eventData)
|
||||
}
|
||||
|
||||
// send - sends an event to the Nats.
|
||||
func (target *NATSTarget) send(eventData event.Event) error {
|
||||
objectName, err := url.QueryUnescape(eventData.S3.Object.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := eventData.S3.Bucket.Name + "/" + objectName
|
||||
|
||||
data, err := json.Marshal(event.Log{EventName: eventData.EventName, Key: key, Records: []event.Event{eventData}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if target.stanConn != nil {
|
||||
if target.args.Streaming.Async {
|
||||
_, err = target.stanConn.PublishAsync(target.args.Subject, data, nil)
|
||||
} else {
|
||||
err = target.stanConn.Publish(target.args.Subject, data)
|
||||
}
|
||||
} else {
|
||||
if target.jstream != nil {
|
||||
_, err = target.jstream.Publish(target.args.Subject, data)
|
||||
} else {
|
||||
err = target.natsConn.Publish(target.args.Subject, data)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// SendFromStore - reads an event from store and sends it to Nats.
|
||||
func (target *NATSTarget) SendFromStore(key store.Key) error {
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventData, eErr := target.store.Get(key)
|
||||
if eErr != nil {
|
||||
// The last event key in a successful batch will be sent in the channel atmost once by the replayEvents()
|
||||
// Such events will not exist and wouldve been already been sent successfully.
|
||||
if os.IsNotExist(eErr) {
|
||||
return nil
|
||||
}
|
||||
return eErr
|
||||
}
|
||||
|
||||
if err := target.send(eventData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return target.store.Del(key)
|
||||
}
|
||||
|
||||
// Close - closes underneath connections to NATS server.
|
||||
func (target *NATSTarget) Close() (err error) {
|
||||
close(target.quitCh)
|
||||
if target.stanConn != nil {
|
||||
// closing the streaming connection does not close the provided NATS connection.
|
||||
if target.stanConn.NatsConn() != nil {
|
||||
target.stanConn.NatsConn().Close()
|
||||
}
|
||||
return target.stanConn.Close()
|
||||
}
|
||||
|
||||
if target.natsConn != nil {
|
||||
target.natsConn.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *NATSTarget) init() error {
|
||||
return target.initOnce.Do(target.initNATS)
|
||||
}
|
||||
|
||||
func (target *NATSTarget) initNATS() error {
|
||||
args := target.args
|
||||
|
||||
var err error
|
||||
if args.Streaming.Enable {
|
||||
target.loggerOnce(context.Background(), errors.New("NATS Streaming is deprecated please migrate to JetStream"), target.ID().String())
|
||||
var stanConn stan.Conn
|
||||
stanConn, err = args.connectStan()
|
||||
target.stanConn = stanConn
|
||||
} else {
|
||||
var natsConn *nats.Conn
|
||||
natsConn, err = args.connectNats()
|
||||
target.natsConn = natsConn
|
||||
}
|
||||
if err != nil {
|
||||
if err.Error() != nats.ErrNoServers.Error() {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if target.natsConn != nil && args.JetStream.Enable {
|
||||
var jstream nats.JetStream
|
||||
jstream, err = target.natsConn.JetStream()
|
||||
if err != nil {
|
||||
if err.Error() != nats.ErrNoServers.Error() {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
}
|
||||
return err
|
||||
}
|
||||
target.jstream = jstream
|
||||
}
|
||||
|
||||
yes, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewNATSTarget - creates new NATS target.
|
||||
func NewNATSTarget(id string, args NATSArgs, loggerOnce logger.LogOnce) (*NATSTarget, error) {
|
||||
var queueStore store.Store[event.Event]
|
||||
if args.QueueDir != "" {
|
||||
queueDir := filepath.Join(args.QueueDir, storePrefix+"-nats-"+id)
|
||||
queueStore = store.NewQueueStore[event.Event](queueDir, args.QueueLimit, event.StoreExtension)
|
||||
if err := queueStore.Open(); err != nil {
|
||||
return nil, fmt.Errorf("unable to initialize the queue store of NATS `%s`: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
target := &NATSTarget{
|
||||
id: event.TargetID{ID: id, Name: "nats"},
|
||||
args: args,
|
||||
loggerOnce: loggerOnce,
|
||||
store: queueStore,
|
||||
quitCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
if target.store != nil {
|
||||
store.StreamItems(target.store, target, target.quitCh, target.loggerOnce)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
131
internal/event/target/nats_contrib_test.go
Normal file
131
internal/event/target/nats_contrib_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* MinIO Object Storage (c) 2021-2023 MinIO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
natsserver "github.com/nats-io/nats-server/v2/test"
|
||||
)
|
||||
|
||||
func TestNatsConnPlain(t *testing.T) {
|
||||
opts := natsserver.DefaultTestOptions
|
||||
opts.Port = 14222
|
||||
s := natsserver.RunServer(&opts)
|
||||
defer s.Shutdown()
|
||||
|
||||
clientConfig := &NATSArgs{
|
||||
Enable: true,
|
||||
Address: xnet.Host{
|
||||
Name: "localhost",
|
||||
Port: (xnet.Port(opts.Port)),
|
||||
IsPortSet: true,
|
||||
},
|
||||
Subject: "test",
|
||||
}
|
||||
con, err := clientConfig.connectNats()
|
||||
if err != nil {
|
||||
t.Errorf("Could not connect to nats: %v", err)
|
||||
}
|
||||
defer con.Close()
|
||||
}
|
||||
|
||||
func TestNatsConnUserPass(t *testing.T) {
|
||||
opts := natsserver.DefaultTestOptions
|
||||
opts.Port = 14223
|
||||
opts.Username = "testminio"
|
||||
opts.Password = "miniotest"
|
||||
s := natsserver.RunServer(&opts)
|
||||
defer s.Shutdown()
|
||||
|
||||
clientConfig := &NATSArgs{
|
||||
Enable: true,
|
||||
Address: xnet.Host{
|
||||
Name: "localhost",
|
||||
Port: (xnet.Port(opts.Port)),
|
||||
IsPortSet: true,
|
||||
},
|
||||
Subject: "test",
|
||||
Username: opts.Username,
|
||||
Password: opts.Password,
|
||||
}
|
||||
|
||||
con, err := clientConfig.connectNats()
|
||||
if err != nil {
|
||||
t.Errorf("Could not connect to nats: %v", err)
|
||||
}
|
||||
defer con.Close()
|
||||
}
|
||||
|
||||
func TestNatsConnToken(t *testing.T) {
|
||||
opts := natsserver.DefaultTestOptions
|
||||
opts.Port = 14223
|
||||
opts.Authorization = "s3cr3t"
|
||||
s := natsserver.RunServer(&opts)
|
||||
defer s.Shutdown()
|
||||
|
||||
clientConfig := &NATSArgs{
|
||||
Enable: true,
|
||||
Address: xnet.Host{
|
||||
Name: "localhost",
|
||||
Port: (xnet.Port(opts.Port)),
|
||||
IsPortSet: true,
|
||||
},
|
||||
Subject: "test",
|
||||
Token: opts.Authorization,
|
||||
}
|
||||
|
||||
con, err := clientConfig.connectNats()
|
||||
if err != nil {
|
||||
t.Errorf("Could not connect to nats: %v", err)
|
||||
}
|
||||
defer con.Close()
|
||||
}
|
||||
|
||||
func TestNatsConnNKeySeed(t *testing.T) {
|
||||
opts := natsserver.DefaultTestOptions
|
||||
opts.Port = 14223
|
||||
opts.Nkeys = []*server.NkeyUser{
|
||||
{
|
||||
// Not a real NKey
|
||||
// Taken from https://docs.nats.io/running-a-nats-service/configuration/securing_nats/auth_intro/nkey_auth
|
||||
Nkey: "UDXU4RCSJNZOIQHZNWXHXORDPRTGNJAHAHFRGZNEEJCPQTT2M7NLCNF4",
|
||||
},
|
||||
}
|
||||
s := natsserver.RunServer(&opts)
|
||||
defer s.Shutdown()
|
||||
|
||||
clientConfig := &NATSArgs{
|
||||
Enable: true,
|
||||
Address: xnet.Host{
|
||||
Name: "localhost",
|
||||
Port: (xnet.Port(opts.Port)),
|
||||
IsPortSet: true,
|
||||
},
|
||||
Subject: "test",
|
||||
NKeySeed: "testdata/contrib/test.nkey",
|
||||
}
|
||||
|
||||
con, err := clientConfig.connectNats()
|
||||
if err != nil {
|
||||
t.Errorf("Could not connect to nats: %v", err)
|
||||
}
|
||||
defer con.Close()
|
||||
}
|
||||
98
internal/event/target/nats_tls_contrib_test.go
Normal file
98
internal/event/target/nats_tls_contrib_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* MinIO Object Storage (c) 2021-2023 MinIO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
natsserver "github.com/nats-io/nats-server/v2/test"
|
||||
)
|
||||
|
||||
func TestNatsConnTLSCustomCA(t *testing.T) {
|
||||
s, opts := natsserver.RunServerWithConfig(filepath.Join("testdata", "contrib", "nats_tls.conf"))
|
||||
defer s.Shutdown()
|
||||
|
||||
clientConfig := &NATSArgs{
|
||||
Enable: true,
|
||||
Address: xnet.Host{
|
||||
Name: "localhost",
|
||||
Port: (xnet.Port(opts.Port)),
|
||||
IsPortSet: true,
|
||||
},
|
||||
Subject: "test",
|
||||
Secure: true,
|
||||
CertAuthority: path.Join("testdata", "contrib", "certs", "root_ca_cert.pem"),
|
||||
}
|
||||
|
||||
con, err := clientConfig.connectNats()
|
||||
if err != nil {
|
||||
t.Errorf("Could not connect to nats: %v", err)
|
||||
}
|
||||
defer con.Close()
|
||||
}
|
||||
|
||||
func TestNatsConnTLSCustomCAHandshakeFirst(t *testing.T) {
|
||||
s, opts := natsserver.RunServerWithConfig(filepath.Join("testdata", "contrib", "nats_tls_handshake_first.conf"))
|
||||
defer s.Shutdown()
|
||||
|
||||
clientConfig := &NATSArgs{
|
||||
Enable: true,
|
||||
Address: xnet.Host{
|
||||
Name: "localhost",
|
||||
Port: (xnet.Port(opts.Port)),
|
||||
IsPortSet: true,
|
||||
},
|
||||
Subject: "test",
|
||||
Secure: true,
|
||||
CertAuthority: path.Join("testdata", "contrib", "certs", "root_ca_cert.pem"),
|
||||
TLSHandshakeFirst: true,
|
||||
}
|
||||
|
||||
con, err := clientConfig.connectNats()
|
||||
if err != nil {
|
||||
t.Errorf("Could not connect to nats: %v", err)
|
||||
}
|
||||
defer con.Close()
|
||||
}
|
||||
|
||||
func TestNatsConnTLSClientAuthorization(t *testing.T) {
|
||||
s, opts := natsserver.RunServerWithConfig(filepath.Join("testdata", "contrib", "nats_tls_client_cert.conf"))
|
||||
defer s.Shutdown()
|
||||
|
||||
clientConfig := &NATSArgs{
|
||||
Enable: true,
|
||||
Address: xnet.Host{
|
||||
Name: "localhost",
|
||||
Port: (xnet.Port(opts.Port)),
|
||||
IsPortSet: true,
|
||||
},
|
||||
Subject: "test",
|
||||
Secure: true,
|
||||
CertAuthority: path.Join("testdata", "contrib", "certs", "root_ca_cert.pem"),
|
||||
ClientCert: path.Join("testdata", "contrib", "certs", "nats_client_cert.pem"),
|
||||
ClientKey: path.Join("testdata", "contrib", "certs", "nats_client_key.pem"),
|
||||
}
|
||||
|
||||
con, err := clientConfig.connectNats()
|
||||
if err != nil {
|
||||
t.Errorf("Could not connect to nats: %v", err)
|
||||
}
|
||||
defer con.Close()
|
||||
}
|
||||
288
internal/event/target/nsq.go
Normal file
288
internal/event/target/nsq.go
Normal file
@@ -0,0 +1,288 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/nsqio/go-nsq"
|
||||
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/minio/internal/once"
|
||||
"github.com/minio/minio/internal/store"
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
)
|
||||
|
||||
// NSQ constants
|
||||
const (
|
||||
NSQAddress = "nsqd_address"
|
||||
NSQTopic = "topic"
|
||||
NSQTLS = "tls"
|
||||
NSQTLSSkipVerify = "tls_skip_verify"
|
||||
NSQQueueDir = "queue_dir"
|
||||
NSQQueueLimit = "queue_limit"
|
||||
|
||||
EnvNSQEnable = "MINIO_NOTIFY_NSQ_ENABLE"
|
||||
EnvNSQAddress = "MINIO_NOTIFY_NSQ_NSQD_ADDRESS"
|
||||
EnvNSQTopic = "MINIO_NOTIFY_NSQ_TOPIC"
|
||||
EnvNSQTLS = "MINIO_NOTIFY_NSQ_TLS"
|
||||
EnvNSQTLSSkipVerify = "MINIO_NOTIFY_NSQ_TLS_SKIP_VERIFY"
|
||||
EnvNSQQueueDir = "MINIO_NOTIFY_NSQ_QUEUE_DIR"
|
||||
EnvNSQQueueLimit = "MINIO_NOTIFY_NSQ_QUEUE_LIMIT"
|
||||
)
|
||||
|
||||
// NSQArgs - NSQ target arguments.
|
||||
type NSQArgs struct {
|
||||
Enable bool `json:"enable"`
|
||||
NSQDAddress xnet.Host `json:"nsqdAddress"`
|
||||
Topic string `json:"topic"`
|
||||
TLS struct {
|
||||
Enable bool `json:"enable"`
|
||||
SkipVerify bool `json:"skipVerify"`
|
||||
} `json:"tls"`
|
||||
QueueDir string `json:"queueDir"`
|
||||
QueueLimit uint64 `json:"queueLimit"`
|
||||
}
|
||||
|
||||
// Validate NSQArgs fields
|
||||
func (n NSQArgs) Validate() error {
|
||||
if !n.Enable {
|
||||
return nil
|
||||
}
|
||||
|
||||
if n.NSQDAddress.IsEmpty() {
|
||||
return errors.New("empty nsqdAddress")
|
||||
}
|
||||
|
||||
if n.Topic == "" {
|
||||
return errors.New("empty topic")
|
||||
}
|
||||
if n.QueueDir != "" {
|
||||
if !filepath.IsAbs(n.QueueDir) {
|
||||
return errors.New("queueDir path should be absolute")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NSQTarget - NSQ target.
|
||||
type NSQTarget struct {
|
||||
initOnce once.Init
|
||||
|
||||
id event.TargetID
|
||||
args NSQArgs
|
||||
producer *nsq.Producer
|
||||
store store.Store[event.Event]
|
||||
config *nsq.Config
|
||||
loggerOnce logger.LogOnce
|
||||
quitCh chan struct{}
|
||||
}
|
||||
|
||||
// ID - returns target ID.
|
||||
func (target *NSQTarget) ID() event.TargetID {
|
||||
return target.id
|
||||
}
|
||||
|
||||
// Name - returns the Name of the target.
|
||||
func (target *NSQTarget) Name() string {
|
||||
return target.ID().String()
|
||||
}
|
||||
|
||||
// Store returns any underlying store if set.
|
||||
func (target *NSQTarget) Store() event.TargetStore {
|
||||
return target.store
|
||||
}
|
||||
|
||||
// IsActive - Return true if target is up and active
|
||||
func (target *NSQTarget) IsActive() (bool, error) {
|
||||
if err := target.init(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return target.isActive()
|
||||
}
|
||||
|
||||
func (target *NSQTarget) isActive() (bool, error) {
|
||||
if target.producer == nil {
|
||||
producer, err := nsq.NewProducer(target.args.NSQDAddress.String(), target.config)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
target.producer = producer
|
||||
}
|
||||
|
||||
if err := target.producer.Ping(); err != nil {
|
||||
// To treat "connection refused" errors as errNotConnected.
|
||||
if xnet.IsConnRefusedErr(err) {
|
||||
return false, store.ErrNotConnected
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Save - saves the events to the store which will be replayed when the nsq connection is active.
|
||||
func (target *NSQTarget) Save(eventData event.Event) error {
|
||||
if target.store != nil {
|
||||
_, err := target.store.Put(eventData)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return target.send(eventData)
|
||||
}
|
||||
|
||||
// send - sends an event to the NSQ.
|
||||
func (target *NSQTarget) send(eventData event.Event) error {
|
||||
objectName, err := url.QueryUnescape(eventData.S3.Object.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := eventData.S3.Bucket.Name + "/" + objectName
|
||||
|
||||
data, err := json.Marshal(event.Log{EventName: eventData.EventName, Key: key, Records: []event.Event{eventData}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return target.producer.Publish(target.args.Topic, data)
|
||||
}
|
||||
|
||||
// SendFromStore - reads an event from store and sends it to NSQ.
|
||||
func (target *NSQTarget) SendFromStore(key store.Key) error {
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventData, eErr := target.store.Get(key)
|
||||
if eErr != nil {
|
||||
// The last event key in a successful batch will be sent in the channel atmost once by the replayEvents()
|
||||
// Such events will not exist and wouldve been already been sent successfully.
|
||||
if os.IsNotExist(eErr) {
|
||||
return nil
|
||||
}
|
||||
return eErr
|
||||
}
|
||||
|
||||
if err := target.send(eventData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the event from store.
|
||||
return target.store.Del(key)
|
||||
}
|
||||
|
||||
// Close - closes underneath connections to NSQD server.
|
||||
func (target *NSQTarget) Close() (err error) {
|
||||
close(target.quitCh)
|
||||
if target.producer != nil {
|
||||
// this blocks until complete:
|
||||
target.producer.Stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *NSQTarget) init() error {
|
||||
return target.initOnce.Do(target.initNSQ)
|
||||
}
|
||||
|
||||
func (target *NSQTarget) initNSQ() error {
|
||||
args := target.args
|
||||
|
||||
config := nsq.NewConfig()
|
||||
if args.TLS.Enable {
|
||||
config.TlsV1 = true
|
||||
config.TlsConfig = &tls.Config{
|
||||
InsecureSkipVerify: args.TLS.SkipVerify,
|
||||
}
|
||||
}
|
||||
target.config = config
|
||||
|
||||
producer, err := nsq.NewProducer(args.NSQDAddress.String(), config)
|
||||
if err != nil {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
return err
|
||||
}
|
||||
target.producer = producer
|
||||
|
||||
err = target.producer.Ping()
|
||||
if err != nil {
|
||||
// To treat "connection refused" errors as errNotConnected.
|
||||
if !xnet.IsConnRefusedErr(err) && !xnet.IsConnResetErr(err) {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
}
|
||||
target.producer.Stop()
|
||||
return err
|
||||
}
|
||||
|
||||
yes, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewNSQTarget - creates new NSQ target.
|
||||
func NewNSQTarget(id string, args NSQArgs, loggerOnce logger.LogOnce) (*NSQTarget, error) {
|
||||
var queueStore store.Store[event.Event]
|
||||
if args.QueueDir != "" {
|
||||
queueDir := filepath.Join(args.QueueDir, storePrefix+"-nsq-"+id)
|
||||
queueStore = store.NewQueueStore[event.Event](queueDir, args.QueueLimit, event.StoreExtension)
|
||||
if err := queueStore.Open(); err != nil {
|
||||
return nil, fmt.Errorf("unable to initialize the queue store of NSQ `%s`: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
target := &NSQTarget{
|
||||
id: event.TargetID{ID: id, Name: "nsq"},
|
||||
args: args,
|
||||
loggerOnce: loggerOnce,
|
||||
store: queueStore,
|
||||
quitCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
if target.store != nil {
|
||||
store.StreamItems(target.store, target, target.quitCh, target.loggerOnce)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
98
internal/event/target/nsq_test.go
Normal file
98
internal/event/target/nsq_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
)
|
||||
|
||||
func TestNSQArgs_Validate(t *testing.T) {
|
||||
type fields struct {
|
||||
Enable bool
|
||||
NSQDAddress xnet.Host
|
||||
Topic string
|
||||
TLS struct {
|
||||
Enable bool
|
||||
SkipVerify bool
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "test1_missing_topic",
|
||||
fields: fields{
|
||||
Enable: true,
|
||||
NSQDAddress: xnet.Host{
|
||||
Name: "127.0.0.1",
|
||||
Port: 4150,
|
||||
IsPortSet: true,
|
||||
},
|
||||
Topic: "",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "test2_disabled",
|
||||
fields: fields{
|
||||
Enable: false,
|
||||
NSQDAddress: xnet.Host{},
|
||||
Topic: "topic",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "test3_OK",
|
||||
fields: fields{
|
||||
Enable: true,
|
||||
NSQDAddress: xnet.Host{
|
||||
Name: "127.0.0.1",
|
||||
Port: 4150,
|
||||
IsPortSet: true,
|
||||
},
|
||||
Topic: "topic",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "test4_emptynsqdaddr",
|
||||
fields: fields{
|
||||
Enable: true,
|
||||
NSQDAddress: xnet.Host{},
|
||||
Topic: "topic",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
n := NSQArgs{
|
||||
Enable: tt.fields.Enable,
|
||||
NSQDAddress: tt.fields.NSQDAddress,
|
||||
Topic: tt.fields.Topic,
|
||||
}
|
||||
if err := n.Validate(); (err != nil) != tt.wantErr {
|
||||
t.Errorf("NSQArgs.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
493
internal/event/target/postgresql.go
Normal file
493
internal/event/target/postgresql.go
Normal file
@@ -0,0 +1,493 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
_ "github.com/lib/pq" // Register postgres driver
|
||||
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/minio/internal/once"
|
||||
"github.com/minio/minio/internal/store"
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
)
|
||||
|
||||
const (
|
||||
psqlTableExists = `SELECT 1 FROM %s;`
|
||||
psqlCreateNamespaceTable = `CREATE TABLE %s (key VARCHAR PRIMARY KEY, value JSONB);`
|
||||
psqlCreateAccessTable = `CREATE TABLE %s (event_time TIMESTAMP WITH TIME ZONE NOT NULL, event_data JSONB);`
|
||||
|
||||
psqlUpdateRow = `INSERT INTO %s (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;`
|
||||
psqlDeleteRow = `DELETE FROM %s WHERE key = $1;`
|
||||
psqlInsertRow = `INSERT INTO %s (event_time, event_data) VALUES ($1, $2);`
|
||||
)
|
||||
|
||||
// Postgres constants
|
||||
const (
|
||||
PostgresFormat = "format"
|
||||
PostgresConnectionString = "connection_string"
|
||||
PostgresTable = "table"
|
||||
PostgresHost = "host"
|
||||
PostgresPort = "port"
|
||||
PostgresUsername = "username"
|
||||
PostgresPassword = "password"
|
||||
PostgresDatabase = "database"
|
||||
PostgresQueueDir = "queue_dir"
|
||||
PostgresQueueLimit = "queue_limit"
|
||||
PostgresMaxOpenConnections = "max_open_connections"
|
||||
|
||||
EnvPostgresEnable = "MINIO_NOTIFY_POSTGRES_ENABLE"
|
||||
EnvPostgresFormat = "MINIO_NOTIFY_POSTGRES_FORMAT"
|
||||
EnvPostgresConnectionString = "MINIO_NOTIFY_POSTGRES_CONNECTION_STRING"
|
||||
EnvPostgresTable = "MINIO_NOTIFY_POSTGRES_TABLE"
|
||||
EnvPostgresHost = "MINIO_NOTIFY_POSTGRES_HOST"
|
||||
EnvPostgresPort = "MINIO_NOTIFY_POSTGRES_PORT"
|
||||
EnvPostgresUsername = "MINIO_NOTIFY_POSTGRES_USERNAME"
|
||||
EnvPostgresPassword = "MINIO_NOTIFY_POSTGRES_PASSWORD"
|
||||
EnvPostgresDatabase = "MINIO_NOTIFY_POSTGRES_DATABASE"
|
||||
EnvPostgresQueueDir = "MINIO_NOTIFY_POSTGRES_QUEUE_DIR"
|
||||
EnvPostgresQueueLimit = "MINIO_NOTIFY_POSTGRES_QUEUE_LIMIT"
|
||||
EnvPostgresMaxOpenConnections = "MINIO_NOTIFY_POSTGRES_MAX_OPEN_CONNECTIONS"
|
||||
)
|
||||
|
||||
// PostgreSQLArgs - PostgreSQL target arguments.
|
||||
type PostgreSQLArgs struct {
|
||||
Enable bool `json:"enable"`
|
||||
Format string `json:"format"`
|
||||
ConnectionString string `json:"connectionString"`
|
||||
Table string `json:"table"`
|
||||
Host xnet.Host `json:"host"` // default: localhost
|
||||
Port string `json:"port"` // default: 5432
|
||||
Username string `json:"username"` // default: user running minio
|
||||
Password string `json:"password"` // default: no password
|
||||
Database string `json:"database"` // default: same as user
|
||||
QueueDir string `json:"queueDir"`
|
||||
QueueLimit uint64 `json:"queueLimit"`
|
||||
MaxOpenConnections int `json:"maxOpenConnections"`
|
||||
}
|
||||
|
||||
// Validate PostgreSQLArgs fields
|
||||
func (p PostgreSQLArgs) Validate() error {
|
||||
if !p.Enable {
|
||||
return nil
|
||||
}
|
||||
if p.Table == "" {
|
||||
return fmt.Errorf("empty table name")
|
||||
}
|
||||
if err := validatePsqlTableName(p.Table); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if p.Format != "" {
|
||||
f := strings.ToLower(p.Format)
|
||||
if f != event.NamespaceFormat && f != event.AccessFormat {
|
||||
return fmt.Errorf("unrecognized format value")
|
||||
}
|
||||
}
|
||||
|
||||
if p.ConnectionString != "" {
|
||||
// No pq API doesn't help to validate connection string
|
||||
// prior connection, so no validation for now.
|
||||
} else {
|
||||
// Some fields need to be specified when ConnectionString is unspecified
|
||||
if p.Port == "" {
|
||||
return fmt.Errorf("unspecified port")
|
||||
}
|
||||
if _, err := strconv.Atoi(p.Port); err != nil {
|
||||
return fmt.Errorf("invalid port")
|
||||
}
|
||||
if p.Database == "" {
|
||||
return fmt.Errorf("database unspecified")
|
||||
}
|
||||
}
|
||||
|
||||
if p.QueueDir != "" {
|
||||
if !filepath.IsAbs(p.QueueDir) {
|
||||
return errors.New("queueDir path should be absolute")
|
||||
}
|
||||
}
|
||||
|
||||
if p.MaxOpenConnections < 0 {
|
||||
return errors.New("maxOpenConnections cannot be less than zero")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostgreSQLTarget - PostgreSQL target.
|
||||
type PostgreSQLTarget struct {
|
||||
initOnce once.Init
|
||||
|
||||
id event.TargetID
|
||||
args PostgreSQLArgs
|
||||
updateStmt *sql.Stmt
|
||||
deleteStmt *sql.Stmt
|
||||
insertStmt *sql.Stmt
|
||||
db *sql.DB
|
||||
store store.Store[event.Event]
|
||||
firstPing bool
|
||||
connString string
|
||||
loggerOnce logger.LogOnce
|
||||
quitCh chan struct{}
|
||||
}
|
||||
|
||||
// ID - returns target ID.
|
||||
func (target *PostgreSQLTarget) ID() event.TargetID {
|
||||
return target.id
|
||||
}
|
||||
|
||||
// Name - returns the Name of the target.
|
||||
func (target *PostgreSQLTarget) Name() string {
|
||||
return target.ID().String()
|
||||
}
|
||||
|
||||
// Store returns any underlying store if set.
|
||||
func (target *PostgreSQLTarget) Store() event.TargetStore {
|
||||
return target.store
|
||||
}
|
||||
|
||||
// IsActive - Return true if target is up and active
|
||||
func (target *PostgreSQLTarget) IsActive() (bool, error) {
|
||||
if err := target.init(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return target.isActive()
|
||||
}
|
||||
|
||||
func (target *PostgreSQLTarget) isActive() (bool, error) {
|
||||
if err := target.db.Ping(); err != nil {
|
||||
if IsConnErr(err) {
|
||||
return false, store.ErrNotConnected
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Save - saves the events to the store if questore is configured, which will be replayed when the PostgreSQL connection is active.
|
||||
func (target *PostgreSQLTarget) Save(eventData event.Event) error {
|
||||
if target.store != nil {
|
||||
_, err := target.store.Put(eventData)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return target.send(eventData)
|
||||
}
|
||||
|
||||
// IsConnErr - To detect a connection error.
|
||||
func IsConnErr(err error) bool {
|
||||
return xnet.IsConnRefusedErr(err) || err.Error() == "sql: database is closed" || err.Error() == "sql: statement is closed" || err.Error() == "invalid connection"
|
||||
}
|
||||
|
||||
// send - sends an event to the PostgreSQL.
|
||||
func (target *PostgreSQLTarget) send(eventData event.Event) error {
|
||||
if target.args.Format == event.NamespaceFormat {
|
||||
objectName, err := url.QueryUnescape(eventData.S3.Object.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := eventData.S3.Bucket.Name + "/" + objectName
|
||||
|
||||
if eventData.EventName == event.ObjectRemovedDelete {
|
||||
_, err = target.deleteStmt.Exec(key)
|
||||
} else {
|
||||
var data []byte
|
||||
if data, err = json.Marshal(struct{ Records []event.Event }{[]event.Event{eventData}}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = target.updateStmt.Exec(key, data)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if target.args.Format == event.AccessFormat {
|
||||
eventTime, err := time.Parse(event.AMZTimeFormat, eventData.EventTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(struct{ Records []event.Event }{[]event.Event{eventData}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = target.insertStmt.Exec(eventTime, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendFromStore - reads an event from store and sends it to PostgreSQL.
|
||||
func (target *PostgreSQLTarget) SendFromStore(key store.Key) error {
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !target.firstPing {
|
||||
if err := target.executeStmts(); err != nil {
|
||||
if IsConnErr(err) {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
eventData, eErr := target.store.Get(key)
|
||||
if eErr != nil {
|
||||
// The last event key in a successful batch will be sent in the channel atmost once by the replayEvents()
|
||||
// Such events will not exist and wouldve been already been sent successfully.
|
||||
if os.IsNotExist(eErr) {
|
||||
return nil
|
||||
}
|
||||
return eErr
|
||||
}
|
||||
|
||||
if err := target.send(eventData); err != nil {
|
||||
if IsConnErr(err) {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the event from store.
|
||||
return target.store.Del(key)
|
||||
}
|
||||
|
||||
// Close - closes underneath connections to PostgreSQL database.
|
||||
func (target *PostgreSQLTarget) Close() error {
|
||||
close(target.quitCh)
|
||||
if target.updateStmt != nil {
|
||||
// FIXME: log returned error. ignore time being.
|
||||
_ = target.updateStmt.Close()
|
||||
}
|
||||
|
||||
if target.deleteStmt != nil {
|
||||
// FIXME: log returned error. ignore time being.
|
||||
_ = target.deleteStmt.Close()
|
||||
}
|
||||
|
||||
if target.insertStmt != nil {
|
||||
// FIXME: log returned error. ignore time being.
|
||||
_ = target.insertStmt.Close()
|
||||
}
|
||||
|
||||
if target.db != nil {
|
||||
target.db.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Executes the table creation statements.
|
||||
func (target *PostgreSQLTarget) executeStmts() error {
|
||||
_, err := target.db.Exec(fmt.Sprintf(psqlTableExists, target.args.Table))
|
||||
if err != nil {
|
||||
createStmt := psqlCreateNamespaceTable
|
||||
if target.args.Format == event.AccessFormat {
|
||||
createStmt = psqlCreateAccessTable
|
||||
}
|
||||
|
||||
if _, dbErr := target.db.Exec(fmt.Sprintf(createStmt, target.args.Table)); dbErr != nil {
|
||||
return dbErr
|
||||
}
|
||||
}
|
||||
|
||||
switch target.args.Format {
|
||||
case event.NamespaceFormat:
|
||||
// insert or update statement
|
||||
if target.updateStmt, err = target.db.Prepare(fmt.Sprintf(psqlUpdateRow, target.args.Table)); err != nil {
|
||||
return err
|
||||
}
|
||||
// delete statement
|
||||
if target.deleteStmt, err = target.db.Prepare(fmt.Sprintf(psqlDeleteRow, target.args.Table)); err != nil {
|
||||
return err
|
||||
}
|
||||
case event.AccessFormat:
|
||||
// insert statement
|
||||
if target.insertStmt, err = target.db.Prepare(fmt.Sprintf(psqlInsertRow, target.args.Table)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *PostgreSQLTarget) init() error {
|
||||
return target.initOnce.Do(target.initPostgreSQL)
|
||||
}
|
||||
|
||||
func (target *PostgreSQLTarget) initPostgreSQL() error {
|
||||
args := target.args
|
||||
|
||||
db, err := sql.Open("postgres", target.connString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target.db = db
|
||||
|
||||
if args.MaxOpenConnections > 0 {
|
||||
// Set the maximum connections limit
|
||||
target.db.SetMaxOpenConns(args.MaxOpenConnections)
|
||||
}
|
||||
|
||||
err = target.db.Ping()
|
||||
if err != nil {
|
||||
if !xnet.IsConnRefusedErr(err) && !xnet.IsConnResetErr(err) {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
}
|
||||
} else {
|
||||
if err = target.executeStmts(); err != nil {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
} else {
|
||||
target.firstPing = true
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
target.db.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
yes, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewPostgreSQLTarget - creates new PostgreSQL target.
|
||||
func NewPostgreSQLTarget(id string, args PostgreSQLArgs, loggerOnce logger.LogOnce) (*PostgreSQLTarget, error) {
|
||||
params := []string{args.ConnectionString}
|
||||
if args.ConnectionString == "" {
|
||||
params = []string{}
|
||||
if !args.Host.IsEmpty() {
|
||||
params = append(params, "host="+args.Host.String())
|
||||
}
|
||||
if args.Port != "" {
|
||||
params = append(params, "port="+args.Port)
|
||||
}
|
||||
if args.Username != "" {
|
||||
params = append(params, "username="+args.Username)
|
||||
}
|
||||
if args.Password != "" {
|
||||
params = append(params, "password="+args.Password)
|
||||
}
|
||||
if args.Database != "" {
|
||||
params = append(params, "dbname="+args.Database)
|
||||
}
|
||||
}
|
||||
connStr := strings.Join(params, " ")
|
||||
|
||||
var queueStore store.Store[event.Event]
|
||||
if args.QueueDir != "" {
|
||||
queueDir := filepath.Join(args.QueueDir, storePrefix+"-postgresql-"+id)
|
||||
queueStore = store.NewQueueStore[event.Event](queueDir, args.QueueLimit, event.StoreExtension)
|
||||
if err := queueStore.Open(); err != nil {
|
||||
return nil, fmt.Errorf("unable to initialize the queue store of PostgreSQL `%s`: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
target := &PostgreSQLTarget{
|
||||
id: event.TargetID{ID: id, Name: "postgresql"},
|
||||
args: args,
|
||||
firstPing: false,
|
||||
store: queueStore,
|
||||
connString: connStr,
|
||||
loggerOnce: loggerOnce,
|
||||
quitCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
if target.store != nil {
|
||||
store.StreamItems(target.store, target, target.quitCh, target.loggerOnce)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
var errInvalidPsqlTablename = errors.New("invalid PostgreSQL table")
|
||||
|
||||
func validatePsqlTableName(name string) error {
|
||||
// check for quoted string (string may not contain a quote)
|
||||
if match, err := regexp.MatchString("^\"[^\"]+\"$", name); err != nil {
|
||||
return err
|
||||
} else if match {
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalize the name to letters, digits, _ or $
|
||||
valid := true
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case unicode.IsLetter(r):
|
||||
return 'a'
|
||||
case unicode.IsDigit(r):
|
||||
return '0'
|
||||
case r == '_', r == '$':
|
||||
return r
|
||||
default:
|
||||
valid = false
|
||||
return -1
|
||||
}
|
||||
}, name)
|
||||
|
||||
if valid {
|
||||
// check for simple name or quoted name
|
||||
// - letter/underscore followed by one or more letter/digit/underscore
|
||||
// - any text between quotes (text cannot contain a quote itself)
|
||||
if match, err := regexp.MatchString("^[a_][a0_$]*$", cleaned); err != nil {
|
||||
return err
|
||||
} else if match {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return errInvalidPsqlTablename
|
||||
}
|
||||
52
internal/event/target/postgresql_test.go
Normal file
52
internal/event/target/postgresql_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPostgreSQLRegistration checks if postgres driver
|
||||
// is registered and fails otherwise.
|
||||
func TestPostgreSQLRegistration(t *testing.T) {
|
||||
var found bool
|
||||
if slices.Contains(sql.Drivers(), "postgres") {
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("postgres driver not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPsqlTableNameValidation(t *testing.T) {
|
||||
validTables := []string{"táblë", "table", "TableName", "\"Table name\"", "\"✅✅\"", "table$one", "\"táblë\""}
|
||||
invalidTables := []string{"table name", "table \"name\"", "✅✅", "$table$"}
|
||||
|
||||
for _, name := range validTables {
|
||||
if err := validatePsqlTableName(name); err != nil {
|
||||
t.Errorf("Should be valid: %s - %s", name, err)
|
||||
}
|
||||
}
|
||||
for _, name := range invalidTables {
|
||||
if err := validatePsqlTableName(name); err != errInvalidPsqlTablename {
|
||||
t.Errorf("Should be invalid: %s - %s", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
382
internal/event/target/redis.go
Normal file
382
internal/event/target/redis.go
Normal file
@@ -0,0 +1,382 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gomodule/redigo/redis"
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/minio/internal/once"
|
||||
"github.com/minio/minio/internal/store"
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
)
|
||||
|
||||
// Redis constants
|
||||
const (
|
||||
RedisFormat = "format"
|
||||
RedisAddress = "address"
|
||||
RedisPassword = "password"
|
||||
RedisUser = "user"
|
||||
RedisKey = "key"
|
||||
RedisQueueDir = "queue_dir"
|
||||
RedisQueueLimit = "queue_limit"
|
||||
|
||||
EnvRedisEnable = "MINIO_NOTIFY_REDIS_ENABLE"
|
||||
EnvRedisFormat = "MINIO_NOTIFY_REDIS_FORMAT"
|
||||
EnvRedisAddress = "MINIO_NOTIFY_REDIS_ADDRESS"
|
||||
EnvRedisPassword = "MINIO_NOTIFY_REDIS_PASSWORD"
|
||||
EnvRedisUser = "MINIO_NOTIFY_REDIS_USER"
|
||||
EnvRedisKey = "MINIO_NOTIFY_REDIS_KEY"
|
||||
EnvRedisQueueDir = "MINIO_NOTIFY_REDIS_QUEUE_DIR"
|
||||
EnvRedisQueueLimit = "MINIO_NOTIFY_REDIS_QUEUE_LIMIT"
|
||||
)
|
||||
|
||||
// RedisArgs - Redis target arguments.
|
||||
type RedisArgs struct {
|
||||
Enable bool `json:"enable"`
|
||||
Format string `json:"format"`
|
||||
Addr xnet.Host `json:"address"`
|
||||
Password string `json:"password"`
|
||||
User string `json:"user"`
|
||||
Key string `json:"key"`
|
||||
QueueDir string `json:"queueDir"`
|
||||
QueueLimit uint64 `json:"queueLimit"`
|
||||
}
|
||||
|
||||
// RedisAccessEvent holds event log data and timestamp
|
||||
type RedisAccessEvent struct {
|
||||
Event []event.Event
|
||||
EventTime string
|
||||
}
|
||||
|
||||
// Validate RedisArgs fields
|
||||
func (r RedisArgs) Validate() error {
|
||||
if !r.Enable {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.Format != "" {
|
||||
f := strings.ToLower(r.Format)
|
||||
if f != event.NamespaceFormat && f != event.AccessFormat {
|
||||
return fmt.Errorf("unrecognized format")
|
||||
}
|
||||
}
|
||||
|
||||
if r.Key == "" {
|
||||
return fmt.Errorf("empty key")
|
||||
}
|
||||
|
||||
if r.QueueDir != "" {
|
||||
if !filepath.IsAbs(r.QueueDir) {
|
||||
return errors.New("queueDir path should be absolute")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r RedisArgs) validateFormat(c redis.Conn) error {
|
||||
typeAvailable, err := redis.String(c.Do("TYPE", r.Key))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if typeAvailable != "none" {
|
||||
expectedType := "hash"
|
||||
if r.Format == event.AccessFormat {
|
||||
expectedType = "list"
|
||||
}
|
||||
|
||||
if typeAvailable != expectedType {
|
||||
return fmt.Errorf("expected type %v does not match with available type %v", expectedType, typeAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RedisTarget - Redis target.
|
||||
type RedisTarget struct {
|
||||
initOnce once.Init
|
||||
|
||||
id event.TargetID
|
||||
args RedisArgs
|
||||
pool *redis.Pool
|
||||
store store.Store[event.Event]
|
||||
firstPing bool
|
||||
loggerOnce logger.LogOnce
|
||||
quitCh chan struct{}
|
||||
}
|
||||
|
||||
// ID - returns target ID.
|
||||
func (target *RedisTarget) ID() event.TargetID {
|
||||
return target.id
|
||||
}
|
||||
|
||||
// Name - returns the Name of the target.
|
||||
func (target *RedisTarget) Name() string {
|
||||
return target.ID().String()
|
||||
}
|
||||
|
||||
// Store returns any underlying store if set.
|
||||
func (target *RedisTarget) Store() event.TargetStore {
|
||||
return target.store
|
||||
}
|
||||
|
||||
// IsActive - Return true if target is up and active
|
||||
func (target *RedisTarget) IsActive() (bool, error) {
|
||||
if err := target.init(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return target.isActive()
|
||||
}
|
||||
|
||||
func (target *RedisTarget) isActive() (bool, error) {
|
||||
conn := target.pool.Get()
|
||||
defer conn.Close()
|
||||
|
||||
_, pingErr := conn.Do("PING")
|
||||
if pingErr != nil {
|
||||
if xnet.IsConnRefusedErr(pingErr) {
|
||||
return false, store.ErrNotConnected
|
||||
}
|
||||
return false, pingErr
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Save - saves the events to the store if questore is configured, which will be replayed when the redis connection is active.
|
||||
func (target *RedisTarget) Save(eventData event.Event) error {
|
||||
if target.store != nil {
|
||||
_, err := target.store.Put(eventData)
|
||||
return err
|
||||
}
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return target.send(eventData)
|
||||
}
|
||||
|
||||
// send - sends an event to the redis.
|
||||
func (target *RedisTarget) send(eventData event.Event) error {
|
||||
conn := target.pool.Get()
|
||||
defer conn.Close()
|
||||
|
||||
if target.args.Format == event.NamespaceFormat {
|
||||
objectName, err := url.QueryUnescape(eventData.S3.Object.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := eventData.S3.Bucket.Name + "/" + objectName
|
||||
|
||||
if eventData.EventName == event.ObjectRemovedDelete {
|
||||
_, err = conn.Do("HDEL", target.args.Key, key)
|
||||
} else {
|
||||
var data []byte
|
||||
if data, err = json.Marshal(struct{ Records []event.Event }{[]event.Event{eventData}}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = conn.Do("HSET", target.args.Key, key, data)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if target.args.Format == event.AccessFormat {
|
||||
data, err := json.Marshal([]RedisAccessEvent{{Event: []event.Event{eventData}, EventTime: eventData.EventTime}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := conn.Do("RPUSH", target.args.Key, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendFromStore - reads an event from store and sends it to redis.
|
||||
func (target *RedisTarget) SendFromStore(key store.Key) error {
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn := target.pool.Get()
|
||||
defer conn.Close()
|
||||
|
||||
_, pingErr := conn.Do("PING")
|
||||
if pingErr != nil {
|
||||
if xnet.IsConnRefusedErr(pingErr) {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
return pingErr
|
||||
}
|
||||
|
||||
if !target.firstPing {
|
||||
if err := target.args.validateFormat(conn); err != nil {
|
||||
if xnet.IsConnRefusedErr(err) {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
return err
|
||||
}
|
||||
target.firstPing = true
|
||||
}
|
||||
|
||||
eventData, eErr := target.store.Get(key)
|
||||
if eErr != nil {
|
||||
// The last event key in a successful batch will be sent in the channel atmost once by the replayEvents()
|
||||
// Such events will not exist and would've been already been sent successfully.
|
||||
if os.IsNotExist(eErr) {
|
||||
return nil
|
||||
}
|
||||
return eErr
|
||||
}
|
||||
|
||||
if err := target.send(eventData); err != nil {
|
||||
if xnet.IsConnRefusedErr(err) {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the event from store.
|
||||
return target.store.Del(key)
|
||||
}
|
||||
|
||||
// Close - releases the resources used by the pool.
|
||||
func (target *RedisTarget) Close() error {
|
||||
close(target.quitCh)
|
||||
if target.pool != nil {
|
||||
return target.pool.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *RedisTarget) init() error {
|
||||
return target.initOnce.Do(target.initRedis)
|
||||
}
|
||||
|
||||
func (target *RedisTarget) initRedis() error {
|
||||
conn := target.pool.Get()
|
||||
defer conn.Close()
|
||||
|
||||
_, pingErr := conn.Do("PING")
|
||||
if pingErr != nil {
|
||||
if !xnet.IsConnRefusedErr(pingErr) && !xnet.IsConnResetErr(pingErr) {
|
||||
target.loggerOnce(context.Background(), pingErr, target.ID().String())
|
||||
}
|
||||
return pingErr
|
||||
}
|
||||
|
||||
if err := target.args.validateFormat(conn); err != nil {
|
||||
target.loggerOnce(context.Background(), err, target.ID().String())
|
||||
return err
|
||||
}
|
||||
|
||||
target.firstPing = true
|
||||
|
||||
yes, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewRedisTarget - creates new Redis target.
|
||||
func NewRedisTarget(id string, args RedisArgs, loggerOnce logger.LogOnce) (*RedisTarget, error) {
|
||||
var queueStore store.Store[event.Event]
|
||||
if args.QueueDir != "" {
|
||||
queueDir := filepath.Join(args.QueueDir, storePrefix+"-redis-"+id)
|
||||
queueStore = store.NewQueueStore[event.Event](queueDir, args.QueueLimit, event.StoreExtension)
|
||||
if err := queueStore.Open(); err != nil {
|
||||
return nil, fmt.Errorf("unable to initialize the queue store of Redis `%s`: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
pool := &redis.Pool{
|
||||
MaxIdle: 3,
|
||||
IdleTimeout: 2 * 60 * time.Second,
|
||||
Dial: func() (redis.Conn, error) {
|
||||
conn, err := redis.Dial("tcp", args.Addr.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if args.Password != "" {
|
||||
if args.User != "" {
|
||||
if _, err = conn.Do("AUTH", args.User, args.Password); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if _, err = conn.Do("AUTH", args.Password); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Must be done after AUTH
|
||||
if _, err = conn.Do("CLIENT", "SETNAME", "MinIO"); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
},
|
||||
TestOnBorrow: func(c redis.Conn, t time.Time) error {
|
||||
_, err := c.Do("PING")
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
target := &RedisTarget{
|
||||
id: event.TargetID{ID: id, Name: "redis"},
|
||||
args: args,
|
||||
pool: pool,
|
||||
store: queueStore,
|
||||
loggerOnce: loggerOnce,
|
||||
quitCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
if target.store != nil {
|
||||
store.StreamItems(target.store, target, target.quitCh, target.loggerOnce)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
13
internal/event/target/testdata/contrib/certs/nats_client_cert.pem
vendored
Normal file
13
internal/event/target/testdata/contrib/certs/nats_client_cert.pem
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICCjCCAbGgAwIBAgIUKLFyLD0Ze9gR3A2aBxgEiT6MgZUwCgYIKoZIzj0EAwIw
|
||||
GDEWMBQGA1UEAwwNTWluaW8gUm9vdCBDQTAeFw0yMDA5MTQxMzI0MzNaFw0zMDA5
|
||||
MTIxMzI0MzNaMEIxCzAJBgNVBAYTAkNBMQ4wDAYDVQQKDAVNaW5JTzEPMA0GA1UE
|
||||
CwwGQ2xpZW50MRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO
|
||||
PQMBBwNCAARAhYrQXYbzeKyVSw8nf57gBphwFP1o5S7CjxoGKCfghzdhExKiEmbi
|
||||
sK+FSS2YtltU7cM7L7AduLIbuEnGHHYQo4GuMIGrMAkGA1UdEwQCMAAwUwYDVR0j
|
||||
BEwwSoAUWN6Fr30E5vvvNOBkuGGkqGzA3SihHKQaMBgxFjAUBgNVBAMMDU1pbmlv
|
||||
IFJvb3QgQ0GCFHiTsAON45VvwFb0MxHEdLPeWi95MA4GA1UdDwEB/wQEAwIFoDAd
|
||||
BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwGgYDVR0RBBMwEYcEfwAAAYIJ
|
||||
bG9jYWxob3N0MAoGCCqGSM49BAMCA0cAMEQCIC7MHOEf0C/zqw/ZOaCffeJIMeFm
|
||||
iT8ugBfhFbgGkd5YAiBz9FEfV4JMZQ4N29WLmvxxDSxkL8g5e3fnIK8Aa4excw==
|
||||
-----END CERTIFICATE-----
|
||||
5
internal/event/target/testdata/contrib/certs/nats_client_key.pem
vendored
Normal file
5
internal/event/target/testdata/contrib/certs/nats_client_key.pem
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIBluB2BuspJcz1e58rnXpQEx48/ZwNmygNw06NbdTZDroAoGCCqGSM49
|
||||
AwEHoUQDQgAEQIWK0F2G83islUsPJ3+e4AaYcBT9aOUuwo8aBign4Ic3YRMSohJm
|
||||
4rCvhUktmLZbVO3DOy+wHbiyG7hJxhx2EA==
|
||||
-----END EC PRIVATE KEY-----
|
||||
12
internal/event/target/testdata/contrib/certs/nats_server_cert.pem
vendored
Normal file
12
internal/event/target/testdata/contrib/certs/nats_server_cert.pem
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIByTCCAW+gAwIBAgIUdAg80BTm1El7s5ZZezgjsls9BwkwCgYIKoZIzj0EAwIw
|
||||
GDEWMBQGA1UEAwwNTWluaW8gUm9vdCBDQTAeFw0yMDA5MTQxMjQzMjNaFw0zMDA5
|
||||
MTIxMjQzMjNaMAAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASolKUI7FVSA2Ts
|
||||
+GSW/DHDKNczDNjfccI2GLETso6ie8buveOODj1JIL9ff5pRDN+U6QvwwlDmXEqh
|
||||
1a6XBI4Ho4GuMIGrMAkGA1UdEwQCMAAwUwYDVR0jBEwwSoAUWN6Fr30E5vvvNOBk
|
||||
uGGkqGzA3SihHKQaMBgxFjAUBgNVBAMMDU1pbmlvIFJvb3QgQ0GCFHiTsAON45Vv
|
||||
wFb0MxHEdLPeWi95MA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcD
|
||||
AQYIKwYBBQUHAwIwGgYDVR0RBBMwEYcEfwAAAYIJbG9jYWxob3N0MAoGCCqGSM49
|
||||
BAMCA0gAMEUCIB7WXnQAkmjw2QE6A3uOscOIctJnlVNREfm4V9CrF6UGAiEA734B
|
||||
vKlhMk8H459BRoIp8GpOuUWqLqocSmMM1febvcg=
|
||||
-----END CERTIFICATE-----
|
||||
5
internal/event/target/testdata/contrib/certs/nats_server_key.pem
vendored
Normal file
5
internal/event/target/testdata/contrib/certs/nats_server_key.pem
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEILFuMS2xvsc/CsuqtSv3S2iSCcc28rZsg1wpR2kirXFloAoGCCqGSM49
|
||||
AwEHoUQDQgAEqJSlCOxVUgNk7PhklvwxwyjXMwzY33HCNhixE7KOonvG7r3jjg49
|
||||
SSC/X3+aUQzflOkL8MJQ5lxKodWulwSOBw==
|
||||
-----END EC PRIVATE KEY-----
|
||||
11
internal/event/target/testdata/contrib/certs/root_ca_cert.pem
vendored
Normal file
11
internal/event/target/testdata/contrib/certs/root_ca_cert.pem
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBlTCCATygAwIBAgIUeJOwA43jlW/AVvQzEcR0s95aL3kwCgYIKoZIzj0EAwIw
|
||||
GDEWMBQGA1UEAwwNTWluaW8gUm9vdCBDQTAeFw0yMDA5MTQxMjMwMDJaFw0zMDA5
|
||||
MTIxMjMwMDJaMBgxFjAUBgNVBAMMDU1pbmlvIFJvb3QgQ0EwWTATBgcqhkjOPQIB
|
||||
BggqhkjOPQMBBwNCAARK9fVNGHc1h5B5fpOMyEdyhh18xNNcNUGQ5iGLO97Z0KtK
|
||||
5vRlDeeE1I0SaJgqppm9OEHw32JU0HMi4FBZi2Rso2QwYjAdBgNVHQ4EFgQUWN6F
|
||||
r30E5vvvNOBkuGGkqGzA3SgwHwYDVR0jBBgwFoAUWN6Fr30E5vvvNOBkuGGkqGzA
|
||||
3SgwDwYDVR0TAQH/BAUwAwEB/zAPBgNVHREECDAGhwR/AAABMAoGCCqGSM49BAMC
|
||||
A0cAMEQCIDPOiks2Vs3RmuJZl5HHjuqaFSOAp1g7pZpMb3Qrh9YDAiAtjO2xOpkS
|
||||
WynK8P7EfyQP/IUa7GxJIoHk6/H/TCsYvQ==
|
||||
-----END CERTIFICATE-----
|
||||
5
internal/event/target/testdata/contrib/certs/root_ca_key.pem
vendored
Normal file
5
internal/event/target/testdata/contrib/certs/root_ca_key.pem
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIB8tAGuc9FP4XbYqMP67TKgjL7OTrACGgEmTf+zMvYRhoAoGCCqGSM49
|
||||
AwEHoUQDQgAESvX1TRh3NYeQeX6TjMhHcoYdfMTTXDVBkOYhizve2dCrSub0ZQ3n
|
||||
hNSNEmiYKqaZvThB8N9iVNBzIuBQWYtkbA==
|
||||
-----END EC PRIVATE KEY-----
|
||||
7
internal/event/target/testdata/contrib/nats_tls.conf
vendored
Normal file
7
internal/event/target/testdata/contrib/nats_tls.conf
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
port: 14225
|
||||
net: localhost
|
||||
|
||||
tls {
|
||||
cert_file: "./testdata/contrib/certs/nats_server_cert.pem"
|
||||
key_file: "./testdata/contrib/certs/nats_server_key.pem"
|
||||
}
|
||||
18
internal/event/target/testdata/contrib/nats_tls_client_cert.conf
vendored
Normal file
18
internal/event/target/testdata/contrib/nats_tls_client_cert.conf
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
port: 14226
|
||||
net: localhost
|
||||
|
||||
tls {
|
||||
cert_file: "./testdata/contrib/certs/nats_server_cert.pem"
|
||||
key_file: "./testdata/contrib/certs/nats_server_key.pem"
|
||||
ca_file: "./testdata/contrib/certs/root_ca_cert.pem"
|
||||
verify_and_map: true
|
||||
}
|
||||
authorization {
|
||||
ADMIN = {
|
||||
publish = ">"
|
||||
subscribe = ">"
|
||||
}
|
||||
users = [
|
||||
{user: "CN=localhost,OU=Client,O=MinIO,C=CA", permissions: $ADMIN}
|
||||
]
|
||||
}
|
||||
8
internal/event/target/testdata/contrib/nats_tls_handshake_first.conf
vendored
Normal file
8
internal/event/target/testdata/contrib/nats_tls_handshake_first.conf
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
port: 14227
|
||||
net: localhost
|
||||
|
||||
tls {
|
||||
cert_file: "./testdata/contrib/certs/nats_server_cert.pem"
|
||||
key_file: "./testdata/contrib/certs/nats_server_key.pem"
|
||||
handshake_first: true
|
||||
}
|
||||
1
internal/event/target/testdata/contrib/test.nkey
vendored
Normal file
1
internal/event/target/testdata/contrib/test.nkey
vendored
Normal file
@@ -0,0 +1 @@
|
||||
SUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY
|
||||
316
internal/event/target/webhook.go
Normal file
316
internal/event/target/webhook.go
Normal file
@@ -0,0 +1,316 @@
|
||||
// Copyright (c) 2015-2023 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package target
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/event"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/minio/internal/once"
|
||||
"github.com/minio/minio/internal/store"
|
||||
"github.com/minio/pkg/v3/certs"
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
)
|
||||
|
||||
// Webhook constants
|
||||
const (
|
||||
WebhookEndpoint = "endpoint"
|
||||
WebhookAuthToken = "auth_token"
|
||||
WebhookQueueDir = "queue_dir"
|
||||
WebhookQueueLimit = "queue_limit"
|
||||
WebhookClientCert = "client_cert"
|
||||
WebhookClientKey = "client_key"
|
||||
|
||||
EnvWebhookEnable = "MINIO_NOTIFY_WEBHOOK_ENABLE"
|
||||
EnvWebhookEndpoint = "MINIO_NOTIFY_WEBHOOK_ENDPOINT"
|
||||
EnvWebhookAuthToken = "MINIO_NOTIFY_WEBHOOK_AUTH_TOKEN"
|
||||
EnvWebhookQueueDir = "MINIO_NOTIFY_WEBHOOK_QUEUE_DIR"
|
||||
EnvWebhookQueueLimit = "MINIO_NOTIFY_WEBHOOK_QUEUE_LIMIT"
|
||||
EnvWebhookClientCert = "MINIO_NOTIFY_WEBHOOK_CLIENT_CERT"
|
||||
EnvWebhookClientKey = "MINIO_NOTIFY_WEBHOOK_CLIENT_KEY"
|
||||
)
|
||||
|
||||
// WebhookArgs - Webhook target arguments.
|
||||
type WebhookArgs struct {
|
||||
Enable bool `json:"enable"`
|
||||
Endpoint xnet.URL `json:"endpoint"`
|
||||
AuthToken string `json:"authToken"`
|
||||
Transport *http.Transport `json:"-"`
|
||||
QueueDir string `json:"queueDir"`
|
||||
QueueLimit uint64 `json:"queueLimit"`
|
||||
ClientCert string `json:"clientCert"`
|
||||
ClientKey string `json:"clientKey"`
|
||||
}
|
||||
|
||||
// Validate WebhookArgs fields
|
||||
func (w WebhookArgs) Validate() error {
|
||||
if !w.Enable {
|
||||
return nil
|
||||
}
|
||||
if w.Endpoint.IsEmpty() {
|
||||
return errors.New("endpoint empty")
|
||||
}
|
||||
if w.QueueDir != "" {
|
||||
if !filepath.IsAbs(w.QueueDir) {
|
||||
return errors.New("queueDir path should be absolute")
|
||||
}
|
||||
}
|
||||
if w.ClientCert != "" && w.ClientKey == "" || w.ClientCert == "" && w.ClientKey != "" {
|
||||
return errors.New("cert and key must be specified as a pair")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WebhookTarget - Webhook target.
|
||||
type WebhookTarget struct {
|
||||
initOnce once.Init
|
||||
|
||||
id event.TargetID
|
||||
args WebhookArgs
|
||||
transport *http.Transport
|
||||
httpClient *http.Client
|
||||
store store.Store[event.Event]
|
||||
loggerOnce logger.LogOnce
|
||||
cancel context.CancelFunc
|
||||
cancelCh <-chan struct{}
|
||||
|
||||
addr string // full address ip/dns with a port number, e.g. x.x.x.x:8080
|
||||
}
|
||||
|
||||
// ID - returns target ID.
|
||||
func (target *WebhookTarget) ID() event.TargetID {
|
||||
return target.id
|
||||
}
|
||||
|
||||
// Name - returns the Name of the target.
|
||||
func (target *WebhookTarget) Name() string {
|
||||
return target.ID().String()
|
||||
}
|
||||
|
||||
// IsActive - Return true if target is up and active
|
||||
func (target *WebhookTarget) IsActive() (bool, error) {
|
||||
if err := target.init(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return target.isActive()
|
||||
}
|
||||
|
||||
// Store returns any underlying store if set.
|
||||
func (target *WebhookTarget) Store() event.TargetStore {
|
||||
return target.store
|
||||
}
|
||||
|
||||
func (target *WebhookTarget) isActive() (bool, error) {
|
||||
conn, err := net.DialTimeout("tcp", target.addr, 5*time.Second)
|
||||
if err != nil {
|
||||
if xnet.IsNetworkOrHostDown(err, false) {
|
||||
return false, store.ErrNotConnected
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
defer conn.Close()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Save - saves the events to the store if queuestore is configured,
|
||||
// which will be replayed when the webhook connection is active.
|
||||
func (target *WebhookTarget) Save(eventData event.Event) error {
|
||||
if target.store != nil {
|
||||
_, err := target.store.Put(eventData)
|
||||
return err
|
||||
}
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
err := target.send(eventData)
|
||||
if err != nil {
|
||||
if xnet.IsNetworkOrHostDown(err, false) {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// send - sends an event to the webhook.
|
||||
func (target *WebhookTarget) send(eventData event.Event) error {
|
||||
objectName, err := url.QueryUnescape(eventData.S3.Object.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := eventData.S3.Bucket.Name + "/" + objectName
|
||||
|
||||
data, err := json.Marshal(event.Log{EventName: eventData.EventName, Key: key, Records: []event.Event{eventData}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, target.args.Endpoint.String(), bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify if the authToken already contains
|
||||
// <Key> <Token> like format, if this is
|
||||
// already present we can blindly use the
|
||||
// authToken as is instead of adding 'Bearer'
|
||||
tokens := strings.Fields(target.args.AuthToken)
|
||||
switch len(tokens) {
|
||||
case 2:
|
||||
req.Header.Set("Authorization", target.args.AuthToken)
|
||||
case 1:
|
||||
req.Header.Set("Authorization", "Bearer "+target.args.AuthToken)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := target.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
xhttp.DrainBody(resp.Body)
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
|
||||
// accepted HTTP status codes.
|
||||
return nil
|
||||
} else if resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("%s returned '%s', please check if your auth token is correctly set", target.args.Endpoint, resp.Status)
|
||||
}
|
||||
return fmt.Errorf("%s returned '%s', please check your endpoint configuration", target.args.Endpoint, resp.Status)
|
||||
}
|
||||
|
||||
// SendFromStore - reads an event from store and sends it to webhook.
|
||||
func (target *WebhookTarget) SendFromStore(key store.Key) error {
|
||||
if err := target.init(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventData, eErr := target.store.Get(key)
|
||||
if eErr != nil {
|
||||
// The last event key in a successful batch will be sent in the channel atmost once by the replayEvents()
|
||||
// Such events will not exist and would've been already been sent successfully.
|
||||
if os.IsNotExist(eErr) {
|
||||
return nil
|
||||
}
|
||||
return eErr
|
||||
}
|
||||
|
||||
if err := target.send(eventData); err != nil {
|
||||
if xnet.IsNetworkOrHostDown(err, false) {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the event from store.
|
||||
return target.store.Del(key)
|
||||
}
|
||||
|
||||
// Close - does nothing and available for interface compatibility.
|
||||
func (target *WebhookTarget) Close() error {
|
||||
target.cancel()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *WebhookTarget) init() error {
|
||||
return target.initOnce.Do(target.initWebhook)
|
||||
}
|
||||
|
||||
// Only called from init()
|
||||
func (target *WebhookTarget) initWebhook() error {
|
||||
args := target.args
|
||||
transport := target.transport
|
||||
|
||||
if args.ClientCert != "" && args.ClientKey != "" {
|
||||
manager, err := certs.NewManager(context.Background(), args.ClientCert, args.ClientKey, tls.LoadX509KeyPair)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.ReloadOnSignal(syscall.SIGHUP) // allow reloads upon SIGHUP
|
||||
transport.TLSClientConfig.GetClientCertificate = manager.GetClientCertificate
|
||||
}
|
||||
target.httpClient = &http.Client{Transport: transport}
|
||||
|
||||
yes, err := target.isActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
return store.ErrNotConnected
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewWebhookTarget - creates new Webhook target.
|
||||
func NewWebhookTarget(ctx context.Context, id string, args WebhookArgs, loggerOnce logger.LogOnce, transport *http.Transport) (*WebhookTarget, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
var queueStore store.Store[event.Event]
|
||||
if args.QueueDir != "" {
|
||||
queueDir := filepath.Join(args.QueueDir, storePrefix+"-webhook-"+id)
|
||||
queueStore = store.NewQueueStore[event.Event](queueDir, args.QueueLimit, event.StoreExtension)
|
||||
if err := queueStore.Open(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("unable to initialize the queue store of Webhook `%s`: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
target := &WebhookTarget{
|
||||
id: event.TargetID{ID: id, Name: "webhook"},
|
||||
args: args,
|
||||
loggerOnce: loggerOnce,
|
||||
transport: transport,
|
||||
store: queueStore,
|
||||
cancel: cancel,
|
||||
cancelCh: ctx.Done(),
|
||||
}
|
||||
|
||||
// Calculate the webhook addr with the port number format
|
||||
target.addr = args.Endpoint.Host
|
||||
if _, _, err := net.SplitHostPort(args.Endpoint.Host); err != nil && strings.Contains(err.Error(), "missing port in address") {
|
||||
switch strings.ToLower(args.Endpoint.Scheme) {
|
||||
case "http":
|
||||
target.addr += ":80"
|
||||
case "https":
|
||||
target.addr += ":443"
|
||||
default:
|
||||
return nil, errors.New("unsupported scheme")
|
||||
}
|
||||
}
|
||||
|
||||
if target.store != nil {
|
||||
store.StreamItems(target.store, target, target.cancelCh, target.loggerOnce)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
74
internal/event/targetid.go
Normal file
74
internal/event/targetid.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TargetID - holds identification and name strings of notification target.
|
||||
type TargetID struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// String - returns string representation.
|
||||
func (tid TargetID) String() string {
|
||||
return tid.ID + ":" + tid.Name
|
||||
}
|
||||
|
||||
// ToARN - converts to ARN.
|
||||
func (tid TargetID) ToARN(region string) ARN {
|
||||
return ARN{TargetID: tid, region: region}
|
||||
}
|
||||
|
||||
// MarshalJSON - encodes to JSON data.
|
||||
func (tid TargetID) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(tid.String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON - decodes JSON data.
|
||||
func (tid *TargetID) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetID, err := parseTargetID(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*tid = *targetID
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTargetID - parses string to TargetID.
|
||||
func parseTargetID(s string) (*TargetID, error) {
|
||||
tokens := strings.Split(s, ":")
|
||||
if len(tokens) != 2 {
|
||||
return nil, fmt.Errorf("invalid TargetID format '%v'", s)
|
||||
}
|
||||
|
||||
return &TargetID{
|
||||
ID: tokens[0],
|
||||
Name: tokens[1],
|
||||
}, nil
|
||||
}
|
||||
118
internal/event/targetid_test.go
Normal file
118
internal/event/targetid_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTargetDString(t *testing.T) {
|
||||
testCases := []struct {
|
||||
tid TargetID
|
||||
expectedResult string
|
||||
}{
|
||||
{TargetID{}, ":"},
|
||||
{TargetID{"1", "webhook"}, "1:webhook"},
|
||||
{TargetID{"httpclient+2e33cdee-fbec-4bdd-917e-7d8e3c5a2531", "localhost:55638"}, "httpclient+2e33cdee-fbec-4bdd-917e-7d8e3c5a2531:localhost:55638"},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.tid.String()
|
||||
|
||||
if result != testCase.expectedResult {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetDToARN(t *testing.T) {
|
||||
tid := TargetID{"1", "webhook"}
|
||||
testCases := []struct {
|
||||
tid TargetID
|
||||
region string
|
||||
expectedARN ARN
|
||||
}{
|
||||
{tid, "", ARN{TargetID: tid, region: ""}},
|
||||
{tid, "us-east-1", ARN{TargetID: tid, region: "us-east-1"}},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
arn := testCase.tid.ToARN(testCase.region)
|
||||
|
||||
if arn != testCase.expectedARN {
|
||||
t.Fatalf("test %v: ARN: expected: %v, got: %v", i+1, testCase.expectedARN, arn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetDMarshalJSON(t *testing.T) {
|
||||
testCases := []struct {
|
||||
tid TargetID
|
||||
expectedData []byte
|
||||
expectErr bool
|
||||
}{
|
||||
{TargetID{}, []byte(`":"`), false},
|
||||
{TargetID{"1", "webhook"}, []byte(`"1:webhook"`), false},
|
||||
{TargetID{"httpclient+2e33cdee-fbec-4bdd-917e-7d8e3c5a2531", "localhost:55638"}, []byte(`"httpclient+2e33cdee-fbec-4bdd-917e-7d8e3c5a2531:localhost:55638"`), false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
data, err := testCase.tid.MarshalJSON()
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
|
||||
if !testCase.expectErr {
|
||||
if !reflect.DeepEqual(data, testCase.expectedData) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, string(testCase.expectedData), string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetDUnmarshalJSON(t *testing.T) {
|
||||
testCases := []struct {
|
||||
data []byte
|
||||
expectedTargetID *TargetID
|
||||
expectErr bool
|
||||
}{
|
||||
{[]byte(`""`), nil, true},
|
||||
{[]byte(`"httpclient+2e33cdee-fbec-4bdd-917e-7d8e3c5a2531:localhost:55638"`), nil, true},
|
||||
{[]byte(`":"`), &TargetID{}, false},
|
||||
{[]byte(`"1:webhook"`), &TargetID{"1", "webhook"}, false},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
targetID := &TargetID{}
|
||||
err := targetID.UnmarshalJSON(testCase.data)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
|
||||
if !testCase.expectErr {
|
||||
if *targetID != *testCase.expectedTargetID {
|
||||
t.Fatalf("test %v: TargetID: expected: %v, got: %v", i+1, testCase.expectedTargetID, targetID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
67
internal/event/targetidset.go
Normal file
67
internal/event/targetidset.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import "maps"
|
||||
|
||||
// TargetIDSet - Set representation of TargetIDs.
|
||||
type TargetIDSet map[TargetID]struct{}
|
||||
|
||||
// Clone - returns copy of this set.
|
||||
func (set TargetIDSet) Clone() TargetIDSet {
|
||||
setCopy := NewTargetIDSet()
|
||||
maps.Copy(setCopy, set)
|
||||
return setCopy
|
||||
}
|
||||
|
||||
// add - adds TargetID to the set.
|
||||
func (set TargetIDSet) add(targetID TargetID) {
|
||||
set[targetID] = struct{}{}
|
||||
}
|
||||
|
||||
// Union - returns union with given set as new set.
|
||||
func (set TargetIDSet) Union(sset TargetIDSet) TargetIDSet {
|
||||
nset := set.Clone()
|
||||
|
||||
for k := range sset {
|
||||
nset.add(k)
|
||||
}
|
||||
|
||||
return nset
|
||||
}
|
||||
|
||||
// Difference - returns difference with given set as new set.
|
||||
func (set TargetIDSet) Difference(sset TargetIDSet) TargetIDSet {
|
||||
nset := NewTargetIDSet()
|
||||
for k := range set {
|
||||
if _, ok := sset[k]; !ok {
|
||||
nset.add(k)
|
||||
}
|
||||
}
|
||||
|
||||
return nset
|
||||
}
|
||||
|
||||
// NewTargetIDSet - creates new TargetID set with given TargetIDs.
|
||||
func NewTargetIDSet(targetIDs ...TargetID) TargetIDSet {
|
||||
set := make(TargetIDSet)
|
||||
for _, targetID := range targetIDs {
|
||||
set.add(targetID)
|
||||
}
|
||||
return set
|
||||
}
|
||||
110
internal/event/targetidset_test.go
Normal file
110
internal/event/targetidset_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTargetIDSetClone(t *testing.T) {
|
||||
testCases := []struct {
|
||||
set TargetIDSet
|
||||
targetIDToAdd TargetID
|
||||
}{
|
||||
{NewTargetIDSet(), TargetID{"1", "webhook"}},
|
||||
{NewTargetIDSet(TargetID{"1", "webhook"}), TargetID{"2", "webhook"}},
|
||||
{NewTargetIDSet(TargetID{"1", "webhook"}, TargetID{"2", "amqp"}), TargetID{"2", "webhook"}},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.set.Clone()
|
||||
|
||||
if !reflect.DeepEqual(result, testCase.set) {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.set, result)
|
||||
}
|
||||
|
||||
result.add(testCase.targetIDToAdd)
|
||||
if reflect.DeepEqual(result, testCase.set) {
|
||||
t.Fatalf("test %v: result: expected: not equal, got: equal", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetIDSetUnion(t *testing.T) {
|
||||
testCases := []struct {
|
||||
set TargetIDSet
|
||||
setToAdd TargetIDSet
|
||||
expectedResult TargetIDSet
|
||||
}{
|
||||
{NewTargetIDSet(), NewTargetIDSet(), NewTargetIDSet()},
|
||||
{NewTargetIDSet(), NewTargetIDSet(TargetID{"1", "webhook"}), NewTargetIDSet(TargetID{"1", "webhook"})},
|
||||
{NewTargetIDSet(TargetID{"1", "webhook"}), NewTargetIDSet(), NewTargetIDSet(TargetID{"1", "webhook"})},
|
||||
{NewTargetIDSet(TargetID{"1", "webhook"}), NewTargetIDSet(TargetID{"2", "amqp"}), NewTargetIDSet(TargetID{"1", "webhook"}, TargetID{"2", "amqp"})},
|
||||
{NewTargetIDSet(TargetID{"1", "webhook"}), NewTargetIDSet(TargetID{"1", "webhook"}), NewTargetIDSet(TargetID{"1", "webhook"})},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.set.Union(testCase.setToAdd)
|
||||
|
||||
if !reflect.DeepEqual(testCase.expectedResult, result) {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetIDSetDifference(t *testing.T) {
|
||||
testCases := []struct {
|
||||
set TargetIDSet
|
||||
setToRemove TargetIDSet
|
||||
expectedResult TargetIDSet
|
||||
}{
|
||||
{NewTargetIDSet(), NewTargetIDSet(), NewTargetIDSet()},
|
||||
{NewTargetIDSet(), NewTargetIDSet(TargetID{"1", "webhook"}), NewTargetIDSet()},
|
||||
{NewTargetIDSet(TargetID{"1", "webhook"}), NewTargetIDSet(), NewTargetIDSet(TargetID{"1", "webhook"})},
|
||||
{NewTargetIDSet(TargetID{"1", "webhook"}), NewTargetIDSet(TargetID{"2", "amqp"}), NewTargetIDSet(TargetID{"1", "webhook"})},
|
||||
{NewTargetIDSet(TargetID{"1", "webhook"}), NewTargetIDSet(TargetID{"1", "webhook"}), NewTargetIDSet()},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.set.Difference(testCase.setToRemove)
|
||||
|
||||
if !reflect.DeepEqual(testCase.expectedResult, result) {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTargetIDSet(t *testing.T) {
|
||||
testCases := []struct {
|
||||
targetIDs []TargetID
|
||||
expectedResult TargetIDSet
|
||||
}{
|
||||
{[]TargetID{}, NewTargetIDSet()},
|
||||
{[]TargetID{{"1", "webhook"}}, NewTargetIDSet(TargetID{"1", "webhook"})},
|
||||
{[]TargetID{{"1", "webhook"}, {"2", "amqp"}}, NewTargetIDSet(TargetID{"1", "webhook"}, TargetID{"2", "amqp"})},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := NewTargetIDSet(testCase.targetIDs...)
|
||||
|
||||
if !reflect.DeepEqual(testCase.expectedResult, result) {
|
||||
t.Fatalf("test %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
396
internal/event/targetlist.go
Normal file
396
internal/event/targetlist.go
Normal file
@@ -0,0 +1,396 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/minio/internal/store"
|
||||
"github.com/minio/pkg/v3/workers"
|
||||
)
|
||||
|
||||
const (
|
||||
logSubsys = "notify"
|
||||
|
||||
// The maximum allowed number of concurrent Send() calls to all configured notifications targets
|
||||
maxConcurrentAsyncSend = 50000
|
||||
)
|
||||
|
||||
// Target - event target interface
|
||||
type Target interface {
|
||||
ID() TargetID
|
||||
IsActive() (bool, error)
|
||||
Save(Event) error
|
||||
SendFromStore(store.Key) error
|
||||
Close() error
|
||||
Store() TargetStore
|
||||
}
|
||||
|
||||
// TargetStore is a shallow version of a target.Store
|
||||
type TargetStore interface {
|
||||
Len() int
|
||||
}
|
||||
|
||||
// Stats is a collection of stats for multiple targets.
|
||||
type Stats struct {
|
||||
TotalEvents int64 // Deprecated
|
||||
EventsSkipped int64
|
||||
CurrentQueuedCalls int64 // Deprecated
|
||||
EventsErrorsTotal int64 // Deprecated
|
||||
CurrentSendCalls int64 // Deprecated
|
||||
|
||||
TargetStats map[TargetID]TargetStat
|
||||
}
|
||||
|
||||
// TargetStat is the stats of a single target.
|
||||
type TargetStat struct {
|
||||
CurrentSendCalls int64 // CurrentSendCalls is the number of concurrent async Send calls to all targets
|
||||
CurrentQueue int // Populated if target has a store.
|
||||
TotalEvents int64
|
||||
FailedEvents int64 // Number of failed events per target
|
||||
}
|
||||
|
||||
// TargetList - holds list of targets indexed by target ID.
|
||||
type TargetList struct {
|
||||
// The number of concurrent async Send calls to all targets
|
||||
currentSendCalls atomic.Int64
|
||||
totalEvents atomic.Int64
|
||||
eventsSkipped atomic.Int64
|
||||
eventsErrorsTotal atomic.Int64
|
||||
|
||||
sync.RWMutex
|
||||
targets map[TargetID]Target
|
||||
queue chan asyncEvent
|
||||
ctx context.Context
|
||||
|
||||
statLock sync.RWMutex
|
||||
targetStats map[TargetID]targetStat
|
||||
}
|
||||
|
||||
type targetStat struct {
|
||||
// The number of concurrent async Send calls per targets
|
||||
currentSendCalls int64
|
||||
// The number of total events per target
|
||||
totalEvents int64
|
||||
// The number of failed events per target
|
||||
failedEvents int64
|
||||
}
|
||||
|
||||
func (list *TargetList) getStatsByTargetID(id TargetID) (stat targetStat) {
|
||||
list.statLock.RLock()
|
||||
defer list.statLock.RUnlock()
|
||||
|
||||
return list.targetStats[id]
|
||||
}
|
||||
|
||||
func (list *TargetList) incCurrentSendCalls(id TargetID) {
|
||||
list.statLock.Lock()
|
||||
defer list.statLock.Unlock()
|
||||
|
||||
stats, ok := list.targetStats[id]
|
||||
if !ok {
|
||||
stats = targetStat{}
|
||||
}
|
||||
|
||||
stats.currentSendCalls++
|
||||
list.targetStats[id] = stats
|
||||
}
|
||||
|
||||
func (list *TargetList) decCurrentSendCalls(id TargetID) {
|
||||
list.statLock.Lock()
|
||||
defer list.statLock.Unlock()
|
||||
|
||||
stats, ok := list.targetStats[id]
|
||||
if !ok {
|
||||
// should not happen
|
||||
return
|
||||
}
|
||||
|
||||
stats.currentSendCalls--
|
||||
list.targetStats[id] = stats
|
||||
}
|
||||
|
||||
func (list *TargetList) incFailedEvents(id TargetID) {
|
||||
list.statLock.Lock()
|
||||
defer list.statLock.Unlock()
|
||||
|
||||
stats, ok := list.targetStats[id]
|
||||
if !ok {
|
||||
stats = targetStat{}
|
||||
}
|
||||
|
||||
stats.failedEvents++
|
||||
list.targetStats[id] = stats
|
||||
}
|
||||
|
||||
func (list *TargetList) incTotalEvents(id TargetID) {
|
||||
list.statLock.Lock()
|
||||
defer list.statLock.Unlock()
|
||||
|
||||
stats, ok := list.targetStats[id]
|
||||
if !ok {
|
||||
stats = targetStat{}
|
||||
}
|
||||
|
||||
stats.totalEvents++
|
||||
list.targetStats[id] = stats
|
||||
}
|
||||
|
||||
type asyncEvent struct {
|
||||
ev Event
|
||||
targetSet TargetIDSet
|
||||
}
|
||||
|
||||
// Add - adds unique target to target list.
|
||||
func (list *TargetList) Add(targets ...Target) error {
|
||||
list.Lock()
|
||||
defer list.Unlock()
|
||||
|
||||
for _, target := range targets {
|
||||
if _, ok := list.targets[target.ID()]; ok {
|
||||
return fmt.Errorf("target %v already exists", target.ID())
|
||||
}
|
||||
list.targets[target.ID()] = target
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists - checks whether target by target ID exists or not.
|
||||
func (list *TargetList) Exists(id TargetID) bool {
|
||||
list.RLock()
|
||||
defer list.RUnlock()
|
||||
|
||||
_, found := list.targets[id]
|
||||
return found
|
||||
}
|
||||
|
||||
// TargetIDResult returns result of Remove/Send operation, sets err if
|
||||
// any for the associated TargetID
|
||||
type TargetIDResult struct {
|
||||
// ID where the remove or send were initiated.
|
||||
ID TargetID
|
||||
// Stores any error while removing a target or while sending an event.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Remove - closes and removes targets by given target IDs.
|
||||
func (list *TargetList) Remove(targetIDSet TargetIDSet) {
|
||||
list.Lock()
|
||||
defer list.Unlock()
|
||||
|
||||
for id := range targetIDSet {
|
||||
target, ok := list.targets[id]
|
||||
if ok {
|
||||
target.Close()
|
||||
delete(list.targets, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Targets - list all targets
|
||||
func (list *TargetList) Targets() []Target {
|
||||
if list == nil {
|
||||
return []Target{}
|
||||
}
|
||||
|
||||
list.RLock()
|
||||
defer list.RUnlock()
|
||||
|
||||
targets := []Target{}
|
||||
for _, tgt := range list.targets {
|
||||
targets = append(targets, tgt)
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
// List - returns available target IDs.
|
||||
func (list *TargetList) List() []TargetID {
|
||||
list.RLock()
|
||||
defer list.RUnlock()
|
||||
|
||||
keys := []TargetID{}
|
||||
for k := range list.targets {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func (list *TargetList) get(id TargetID) (Target, bool) {
|
||||
list.RLock()
|
||||
defer list.RUnlock()
|
||||
|
||||
target, ok := list.targets[id]
|
||||
return target, ok
|
||||
}
|
||||
|
||||
// TargetMap - returns available targets.
|
||||
func (list *TargetList) TargetMap() map[TargetID]Target {
|
||||
list.RLock()
|
||||
defer list.RUnlock()
|
||||
|
||||
ntargets := make(map[TargetID]Target, len(list.targets))
|
||||
maps.Copy(ntargets, list.targets)
|
||||
return ntargets
|
||||
}
|
||||
|
||||
// Send - sends events to targets identified by target IDs.
|
||||
func (list *TargetList) Send(event Event, targetIDset TargetIDSet, sync bool) {
|
||||
if sync {
|
||||
list.sendSync(event, targetIDset)
|
||||
} else {
|
||||
list.sendAsync(event, targetIDset)
|
||||
}
|
||||
}
|
||||
|
||||
func (list *TargetList) sendSync(event Event, targetIDset TargetIDSet) {
|
||||
var wg sync.WaitGroup
|
||||
for id := range targetIDset {
|
||||
target, ok := list.get(id)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(id TargetID, target Target) {
|
||||
list.currentSendCalls.Add(1)
|
||||
list.incCurrentSendCalls(id)
|
||||
list.incTotalEvents(id)
|
||||
defer list.decCurrentSendCalls(id)
|
||||
defer list.currentSendCalls.Add(-1)
|
||||
defer wg.Done()
|
||||
|
||||
if err := target.Save(event); err != nil {
|
||||
list.eventsErrorsTotal.Add(1)
|
||||
list.incFailedEvents(id)
|
||||
reqInfo := &logger.ReqInfo{}
|
||||
reqInfo.AppendTags("targetID", id.String())
|
||||
logger.LogOnceIf(logger.SetReqInfo(context.Background(), reqInfo), logSubsys, err, id.String())
|
||||
}
|
||||
}(id, target)
|
||||
}
|
||||
wg.Wait()
|
||||
list.totalEvents.Add(1)
|
||||
}
|
||||
|
||||
func (list *TargetList) sendAsync(event Event, targetIDset TargetIDSet) {
|
||||
select {
|
||||
case list.queue <- asyncEvent{
|
||||
ev: event,
|
||||
targetSet: targetIDset.Clone(),
|
||||
}:
|
||||
case <-list.ctx.Done():
|
||||
list.eventsSkipped.Add(int64(len(list.queue)))
|
||||
return
|
||||
default:
|
||||
list.eventsSkipped.Add(1)
|
||||
err := fmt.Errorf("concurrent target notifications exceeded %d, configured notification target is too slow to accept events for the incoming request rate", maxConcurrentAsyncSend)
|
||||
for id := range targetIDset {
|
||||
reqInfo := &logger.ReqInfo{}
|
||||
reqInfo.AppendTags("targetID", id.String())
|
||||
logger.LogOnceIf(logger.SetReqInfo(context.Background(), reqInfo), logSubsys, err, id.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Stats returns stats for targets.
|
||||
func (list *TargetList) Stats() Stats {
|
||||
t := Stats{}
|
||||
if list == nil {
|
||||
return t
|
||||
}
|
||||
t.CurrentSendCalls = list.currentSendCalls.Load()
|
||||
t.EventsSkipped = list.eventsSkipped.Load()
|
||||
t.TotalEvents = list.totalEvents.Load()
|
||||
t.CurrentQueuedCalls = int64(len(list.queue))
|
||||
t.EventsErrorsTotal = list.eventsErrorsTotal.Load()
|
||||
|
||||
list.RLock()
|
||||
defer list.RUnlock()
|
||||
t.TargetStats = make(map[TargetID]TargetStat, len(list.targets))
|
||||
for id, target := range list.targets {
|
||||
var currentQueue int
|
||||
if st := target.Store(); st != nil {
|
||||
currentQueue = st.Len()
|
||||
}
|
||||
stats := list.getStatsByTargetID(id)
|
||||
t.TargetStats[id] = TargetStat{
|
||||
CurrentSendCalls: stats.currentSendCalls,
|
||||
CurrentQueue: currentQueue,
|
||||
FailedEvents: stats.failedEvents,
|
||||
TotalEvents: stats.totalEvents,
|
||||
}
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func (list *TargetList) startSendWorkers(workerCount int) {
|
||||
if workerCount == 0 {
|
||||
workerCount = runtime.GOMAXPROCS(0)
|
||||
}
|
||||
wk, err := workers.New(workerCount)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for range workerCount {
|
||||
wk.Take()
|
||||
go func() {
|
||||
defer wk.Give()
|
||||
|
||||
for {
|
||||
select {
|
||||
case av := <-list.queue:
|
||||
list.sendSync(av.ev, av.targetSet)
|
||||
case <-list.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wk.Wait()
|
||||
}
|
||||
|
||||
var startOnce sync.Once
|
||||
|
||||
// Init initialize target send workers.
|
||||
func (list *TargetList) Init(workers int) *TargetList {
|
||||
startOnce.Do(func() {
|
||||
go list.startSendWorkers(workers)
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
// NewTargetList - creates TargetList.
|
||||
func NewTargetList(ctx context.Context) *TargetList {
|
||||
list := &TargetList{
|
||||
targets: make(map[TargetID]Target),
|
||||
queue: make(chan asyncEvent, maxConcurrentAsyncSend),
|
||||
targetStats: make(map[TargetID]targetStat),
|
||||
ctx: ctx,
|
||||
}
|
||||
return list
|
||||
}
|
||||
225
internal/event/targetlist_test.go
Normal file
225
internal/event/targetlist_test.go
Normal file
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) 2015-2021 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/store"
|
||||
)
|
||||
|
||||
type ExampleTarget struct {
|
||||
id TargetID
|
||||
sendErr bool
|
||||
closeErr bool
|
||||
}
|
||||
|
||||
func (target ExampleTarget) ID() TargetID {
|
||||
return target.id
|
||||
}
|
||||
|
||||
// Save - Sends event directly without persisting.
|
||||
func (target ExampleTarget) Save(eventData Event) error {
|
||||
return target.send(eventData)
|
||||
}
|
||||
|
||||
// Store - Returns a nil store.
|
||||
func (target ExampleTarget) Store() TargetStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target ExampleTarget) send(eventData Event) error {
|
||||
b := make([]byte, 1)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(b[0]) * time.Millisecond)
|
||||
|
||||
if target.sendErr {
|
||||
return errors.New("send error")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendFromStore - interface compatible method does no-op.
|
||||
func (target *ExampleTarget) SendFromStore(_ store.Key) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target ExampleTarget) Close() error {
|
||||
if target.closeErr {
|
||||
return errors.New("close error")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target ExampleTarget) IsActive() (bool, error) {
|
||||
return false, errors.New("not connected to target server/service")
|
||||
}
|
||||
|
||||
// FlushQueueStore - No-Op. Added for interface compatibility
|
||||
func (target ExampleTarget) FlushQueueStore() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTargetListAdd(t *testing.T) {
|
||||
targetListCase1 := NewTargetList(t.Context())
|
||||
|
||||
targetListCase2 := NewTargetList(t.Context())
|
||||
if err := targetListCase2.Add(&ExampleTarget{TargetID{"2", "testcase"}, false, false}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
targetListCase3 := NewTargetList(t.Context())
|
||||
if err := targetListCase3.Add(&ExampleTarget{TargetID{"3", "testcase"}, false, false}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
targetList *TargetList
|
||||
target Target
|
||||
expectedResult []TargetID
|
||||
expectErr bool
|
||||
}{
|
||||
{targetListCase1, &ExampleTarget{TargetID{"1", "webhook"}, false, false}, []TargetID{{"1", "webhook"}}, false},
|
||||
{targetListCase2, &ExampleTarget{TargetID{"1", "webhook"}, false, false}, []TargetID{{"2", "testcase"}, {"1", "webhook"}}, false},
|
||||
{targetListCase3, &ExampleTarget{TargetID{"3", "testcase"}, false, false}, nil, true},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
err := testCase.targetList.Add(testCase.target)
|
||||
expectErr := (err != nil)
|
||||
|
||||
if expectErr != testCase.expectErr {
|
||||
t.Fatalf("test %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr)
|
||||
}
|
||||
|
||||
if !testCase.expectErr {
|
||||
result := testCase.targetList.List()
|
||||
|
||||
if len(result) != len(testCase.expectedResult) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
|
||||
for _, targetID1 := range result {
|
||||
var found bool
|
||||
for _, targetID2 := range testCase.expectedResult {
|
||||
if reflect.DeepEqual(targetID1, targetID2) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetListExists(t *testing.T) {
|
||||
targetListCase1 := NewTargetList(t.Context())
|
||||
|
||||
targetListCase2 := NewTargetList(t.Context())
|
||||
if err := targetListCase2.Add(&ExampleTarget{TargetID{"2", "testcase"}, false, false}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
targetListCase3 := NewTargetList(t.Context())
|
||||
if err := targetListCase3.Add(&ExampleTarget{TargetID{"3", "testcase"}, false, false}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
targetList *TargetList
|
||||
targetID TargetID
|
||||
expectedResult bool
|
||||
}{
|
||||
{targetListCase1, TargetID{"1", "webhook"}, false},
|
||||
{targetListCase2, TargetID{"1", "webhook"}, false},
|
||||
{targetListCase3, TargetID{"3", "testcase"}, true},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.targetList.Exists(testCase.targetID)
|
||||
|
||||
if result != testCase.expectedResult {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetListList(t *testing.T) {
|
||||
targetListCase1 := NewTargetList(t.Context())
|
||||
|
||||
targetListCase2 := NewTargetList(t.Context())
|
||||
if err := targetListCase2.Add(&ExampleTarget{TargetID{"2", "testcase"}, false, false}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
targetListCase3 := NewTargetList(t.Context())
|
||||
if err := targetListCase3.Add(&ExampleTarget{TargetID{"3", "testcase"}, false, false}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := targetListCase3.Add(&ExampleTarget{TargetID{"1", "webhook"}, false, false}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
targetList *TargetList
|
||||
expectedResult []TargetID
|
||||
}{
|
||||
{targetListCase1, []TargetID{}},
|
||||
{targetListCase2, []TargetID{{"2", "testcase"}}},
|
||||
{targetListCase3, []TargetID{{"3", "testcase"}, {"1", "webhook"}}},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
result := testCase.targetList.List()
|
||||
|
||||
if len(result) != len(testCase.expectedResult) {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
|
||||
for _, targetID1 := range result {
|
||||
var found bool
|
||||
for _, targetID2 := range testCase.expectedResult {
|
||||
if reflect.DeepEqual(targetID1, targetID2) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("test %v: data: expected: %v, got: %v", i+1, testCase.expectedResult, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTargetList(t *testing.T) {
|
||||
if result := NewTargetList(t.Context()); result == nil {
|
||||
t.Fatalf("test: result: expected: <non-nil>, got: <nil>")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user