diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1593b809..e5bd92de 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:12 + image: postgres:13 ports: - 5432:5432 env: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c1e11d6b..82cd9c79 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:12 + image: postgres:13 ports: - 5432:5432 env: diff --git a/cli/deps.go b/cli/deps.go index db1bcc95..ce00c3c3 100644 --- a/cli/deps.go +++ b/cli/deps.go @@ -6,14 +6,16 @@ import ( "github.com/newrelic/go-agent/v3/newrelic" "github.com/odpf/salt/db" - "github.com/odpf/salt/log" + saltlog "github.com/odpf/salt/log" "github.com/odpf/siren/config" "github.com/odpf/siren/core/alert" + "github.com/odpf/siren/core/log" "github.com/odpf/siren/core/namespace" "github.com/odpf/siren/core/notification" "github.com/odpf/siren/core/provider" "github.com/odpf/siren/core/receiver" "github.com/odpf/siren/core/rule" + "github.com/odpf/siren/core/silence" "github.com/odpf/siren/core/subscription" "github.com/odpf/siren/core/template" "github.com/odpf/siren/internal/api" @@ -30,18 +32,19 @@ import ( func InitDeps( ctx context.Context, - logger log.Logger, + logger saltlog.Logger, cfg config.Config, queue notification.Queuer, ) (*api.Deps, *newrelic.Application, *pgc.Client, map[string]notification.Notifier, error) { telemetry.Init(ctx, cfg.Telemetry, logger) + nrApp, err := newrelic.NewApplication( - newrelic.ConfigAppName(cfg.Telemetry.ServiceName), + newrelic.ConfigAppName(cfg.Telemetry.NewRelicAppName), newrelic.ConfigLicense(cfg.Telemetry.NewRelicAPIKey), ) if err != nil { - return nil, nil, nil, nil, err + logger.Warn("failed to init newrelic", "err", err) } dbClient, err := db.New(cfg.DB) @@ -59,22 +62,26 @@ func InitDeps( return nil, nil, nil, nil, fmt.Errorf("cannot initialize encryptor: %w", err) } - idempotencyRepository := postgres.NewIdempotencyRepository(pgClient) templateRepository := postgres.NewTemplateRepository(pgClient) templateService := template.NewService(templateRepository) - alertRepository := postgres.NewAlertRepository(pgClient) - providerRepository := postgres.NewProviderRepository(pgClient) providerService := provider.NewService(providerRepository) - namespaceRepository := postgres.NewNamespaceRepository(pgClient) + logRepository := postgres.NewLogRepository(pgClient) + logService := log.NewService(logRepository) cortexPluginService := cortex.NewPluginService(logger, cfg.Providers.Cortex) + alertRepository := postgres.NewAlertRepository(pgClient) + alertService := alert.NewService( + alertRepository, + logService, + map[string]alert.AlertTransformer{ + provider.TypeCortex: cortexPluginService, + }, + ) - alertHistoryService := alert.NewService(alertRepository, map[string]alert.AlertTransformer{ - provider.TypeCortex: cortexPluginService, - }) + namespaceRepository := postgres.NewNamespaceRepository(pgClient) namespaceService := namespace.NewService(encryptor, namespaceRepository, providerService, map[string]namespace.ConfigSyncer{ provider.TypeCortex: cortexPluginService, }) @@ -89,6 +96,9 @@ func InitDeps( }, ) + silenceRepository := postgres.NewSilenceRepository(pgClient) + silenceService := silence.NewService(silenceRepository) + // plugin receiver services slackPluginService := slack.NewPluginService(cfg.Receivers.Slack, encryptor) pagerDutyPluginService := pagerduty.NewPluginService(cfg.Receivers.Pagerduty) @@ -109,6 +119,7 @@ func InitDeps( subscriptionRepository := postgres.NewSubscriptionRepository(pgClient) subscriptionService := subscription.NewService( subscriptionRepository, + logService, namespaceService, receiverService, ) @@ -121,17 +132,33 @@ func InitDeps( receiver.TypeFile: filePluginService, } - notificationService := notification.NewService(logger, queue, idempotencyRepository, receiverService, subscriptionService, notifierRegistry) + idempotencyRepository := postgres.NewIdempotencyRepository(pgClient) + notificationRepository := postgres.NewNotificationRepository(pgClient) + notificationService := notification.NewService( + logger, + notificationRepository, + queue, + notifierRegistry, + notification.Deps{ + LogService: logService, + IdempotencyRepository: idempotencyRepository, + ReceiverService: receiverService, + SubscriptionService: subscriptionService, + SilenceService: silenceService, + AlertService: alertService, + }, + ) return &api.Deps{ TemplateService: templateService, RuleService: ruleService, - AlertService: alertHistoryService, + AlertService: alertService, ProviderService: providerService, NamespaceService: namespaceService, ReceiverService: receiverService, SubscriptionService: subscriptionService, NotificationService: notificationService, + SilenceService: silenceService, }, nrApp, pgClient, notifierRegistry, nil } diff --git a/config/init.go b/config/init.go index a2c0b330..949acdd7 100644 --- a/config/init.go +++ b/config/init.go @@ -5,7 +5,7 @@ import ( "github.com/mcuadros/go-defaults" "github.com/odpf/siren/core/receiver" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) func Init(configFile string) error { diff --git a/core/alert/alert.go b/core/alert/alert.go index 38506994..b3ae43d8 100644 --- a/core/alert/alert.go +++ b/core/alert/alert.go @@ -9,20 +9,22 @@ import ( type Repository interface { Create(context.Context, Alert) (Alert, error) List(context.Context, Filter) ([]Alert, error) + BulkUpdateSilence(context.Context, []int64, string) error } type Alert struct { - ID uint64 `json:"id"` - ProviderID uint64 `json:"provider_id"` - NamespaceID uint64 `json:"namespace_id"` - ResourceName string `json:"resource_name"` - MetricName string `json:"metric_name"` - MetricValue string `json:"metric_value"` - Severity string `json:"severity"` - Rule string `json:"rule"` - TriggeredAt time.Time `json:"triggered_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + ProviderID uint64 `json:"provider_id"` + NamespaceID uint64 `json:"namespace_id"` + ResourceName string `json:"resource_name"` + MetricName string `json:"metric_name"` + MetricValue string `json:"metric_value"` + Severity string `json:"severity"` + Rule string `json:"rule"` + TriggeredAt time.Time `json:"triggered_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SilenceStatus string `json:"silence_status"` // These fields won't be stored in the DB // these are additional information for notification purposes diff --git a/core/alert/filter.go b/core/alert/filter.go index 7892e7b8..1bfa92a3 100644 --- a/core/alert/filter.go +++ b/core/alert/filter.go @@ -6,4 +6,6 @@ type Filter struct { NamespaceID uint64 StartTime int64 EndTime int64 + SilenceID string + IDs []int64 } diff --git a/core/alert/mocks/alert_repository.go b/core/alert/mocks/alert_repository.go index 9208af0e..313bd0c6 100644 --- a/core/alert/mocks/alert_repository.go +++ b/core/alert/mocks/alert_repository.go @@ -23,6 +23,45 @@ func (_m *AlertRepository) EXPECT() *AlertRepository_Expecter { return &AlertRepository_Expecter{mock: &_m.Mock} } +// BulkUpdateSilence provides a mock function with given fields: _a0, _a1, _a2 +func (_m *AlertRepository) BulkUpdateSilence(_a0 context.Context, _a1 []int64, _a2 string) error { + ret := _m.Called(_a0, _a1, _a2) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, []int64, string) error); ok { + r0 = rf(_a0, _a1, _a2) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// AlertRepository_BulkUpdateSilence_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BulkUpdateSilence' +type AlertRepository_BulkUpdateSilence_Call struct { + *mock.Call +} + +// BulkUpdateSilence is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 []int64 +// - _a2 string +func (_e *AlertRepository_Expecter) BulkUpdateSilence(_a0 interface{}, _a1 interface{}, _a2 interface{}) *AlertRepository_BulkUpdateSilence_Call { + return &AlertRepository_BulkUpdateSilence_Call{Call: _e.mock.On("BulkUpdateSilence", _a0, _a1, _a2)} +} + +func (_c *AlertRepository_BulkUpdateSilence_Call) Run(run func(_a0 context.Context, _a1 []int64, _a2 string)) *AlertRepository_BulkUpdateSilence_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]int64), args[2].(string)) + }) + return _c +} + +func (_c *AlertRepository_BulkUpdateSilence_Call) Return(_a0 error) *AlertRepository_BulkUpdateSilence_Call { + _c.Call.Return(_a0) + return _c +} + // Create provides a mock function with given fields: _a0, _a1 func (_m *AlertRepository) Create(_a0 context.Context, _a1 alert.Alert) (alert.Alert, error) { ret := _m.Called(_a0, _a1) diff --git a/core/alert/mocks/log_service.go b/core/alert/mocks/log_service.go new file mode 100644 index 00000000..1a059738 --- /dev/null +++ b/core/alert/mocks/log_service.go @@ -0,0 +1,84 @@ +// Code generated by mockery v2.16.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// LogService is an autogenerated mock type for the LogService type +type LogService struct { + mock.Mock +} + +type LogService_Expecter struct { + mock *mock.Mock +} + +func (_m *LogService) EXPECT() *LogService_Expecter { + return &LogService_Expecter{mock: &_m.Mock} +} + +// ListNotificationAlertIDsBySilenceID provides a mock function with given fields: ctx, silenceID +func (_m *LogService) ListNotificationAlertIDsBySilenceID(ctx context.Context, silenceID string) ([]int64, error) { + ret := _m.Called(ctx, silenceID) + + var r0 []int64 + if rf, ok := ret.Get(0).(func(context.Context, string) []int64); ok { + r0 = rf(ctx, silenceID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int64) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, silenceID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// LogService_ListNotificationAlertIDsBySilenceID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNotificationAlertIDsBySilenceID' +type LogService_ListNotificationAlertIDsBySilenceID_Call struct { + *mock.Call +} + +// ListNotificationAlertIDsBySilenceID is a helper method to define mock.On call +// - ctx context.Context +// - silenceID string +func (_e *LogService_Expecter) ListNotificationAlertIDsBySilenceID(ctx interface{}, silenceID interface{}) *LogService_ListNotificationAlertIDsBySilenceID_Call { + return &LogService_ListNotificationAlertIDsBySilenceID_Call{Call: _e.mock.On("ListNotificationAlertIDsBySilenceID", ctx, silenceID)} +} + +func (_c *LogService_ListNotificationAlertIDsBySilenceID_Call) Run(run func(ctx context.Context, silenceID string)) *LogService_ListNotificationAlertIDsBySilenceID_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *LogService_ListNotificationAlertIDsBySilenceID_Call) Return(_a0 []int64, _a1 error) *LogService_ListNotificationAlertIDsBySilenceID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +type mockConstructorTestingTNewLogService interface { + mock.TestingT + Cleanup(func()) +} + +// NewLogService creates a new instance of LogService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewLogService(t mockConstructorTestingTNewLogService) *LogService { + mock := &LogService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/alert/service.go b/core/alert/service.go index 012922ba..ae259a0d 100644 --- a/core/alert/service.go +++ b/core/alert/service.go @@ -7,15 +7,21 @@ import ( "github.com/odpf/siren/pkg/errors" ) +//go:generate mockery --name=LogService -r --case underscore --with-expecter --structname LogService --filename log_service.go --output=./mocks +type LogService interface { + ListAlertIDsBySilenceID(ctx context.Context, silenceID string) ([]int64, error) +} + // Service handles business logic type Service struct { repository Repository + logService LogService registry map[string]AlertTransformer } // NewService returns repository struct -func NewService(repository Repository, registry map[string]AlertTransformer) *Service { - return &Service{repository, registry} +func NewService(repository Repository, logService LogService, registry map[string]AlertTransformer) *Service { + return &Service{repository, logService, registry} } func (s *Service) CreateAlerts(ctx context.Context, providerType string, providerID uint64, namespaceID uint64, body map[string]interface{}) ([]Alert, int, error) { @@ -29,15 +35,15 @@ func (s *Service) CreateAlerts(ctx context.Context, providerType string, provide return nil, 0, err } - for _, alrt := range alerts { - createdAlert, err := s.repository.Create(ctx, alrt) + for i := 0; i < len(alerts); i++ { + createdAlert, err := s.repository.Create(ctx, alerts[i]) if err != nil { if errors.Is(err, ErrRelation) { return nil, 0, errors.ErrNotFound.WithMsgf(err.Error()) } return nil, 0, err } - alrt.ID = createdAlert.ID + alerts[i].ID = createdAlert.ID } return alerts, firingLen, nil @@ -48,9 +54,21 @@ func (s *Service) List(ctx context.Context, flt Filter) ([]Alert, error) { flt.EndTime = time.Now().Unix() } + if flt.SilenceID != "" { + alertIDs, err := s.logService.ListAlertIDsBySilenceID(ctx, flt.SilenceID) + if err != nil { + return nil, err + } + flt.IDs = alertIDs + } + return s.repository.List(ctx, flt) } +func (s *Service) UpdateSilenceStatus(ctx context.Context, alertIDs []int64, hasSilenced bool, hasNonSilenced bool) error { + return s.repository.BulkUpdateSilence(ctx, alertIDs, silenceStatus(hasSilenced, hasNonSilenced)) +} + func (s *Service) getProviderPluginService(providerType string) (AlertTransformer, error) { pluginService, exist := s.registry[providerType] if !exist { diff --git a/core/alert/service_test.go b/core/alert/service_test.go index 176505d1..88785020 100644 --- a/core/alert/service_test.go +++ b/core/alert/service_test.go @@ -13,12 +13,12 @@ import ( "github.com/stretchr/testify/mock" ) -func TestService_Get(t *testing.T) { +func TestService_List(t *testing.T) { ctx := context.TODO() t.Run("should call repository List method with proper arguments and return result in domain's type", func(t *testing.T) { repositoryMock := &mocks.AlertRepository{} - dummyService := alert.NewService(repositoryMock, nil) + dummyService := alert.NewService(repositoryMock, nil, nil) timenow := time.Now() dummyAlerts := []alert.Alert{ {ID: 1, ProviderID: 1, ResourceName: "foo", Severity: "CRITICAL", MetricName: "baz", MetricValue: "20", @@ -45,7 +45,7 @@ func TestService_Get(t *testing.T) { t.Run("should call repository List method with proper arguments if endtime is zero", func(t *testing.T) { repositoryMock := &mocks.AlertRepository{} - dummyService := alert.NewService(repositoryMock, nil) + dummyService := alert.NewService(repositoryMock, nil, nil) timenow := time.Now() dummyAlerts := []alert.Alert{ {ID: 1, ProviderID: 1, ResourceName: "foo", Severity: "CRITICAL", MetricName: "baz", MetricValue: "20", @@ -67,7 +67,7 @@ func TestService_Get(t *testing.T) { t.Run("should call repository List method and handle errors", func(t *testing.T) { repositoryMock := &mocks.AlertRepository{} - dummyService := alert.NewService(repositoryMock, nil) + dummyService := alert.NewService(repositoryMock, nil, nil) repositoryMock.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), mock.Anything). Return(nil, errors.New("random error")) actualAlerts, err := dummyService.List(ctx, alert.Filter{ @@ -81,7 +81,7 @@ func TestService_Get(t *testing.T) { }) } -func TestService_Create(t *testing.T) { +func TestService_CreateAlerts(t *testing.T) { var ( ctx = context.TODO() timenow = time.Now() @@ -175,7 +175,7 @@ func TestService_Create(t *testing.T) { tc.setup(repositoryMock, alertTransformerMock) } - svc := alert.NewService(repositoryMock, map[string]alert.AlertTransformer{ + svc := alert.NewService(repositoryMock, nil, map[string]alert.AlertTransformer{ testType: alertTransformerMock, }) actualAlerts, firingLen, err := svc.CreateAlerts(ctx, testType, 1, 1, tc.alertsToBeCreated) diff --git a/core/alert/silence.go b/core/alert/silence.go new file mode 100644 index 00000000..f7329e89 --- /dev/null +++ b/core/alert/silence.go @@ -0,0 +1,15 @@ +package alert + +const ( + SilenceStatusTotal = "total" + SilenceStatusPartial = "partial" +) + +func silenceStatus(hasSilenced, hasNonSilenced bool) string { + if hasSilenced && !hasNonSilenced { + return SilenceStatusTotal + } else if hasSilenced && hasNonSilenced { + return SilenceStatusPartial + } + return "" +} diff --git a/core/idempotency/idempotency.go b/core/idempotency/idempotency.go deleted file mode 100644 index e5e8458b..00000000 --- a/core/idempotency/idempotency.go +++ /dev/null @@ -1,18 +0,0 @@ -package idempotency - -import ( - "time" -) - -type Filter struct { - TTL time.Duration -} - -type Idempotency struct { - ID uint64 - Scope string - Key string - Success bool - CreatedAt time.Time - UpdatedAt time.Time -} diff --git a/core/log/filter.go b/core/log/filter.go new file mode 100644 index 00000000..9587f445 --- /dev/null +++ b/core/log/filter.go @@ -0,0 +1,5 @@ +package log + +type NotificationFilter struct { + SilenceID string +} diff --git a/core/log/mocks/notification_log_repository.go b/core/log/mocks/notification_log_repository.go new file mode 100644 index 00000000..d24a59fc --- /dev/null +++ b/core/log/mocks/notification_log_repository.go @@ -0,0 +1,170 @@ +// Code generated by mockery v2.16.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + log "github.com/odpf/siren/core/log" + mock "github.com/stretchr/testify/mock" +) + +// NotificationLogRepository is an autogenerated mock type for the NotificationLogRepository type +type NotificationLogRepository struct { + mock.Mock +} + +type NotificationLogRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *NotificationLogRepository) EXPECT() *NotificationLogRepository_Expecter { + return &NotificationLogRepository_Expecter{mock: &_m.Mock} +} + +// BulkCreate provides a mock function with given fields: _a0, _a1 +func (_m *NotificationLogRepository) BulkCreate(_a0 context.Context, _a1 []log.Notification) error { + ret := _m.Called(_a0, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, []log.Notification) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NotificationLogRepository_BulkCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BulkCreate' +type NotificationLogRepository_BulkCreate_Call struct { + *mock.Call +} + +// BulkCreate is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 []log.Notification +func (_e *NotificationLogRepository_Expecter) BulkCreate(_a0 interface{}, _a1 interface{}) *NotificationLogRepository_BulkCreate_Call { + return &NotificationLogRepository_BulkCreate_Call{Call: _e.mock.On("BulkCreate", _a0, _a1)} +} + +func (_c *NotificationLogRepository_BulkCreate_Call) Run(run func(_a0 context.Context, _a1 []log.Notification)) *NotificationLogRepository_BulkCreate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]log.Notification)) + }) + return _c +} + +func (_c *NotificationLogRepository_BulkCreate_Call) Return(_a0 error) *NotificationLogRepository_BulkCreate_Call { + _c.Call.Return(_a0) + return _c +} + +// ListAlertIDsBySilenceID provides a mock function with given fields: _a0, _a1 +func (_m *NotificationLogRepository) ListAlertIDsBySilenceID(_a0 context.Context, _a1 string) ([]int64, error) { + ret := _m.Called(_a0, _a1) + + var r0 []int64 + if rf, ok := ret.Get(0).(func(context.Context, string) []int64); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int64) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NotificationLogRepository_ListAlertIDsBySilenceID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAlertIDsBySilenceID' +type NotificationLogRepository_ListAlertIDsBySilenceID_Call struct { + *mock.Call +} + +// ListAlertIDsBySilenceID is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 string +func (_e *NotificationLogRepository_Expecter) ListAlertIDsBySilenceID(_a0 interface{}, _a1 interface{}) *NotificationLogRepository_ListAlertIDsBySilenceID_Call { + return &NotificationLogRepository_ListAlertIDsBySilenceID_Call{Call: _e.mock.On("ListAlertIDsBySilenceID", _a0, _a1)} +} + +func (_c *NotificationLogRepository_ListAlertIDsBySilenceID_Call) Run(run func(_a0 context.Context, _a1 string)) *NotificationLogRepository_ListAlertIDsBySilenceID_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *NotificationLogRepository_ListAlertIDsBySilenceID_Call) Return(_a0 []int64, _a1 error) *NotificationLogRepository_ListAlertIDsBySilenceID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +// ListSubscriptionIDsBySilenceID provides a mock function with given fields: _a0, _a1 +func (_m *NotificationLogRepository) ListSubscriptionIDsBySilenceID(_a0 context.Context, _a1 string) ([]int64, error) { + ret := _m.Called(_a0, _a1) + + var r0 []int64 + if rf, ok := ret.Get(0).(func(context.Context, string) []int64); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int64) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NotificationLogRepository_ListSubscriptionIDsBySilenceID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSubscriptionIDsBySilenceID' +type NotificationLogRepository_ListSubscriptionIDsBySilenceID_Call struct { + *mock.Call +} + +// ListSubscriptionIDsBySilenceID is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 string +func (_e *NotificationLogRepository_Expecter) ListSubscriptionIDsBySilenceID(_a0 interface{}, _a1 interface{}) *NotificationLogRepository_ListSubscriptionIDsBySilenceID_Call { + return &NotificationLogRepository_ListSubscriptionIDsBySilenceID_Call{Call: _e.mock.On("ListSubscriptionIDsBySilenceID", _a0, _a1)} +} + +func (_c *NotificationLogRepository_ListSubscriptionIDsBySilenceID_Call) Run(run func(_a0 context.Context, _a1 string)) *NotificationLogRepository_ListSubscriptionIDsBySilenceID_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *NotificationLogRepository_ListSubscriptionIDsBySilenceID_Call) Return(_a0 []int64, _a1 error) *NotificationLogRepository_ListSubscriptionIDsBySilenceID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +type mockConstructorTestingTNewNotificationLogRepository interface { + mock.TestingT + Cleanup(func()) +} + +// NewNotificationLogRepository creates a new instance of NotificationLogRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewNotificationLogRepository(t mockConstructorTestingTNewNotificationLogRepository) *NotificationLogRepository { + mock := &NotificationLogRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/log/notification.go b/core/log/notification.go new file mode 100644 index 00000000..3cec5905 --- /dev/null +++ b/core/log/notification.go @@ -0,0 +1,24 @@ +package log + +import ( + "context" + "time" +) + +//go:generate mockery --name=NotificationLogRepository -r --case underscore --with-expecter --structname NotificationLogRepository --filename notification_log_repository.go --output=./mocks +type NotificationLogRepository interface { + BulkCreate(context.Context, []Notification) error + ListAlertIDsBySilenceID(context.Context, string) ([]int64, error) + ListSubscriptionIDsBySilenceID(context.Context, string) ([]int64, error) +} + +type Notification struct { + ID string + NamespaceID uint64 + NotificationID string + SubscriptionID uint64 + ReceiverID uint64 + AlertIDs []int64 + SilenceIDs []string + CreatedAt time.Time +} diff --git a/core/log/service.go b/core/log/service.go new file mode 100644 index 00000000..07565912 --- /dev/null +++ b/core/log/service.go @@ -0,0 +1,23 @@ +package log + +import "context" + +type Service struct { + notificationLogRepo NotificationLogRepository +} + +func NewService(nlr NotificationLogRepository) *Service { + return &Service{nlr} +} + +func (s *Service) LogNotifications(ctx context.Context, nlogs ...Notification) error { + return s.notificationLogRepo.BulkCreate(ctx, nlogs) +} + +func (s *Service) ListAlertIDsBySilenceID(ctx context.Context, silenceID string) ([]int64, error) { + return s.notificationLogRepo.ListAlertIDsBySilenceID(ctx, silenceID) +} + +func (s *Service) ListSubscriptionIDsBySilenceID(ctx context.Context, silenceID string) ([]int64, error) { + return s.notificationLogRepo.ListSubscriptionIDsBySilenceID(ctx, silenceID) +} diff --git a/core/notification/builder.go b/core/notification/builder.go new file mode 100644 index 00000000..4cb90154 --- /dev/null +++ b/core/notification/builder.go @@ -0,0 +1,130 @@ +package notification + +import ( + "fmt" + "time" + + "github.com/mitchellh/mapstructure" + "github.com/odpf/siren/core/alert" + "github.com/odpf/siren/core/template" + "github.com/odpf/siren/pkg/errors" +) + +// Transform alerts and populate Data and Labels to be interpolated to the system-default template +// .Data +// - id +// - status "FIRING"/"RESOLVED" +// - resource +// - template +// - metric_value +// - metric_name +// - generator_url +// - num_alerts_firing +// - dashboard +// - playbook +// - summary +// .Labels +// - severity "WARNING"/"CRITICAL" +// - alertname +// - (others labels defined in rules) +func BuildFromAlerts( + as []alert.Alert, + firingLen int, + createdTime time.Time, +) Notification { + if len(as) == 0 { + return Notification{} + } + + sampleAlert := as[0] + + data := map[string]interface{}{} + + mergedAnnotations := map[string][]string{} + for _, a := range as { + for k, v := range a.Annotations { + mergedAnnotations[k] = append(mergedAnnotations[k], v) + } + } + // make unique + for k, v := range mergedAnnotations { + mergedAnnotations[k] = removeDuplicateStringValues(v) + } + // render annotations + for k, vSlice := range mergedAnnotations { + for _, v := range vSlice { + if _, ok := data[k]; ok { + data[k] = fmt.Sprintf("%s\n%s", data[k], v) + } else { + data[k] = v + } + } + } + + data["status"] = sampleAlert.Status + data["generator_url"] = sampleAlert.GeneratorURL + data["num_alerts_firing"] = firingLen + + labels := map[string]string{} + alertIDs := []int64{} + + for _, a := range as { + alertIDs = append(alertIDs, int64(a.ID)) + for k, v := range a.Labels { + labels[k] = v + } + } + + return Notification{ + NamespaceID: sampleAlert.NamespaceID, + Type: TypeSubscriber, + Data: data, + Labels: labels, + Template: template.ReservedName_SystemDefault, + CreatedAt: createdTime, + AlertIDs: alertIDs, + } +} + +// BuildTypeReceiver builds a notification struct with receiver type flow +func BuildTypeReceiver(receiverID uint64, payloadMap map[string]interface{}) (Notification, error) { + n := Notification{} + if err := mapstructure.Decode(payloadMap, &n); err != nil { + return Notification{}, errors.ErrInvalid.WithMsgf("failed to parse payload to notification: %s", err.Error()) + } + + if val, ok := payloadMap[ValidDurationRequestKey]; ok { + valString, ok := val.(string) + if !ok { + return Notification{}, fmt.Errorf("cannot parse %s value: %v", ValidDurationRequestKey, val) + } + parsedDur, err := time.ParseDuration(valString) + if err != nil { + return Notification{}, err + } + n.ValidDuration = parsedDur + } + + n.Type = TypeReceiver + + if len(n.Labels) == 0 { + n.Labels = map[string]string{} + } + + n.Labels[ReceiverIDLabelKey] = fmt.Sprintf("%d", receiverID) + + return n, nil +} + +func removeDuplicateStringValues(strSlice []string) []string { + keys := make(map[string]bool) + list := []string{} + + for _, v := range strSlice { + if _, value := keys[v]; !value { + keys[v] = true + list = append(list, v) + } + } + return list +} diff --git a/core/notification/builder_test.go b/core/notification/builder_test.go new file mode 100644 index 00000000..b72e5840 --- /dev/null +++ b/core/notification/builder_test.go @@ -0,0 +1,160 @@ +package notification_test + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/odpf/siren/core/alert" + "github.com/odpf/siren/core/notification" + "github.com/odpf/siren/core/template" +) + +func TestBuildTypeReceiver(t *testing.T) { + sampleReceiverID := uint64(11) + + tests := []struct { + name string + receiverID uint64 + payloadMap map[string]interface{} + want notification.Notification + wantErr bool + }{ + { + name: "should build a correct notification", + receiverID: sampleReceiverID, + payloadMap: map[string]interface{}{ + "data": map[string]interface{}{ + "key1": "key2", + }, + "valid_duration": "10m", + "template": "some-template", + }, + want: notification.Notification{ + Type: notification.TypeReceiver, + Data: map[string]interface{}{ + "key1": "key2", + }, + Labels: map[string]string{ + "receiver_id": "11", + }, + ValidDuration: time.Duration(10 * time.Minute), + Template: "some-template", + }, + }, + { + name: "should return error if payload is not decodable", + receiverID: sampleReceiverID, + payloadMap: map[string]interface{}{ + "template": 1, + }, + wantErr: true, + }, + { + name: "should return error if 'valid_duration' is not string", + receiverID: sampleReceiverID, + payloadMap: map[string]interface{}{ + "valid_duration": 1, + }, + wantErr: true, + }, + { + name: "should return error if 'valid_duration' is not parsable", + receiverID: sampleReceiverID, + payloadMap: map[string]interface{}{ + "valid_duration": "xzx", + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := notification.BuildTypeReceiver(tt.receiverID, tt.payloadMap) + if (err != nil) != tt.wantErr { + t.Errorf("BuildTypeReceiver() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Errorf("BuildTypeReceiver() diff = %v", diff) + } + }) + } +} + +func TestBuildFromAlerts(t *testing.T) { + tests := []struct { + name string + alerts []alert.Alert + firingLen int + want notification.Notification + }{ + + { + name: "should return empty notification if alerts slice is empty", + want: notification.Notification{}, + }, + { + name: `should properly return notification + - same annotations are joined by newline + - labels are merged + `, + alerts: []alert.Alert{ + { + ID: 14, + ProviderID: 1, + NamespaceID: 1, + ResourceName: "test-alert-host-1", + MetricName: "test-alert", + MetricValue: "15", + Severity: "WARNING", + Rule: "test-alert-template", + Labels: map[string]string{"lk1": "lv1"}, + Annotations: map[string]string{"ak1": "akv1"}, + Status: "FIRING", + }, + { + ID: 15, + ProviderID: 1, + NamespaceID: 1, + ResourceName: "test-alert-host-2", + MetricName: "test-alert", + MetricValue: "16", + Severity: "WARNING", + Rule: "test-alert-template", + Labels: map[string]string{"lk1": "lv11", "lk2": "lv2"}, + Annotations: map[string]string{"ak1": "akv11", "ak2": "akv2"}, + Status: "FIRING", + }, + }, + firingLen: 2, + want: notification.Notification{ + NamespaceID: 1, + Type: notification.TypeSubscriber, + + Data: map[string]interface{}{ + "generator_url": "", + "num_alerts_firing": 2, + "status": "FIRING", + "ak1": "akv1\nakv11", + "ak2": "akv2", + }, + Labels: map[string]string{ + "lk1": "lv11", + "lk2": "lv2", + }, + Template: template.ReservedName_SystemDefault, + AlertIDs: []int64{14, 15}, + }, + }, + {}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := notification.BuildFromAlerts(tt.alerts, tt.firingLen, time.Time{}) + + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Errorf("BuildFromAlerts() got diff = %v", diff) + } + }) + } +} diff --git a/core/notification/dispatch_receiver_service.go b/core/notification/dispatch_receiver_service.go new file mode 100644 index 00000000..f53bdc70 --- /dev/null +++ b/core/notification/dispatch_receiver_service.go @@ -0,0 +1,73 @@ +package notification + +import ( + "context" + "strconv" + + "github.com/odpf/siren/core/log" + "github.com/odpf/siren/core/receiver" + "github.com/odpf/siren/pkg/errors" +) + +type DispatchReceiverService struct { + receiverService ReceiverService + notifierPlugins map[string]Notifier +} + +func NewDispatchReceiverService(receiverService ReceiverService, notifierPlugins map[string]Notifier) *DispatchReceiverService { + return &DispatchReceiverService{ + receiverService: receiverService, + notifierPlugins: notifierPlugins, + } +} + +func (s *DispatchReceiverService) getNotifierPlugin(receiverType string) (Notifier, error) { + notifierPlugin, exist := s.notifierPlugins[receiverType] + if !exist { + return nil, errors.ErrInvalid.WithMsgf("unsupported receiver type: %q", receiverType) + } + return notifierPlugin, nil +} + +func (s *DispatchReceiverService) PrepareMessage(ctx context.Context, n Notification) ([]Message, []log.Notification, bool, error) { + + var notificationLogs []log.Notification + + receiverID, err := strconv.ParseUint(n.Labels[ReceiverIDLabelKey], 0, 64) + if err != nil { + // should not goes here as this already have been checked + return nil, nil, false, err + } + + rcv, err := s.receiverService.Get(ctx, receiverID, receiver.GetWithData(false)) + if err != nil { + return nil, nil, false, err + } + + notifierPlugin, err := s.getNotifierPlugin(rcv.Type) + if err != nil { + return nil, nil, false, errors.ErrInvalid.WithMsgf("invalid receiver type: %s", err.Error()) + } + + message, err := InitMessage( + ctx, + notifierPlugin, + n, + rcv.Type, + rcv.Configurations, + InitWithExpiryDuration(n.ValidDuration), + ) + if err != nil { + return nil, nil, false, err + } + + messages := []Message{message} + notificationLogs = append(notificationLogs, log.Notification{ + NamespaceID: n.NamespaceID, + NotificationID: n.ID, + ReceiverID: rcv.ID, + AlertIDs: n.AlertIDs, + }) + + return messages, notificationLogs, false, nil +} diff --git a/core/notification/dispatch_receiver_service_test.go b/core/notification/dispatch_receiver_service_test.go new file mode 100644 index 00000000..95cf231f --- /dev/null +++ b/core/notification/dispatch_receiver_service_test.go @@ -0,0 +1,133 @@ +package notification_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/odpf/siren/core/log" + "github.com/odpf/siren/core/notification" + "github.com/odpf/siren/core/notification/mocks" + "github.com/odpf/siren/core/receiver" + "github.com/odpf/siren/pkg/errors" + "github.com/stretchr/testify/mock" +) + +func TestDispatchReceiverService_PrepareMessage(t *testing.T) { + tests := []struct { + name string + setup func(*mocks.ReceiverService, *mocks.Notifier) + n notification.Notification + want []notification.Message + want1 []log.Notification + want2 bool + wantErr bool + }{ + { + name: "should return error if receiver id in label is not parsable", + n: notification.Notification{ + Labels: map[string]string{ + notification.ReceiverIDLabelKey: "x", + }, + }, + wantErr: true, + }, + { + name: "should return error if receiver service return error", + n: notification.Notification{ + Labels: map[string]string{ + notification.ReceiverIDLabelKey: "11", + }, + }, + setup: func(rs *mocks.ReceiverService, n *mocks.Notifier) { + rs.EXPECT().Get(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("receiver.GetOption")).Return(nil, errors.New("some error")) + }, + wantErr: true, + }, + { + name: "should return error if receiver type is unknown", + n: notification.Notification{ + Labels: map[string]string{ + notification.ReceiverIDLabelKey: "11", + }, + }, + setup: func(rs *mocks.ReceiverService, n *mocks.Notifier) { + rs.EXPECT().Get(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("receiver.GetOption")).Return(&receiver.Receiver{}, nil) + }, + wantErr: true, + }, + { + name: "should return error if init message return error", + n: notification.Notification{ + Labels: map[string]string{ + notification.ReceiverIDLabelKey: "11", + }, + }, + setup: func(rs *mocks.ReceiverService, n *mocks.Notifier) { + rs.EXPECT().Get(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("receiver.GetOption")).Return(&receiver.Receiver{ + Type: testPluginType, + }, nil) + n.EXPECT().PreHookQueueTransformConfigs(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("map[string]interface {}")).Return(nil, errors.New("some error")) + }, + wantErr: true, + }, + { + name: "should return no error if all flow passed", + n: notification.Notification{ + Labels: map[string]string{ + notification.ReceiverIDLabelKey: "11", + }, + }, + setup: func(rs *mocks.ReceiverService, n *mocks.Notifier) { + rs.EXPECT().Get(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("receiver.GetOption")).Return(&receiver.Receiver{ + ID: 11, + Type: testPluginType, + }, nil) + n.EXPECT().PreHookQueueTransformConfigs(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("map[string]interface {}")).Return(map[string]interface{}{}, nil) + }, + want: []notification.Message{ + { + Status: notification.MessageStatusEnqueued, + ReceiverType: testPluginType, + Configs: map[string]interface{}{}, + Details: map[string]interface{}{"notification_type": string(""), "receiver_id": string("11")}, + MaxTries: 3, + }, + }, + want1: []log.Notification{{ReceiverID: 11}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + mockReceiverService = new(mocks.ReceiverService) + mockNotifier = new(mocks.Notifier) + ) + s := notification.NewDispatchReceiverService( + mockReceiverService, + map[string]notification.Notifier{ + testPluginType: mockNotifier, + }) + if tt.setup != nil { + tt.setup(mockReceiverService, mockNotifier) + } + got, got1, got2, err := s.PrepareMessage(context.TODO(), tt.n) + if (err != nil) != tt.wantErr { + t.Errorf("DispatchReceiverService.PrepareMessage() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(got, tt.want, + cmpopts.IgnoreFields(notification.Message{}, "ID", "CreatedAt", "UpdatedAt"), + cmpopts.IgnoreUnexported(notification.Message{})); diff != "" { + t.Errorf("DispatchReceiverService.PrepareMessage() diff = %v", diff) + } + if diff := cmp.Diff(got1, tt.want1); diff != "" { + t.Errorf("DispatchReceiverService.PrepareMessage() diff = %v", diff) + } + if got2 != tt.want2 { + t.Errorf("DispatchReceiverService.PrepareMessage() got2 = %v, want %v", got2, tt.want2) + } + }) + } +} diff --git a/core/notification/dispatch_subscriber_service.go b/core/notification/dispatch_subscriber_service.go new file mode 100644 index 00000000..151a697c --- /dev/null +++ b/core/notification/dispatch_subscriber_service.go @@ -0,0 +1,160 @@ +package notification + +import ( + "context" + "fmt" + + saltlog "github.com/odpf/salt/log" + "github.com/odpf/siren/core/log" + "github.com/odpf/siren/core/silence" + "github.com/odpf/siren/pkg/errors" + "github.com/odpf/siren/pkg/telemetry" +) + +type DispatchSubscriberService struct { + logger saltlog.Logger + subscriptionService SubscriptionService + silenceService SilenceService + notifierPlugins map[string]Notifier +} + +func NewDispatchSubscriberService( + logger saltlog.Logger, + subscriptionService SubscriptionService, + silenceService SilenceService, + notifierPlugins map[string]Notifier) *DispatchSubscriberService { + return &DispatchSubscriberService{ + logger: logger, + subscriptionService: subscriptionService, + silenceService: silenceService, + notifierPlugins: notifierPlugins, + } +} + +func (s *DispatchSubscriberService) getNotifierPlugin(receiverType string) (Notifier, error) { + notifierPlugin, exist := s.notifierPlugins[receiverType] + if !exist { + return nil, errors.ErrInvalid.WithMsgf("unsupported receiver type: %q", receiverType) + } + return notifierPlugin, nil +} + +func (s *DispatchSubscriberService) PrepareMessage(ctx context.Context, n Notification) ([]Message, []log.Notification, bool, error) { + + var ( + messages = make([]Message, 0) + notificationLogs []log.Notification + hasSilenced bool + ) + + subscriptions, err := s.subscriptionService.MatchByLabels(ctx, n.NamespaceID, n.Labels) + if err != nil { + return nil, nil, false, err + } + + if len(subscriptions) == 0 { + telemetry.IncrementInt64Counter(ctx, telemetry.MetricNotificationSubscriberNotFound) + return nil, nil, false, errors.ErrInvalid.WithMsgf("not matching any subscription") + } + + for _, sub := range subscriptions { + + if len(sub.Receivers) == 0 { + s.logger.Warn(fmt.Sprintf("invalid subscription with id %d, no receiver found", sub.ID)) + continue + } + + // try silencing by labels + silences, err := s.silenceService.List(ctx, silence.Filter{ + NamespaceID: n.NamespaceID, + SubscriptionMatch: sub.Match, + }) + if err != nil { + return nil, nil, false, err + } + + if len(silences) != 0 { + hasSilenced = true + + var silenceIDs []string + for _, sil := range silences { + silenceIDs = append(silenceIDs, sil.ID) + } + + notificationLogs = append(notificationLogs, log.Notification{ + NamespaceID: n.NamespaceID, + NotificationID: n.ID, + SubscriptionID: sub.ID, + AlertIDs: n.AlertIDs, + SilenceIDs: silenceIDs, + }) + + s.logger.Info(fmt.Sprintf("notification '%s' of alert ids '%v' is being silenced by labels '%v'", n.ID, n.AlertIDs, silences)) + continue + } + + // subscription not being silenced by label + silences, err = s.silenceService.List(ctx, silence.Filter{ + NamespaceID: n.NamespaceID, + SubscriptionID: sub.ID, + }) + if err != nil { + return nil, nil, false, err + } + + silencedReceiversMap, validReceivers, err := sub.SilenceReceivers(silences) + if err != nil { + return nil, nil, false, errors.ErrInvalid.WithMsgf(err.Error()) + } + + if len(silencedReceiversMap) != 0 { + hasSilenced = true + + for rcvID, sils := range silencedReceiversMap { + var silenceIDs []string + for _, sil := range sils { + silenceIDs = append(silenceIDs, sil.ID) + } + + notificationLogs = append(notificationLogs, log.Notification{ + NamespaceID: n.NamespaceID, + NotificationID: n.ID, + SubscriptionID: sub.ID, + ReceiverID: rcvID, + AlertIDs: n.AlertIDs, + SilenceIDs: silenceIDs, + }) + } + } + + for _, rcv := range validReceivers { + notifierPlugin, err := s.getNotifierPlugin(rcv.Type) + if err != nil { + return nil, nil, false, err + } + + message, err := InitMessage( + ctx, + notifierPlugin, + n, + rcv.Type, + rcv.Configuration, + InitWithExpiryDuration(n.ValidDuration), + ) + if err != nil { + return nil, nil, false, err + } + + messages = append(messages, message) + notificationLogs = append(notificationLogs, log.Notification{ + NamespaceID: n.NamespaceID, + NotificationID: n.ID, + SubscriptionID: sub.ID, + ReceiverID: rcv.ID, + AlertIDs: n.AlertIDs, + }) + } + } + + return messages, notificationLogs, hasSilenced, nil +} diff --git a/core/notification/dispatch_subscriber_service_test.go b/core/notification/dispatch_subscriber_service_test.go new file mode 100644 index 00000000..c7648ac9 --- /dev/null +++ b/core/notification/dispatch_subscriber_service_test.go @@ -0,0 +1,342 @@ +package notification_test + +import ( + "context" + "errors" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + saltlog "github.com/odpf/salt/log" + "github.com/odpf/siren/core/log" + "github.com/odpf/siren/core/notification" + "github.com/odpf/siren/core/notification/mocks" + "github.com/odpf/siren/core/silence" + "github.com/odpf/siren/core/subscription" + "github.com/stretchr/testify/mock" +) + +func TestDispatchSubscriberService_PrepareMessage(t *testing.T) { + tests := []struct { + name string + setup func(*mocks.SubscriptionService, *mocks.SilenceService, *mocks.Notifier) + n notification.Notification + want []notification.Message + want1 []log.Notification + want2 bool + wantErr bool + }{ + { + name: "should return error if subscription service match by labels return error", + setup: func(ss1 *mocks.SubscriptionService, ss2 *mocks.SilenceService, n *mocks.Notifier) { + ss1.EXPECT().MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")).Return(nil, errors.New("some error")) + }, + wantErr: true, + }, + { + name: "should return error if no matching subscriptions", + setup: func(ss1 *mocks.SubscriptionService, ss2 *mocks.SilenceService, n *mocks.Notifier) { + ss1.EXPECT().MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")).Return(nil, nil) + }, + wantErr: true, + }, + { + name: "should return error if match subscription exist but list silences return error", + setup: func(ss1 *mocks.SubscriptionService, ss2 *mocks.SilenceService, n *mocks.Notifier) { + ss1.EXPECT().MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")).Return([]subscription.Subscription{ + { + ID: 123, + Receivers: []subscription.Receiver{ + { + ID: 1, + }, + }, + }, + }, nil) + ss2.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("silence.Filter")).Return(nil, errors.New("some error")) + }, + wantErr: true, + }, + { + name: "should return error if match subscription exist but list silences by label return error", + setup: func(ss1 *mocks.SubscriptionService, ss2 *mocks.SilenceService, n *mocks.Notifier) { + ss1.EXPECT().MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")).Return([]subscription.Subscription{ + { + ID: 123, + Receivers: []subscription.Receiver{ + { + ID: 1, + }, + }, + }, + }, nil) + ss2.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("silence.Filter")).Return(nil, errors.New("some error")) + }, + wantErr: true, + }, + { + name: "should return no error if silenced by labels success", + n: notification.Notification{ + NamespaceID: 1, + }, + setup: func(ss1 *mocks.SubscriptionService, ss2 *mocks.SilenceService, n *mocks.Notifier) { + ss1.EXPECT().MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")).Return([]subscription.Subscription{ + { + ID: 123, + Match: map[string]string{ + "k1": "v1", + }, + Receivers: []subscription.Receiver{ + { + ID: 1, + }, + }, + }, + }, nil) + ss2.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), silence.Filter{ + NamespaceID: 1, + SubscriptionMatch: map[string]string{ + "k1": "v1", + }, + }).Return([]silence.Silence{ + { + ID: "silence-id", + NamespaceID: 1, + TargetID: 123, + }, + }, nil) + }, + want: []notification.Message{}, + want1: []log.Notification{{SubscriptionID: 123, NamespaceID: 1, SilenceIDs: []string{"silence-id"}}}, + want2: true, + }, + { + name: "should return error if silenced by subscription return error", + n: notification.Notification{ + NamespaceID: 1, + }, + setup: func(ss1 *mocks.SubscriptionService, ss2 *mocks.SilenceService, n *mocks.Notifier) { + ss1.EXPECT().MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")).Return([]subscription.Subscription{ + { + ID: 123, + Namespace: 1, + Match: map[string]string{ + "k1": "v1", + }, + Receivers: []subscription.Receiver{ + { + ID: 1, + }, + }, + }, + }, nil) + ss2.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), silence.Filter{ + NamespaceID: 1, + SubscriptionMatch: map[string]string{ + "k1": "v1", + }, + }).Return(nil, nil) + ss2.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), silence.Filter{ + NamespaceID: 1, + SubscriptionID: 123, + }).Return([]silence.Silence{ + { + ID: "silence-id", + NamespaceID: 1, + }, + }, nil) + }, + wantErr: true, + }, + { + name: "should return no error if silenced by subscription success", + n: notification.Notification{ + NamespaceID: 1, + }, + setup: func(ss1 *mocks.SubscriptionService, ss2 *mocks.SilenceService, n *mocks.Notifier) { + ss1.EXPECT().MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")).Return([]subscription.Subscription{ + { + ID: 123, + Namespace: 1, + Match: map[string]string{ + "k1": "v1", + }, + Receivers: []subscription.Receiver{ + { + ID: 1, + }, + }, + }, + }, nil) + ss2.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), silence.Filter{ + NamespaceID: 1, + SubscriptionMatch: map[string]string{ + "k1": "v1", + }, + }).Return(nil, nil) + ss2.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), silence.Filter{ + NamespaceID: 1, + SubscriptionID: 123, + }).Return([]silence.Silence{ + { + ID: "silence-id", + NamespaceID: 1, + Type: silence.TypeSubscription, + }, + }, nil) + }, + want: []notification.Message{}, + want1: []log.Notification{{SubscriptionID: 123, NamespaceID: 1, ReceiverID: 1, SilenceIDs: []string{"silence-id"}}}, + want2: true, + }, + { + name: "should return error if receiver type is unknown", + n: notification.Notification{ + NamespaceID: 1, + }, + setup: func(ss1 *mocks.SubscriptionService, ss2 *mocks.SilenceService, n *mocks.Notifier) { + ss1.EXPECT().MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")).Return([]subscription.Subscription{ + { + ID: 123, + Namespace: 1, + Match: map[string]string{ + "k1": "v1", + }, + Receivers: []subscription.Receiver{ + { + ID: 1, + }, + }, + }, + }, nil) + ss2.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), silence.Filter{ + NamespaceID: 1, + SubscriptionMatch: map[string]string{ + "k1": "v1", + }, + }).Return(nil, nil) + ss2.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), silence.Filter{ + NamespaceID: 1, + SubscriptionID: 123, + }).Return(nil, nil) + }, + wantErr: true, + }, + { + name: "should return error if init messages return error", + n: notification.Notification{ + NamespaceID: 1, + }, + setup: func(ss1 *mocks.SubscriptionService, ss2 *mocks.SilenceService, n *mocks.Notifier) { + ss1.EXPECT().MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")).Return([]subscription.Subscription{ + { + ID: 123, + Namespace: 1, + Match: map[string]string{ + "k1": "v1", + }, + Receivers: []subscription.Receiver{ + { + ID: 1, + Type: testPluginType, + }, + }, + }, + }, nil) + ss2.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), silence.Filter{ + NamespaceID: 1, + SubscriptionMatch: map[string]string{ + "k1": "v1", + }, + }).Return(nil, nil) + ss2.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), silence.Filter{ + NamespaceID: 1, + SubscriptionID: 123, + }).Return(nil, nil) + n.EXPECT().PreHookQueueTransformConfigs(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("map[string]interface {}")).Return(nil, errors.New("some error")) + }, + wantErr: true, + }, + { + name: "should return no error if all flow passed and no silences", + n: notification.Notification{ + NamespaceID: 1, + }, + setup: func(ss1 *mocks.SubscriptionService, ss2 *mocks.SilenceService, n *mocks.Notifier) { + ss1.EXPECT().MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")).Return([]subscription.Subscription{ + { + ID: 123, + Namespace: 1, + Match: map[string]string{ + "k1": "v1", + }, + Receivers: []subscription.Receiver{ + { + ID: 1, + Type: testPluginType, + }, + }, + }, + }, nil) + ss2.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), silence.Filter{ + NamespaceID: 1, + SubscriptionMatch: map[string]string{ + "k1": "v1", + }, + }).Return(nil, nil) + ss2.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), silence.Filter{ + NamespaceID: 1, + SubscriptionID: 123, + }).Return(nil, nil) + n.EXPECT().PreHookQueueTransformConfigs(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("map[string]interface {}")).Return(map[string]interface{}{}, nil) + }, + want: []notification.Message{ + { + Status: notification.MessageStatusEnqueued, + ReceiverType: testPluginType, + Configs: map[string]interface{}{}, + Details: map[string]interface{}{"notification_type": string("")}, + MaxTries: 3, + }, + }, + want1: []log.Notification{{NamespaceID: 1, SubscriptionID: 123, ReceiverID: 1}}, + want2: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + mockSubscriptionService = new(mocks.SubscriptionService) + mockSilenceService = new(mocks.SilenceService) + mockNotifier = new(mocks.Notifier) + ) + s := notification.NewDispatchSubscriberService( + saltlog.NewNoop(), + mockSubscriptionService, + mockSilenceService, map[string]notification.Notifier{ + testPluginType: mockNotifier, + }) + + if tt.setup != nil { + tt.setup(mockSubscriptionService, mockSilenceService, mockNotifier) + } + + got, got1, got2, err := s.PrepareMessage(context.TODO(), tt.n) + if (err != nil) != tt.wantErr { + t.Errorf("DispatchSubscriberService.PrepareMessage() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(got, tt.want, + cmpopts.IgnoreFields(notification.Message{}, "ID", "CreatedAt", "UpdatedAt"), + cmpopts.IgnoreUnexported(notification.Message{})); diff != "" { + t.Errorf("DispatchSubscriberService.PrepareMessage() diff = %v", diff) + } + if diff := cmp.Diff(got1, tt.want1); diff != "" { + t.Errorf("DispatchSubscriberService.PrepareMessage() diff = %v", diff) + } + if got2 != tt.want2 { + t.Errorf("DispatchSubscriberService.PrepareMessage() got2 = %v, want %v", got2, tt.want2) + } + }) + } +} diff --git a/core/notification/idempotency.go b/core/notification/idempotency.go new file mode 100644 index 00000000..9f02bf34 --- /dev/null +++ b/core/notification/idempotency.go @@ -0,0 +1,26 @@ +package notification + +import ( + "context" + "time" +) + +type IdempotencyFilter struct { + TTL time.Duration +} + +//go:generate mockery --name=IdempotencyRepository -r --case underscore --with-expecter --structname IdempotencyRepository --filename idempotency_repository.go --output=./mocks +type IdempotencyRepository interface { + InsertOnConflictReturning(context.Context, string, string) (*Idempotency, error) + UpdateSuccess(context.Context, uint64, bool) error + Delete(context.Context, IdempotencyFilter) error +} + +type Idempotency struct { + ID uint64 + Scope string + Key string + Success bool + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/core/notification/message.go b/core/notification/message.go index af3f0329..90fb9a8e 100644 --- a/core/notification/message.go +++ b/core/notification/message.go @@ -1,19 +1,25 @@ package notification import ( + "context" "time" "github.com/google/uuid" + "github.com/odpf/siren/core/template" + "github.com/odpf/siren/pkg/errors" + "github.com/odpf/siren/pkg/telemetry" + "go.opencensus.io/tag" + "gopkg.in/yaml.v3" ) // MessageStatus determines the state of the message type MessageStatus string const ( - DefaultMaxTries = 3 + defaultMaxTries int = 3 // additional details - DetailsKeyRoutingMethod = "routing_method" + DetailsKeyNotificationType = "notification_type" MessageStatusEnqueued MessageStatus = "enqueued" MessageStatusFailed MessageStatus = "failed" @@ -59,42 +65,53 @@ func InitWithMaxTries(mt int) MessageOption { // Message is the model to be sent for a specific subscription's receiver type Message struct { - ID string - Status MessageStatus - + ID string + Status MessageStatus ReceiverType string Configs map[string]interface{} // the datasource to build vendor-specific configs Details map[string]interface{} // the datasource to build vendor-specific message - LastError string + MaxTries int + ExpiredAt time.Time + CreatedAt time.Time + UpdatedAt time.Time - MaxTries int + LastError string TryCount int Retryable bool - ExpiredAt time.Time - CreatedAt time.Time - UpdatedAt time.Time - expiryDuration time.Duration } // Initialize initializes the message with some default value // or the customized value -func (m *Message) Initialize( +func InitMessage( + ctx context.Context, + notifierPlugin Notifier, n Notification, receiverType string, - notificationConfigs map[string]interface{}, + messageConfig map[string]interface{}, opts ...MessageOption, -) { - var timeNow = time.Now() +) (Message, error) { + if notifierPlugin == nil { + return Message{}, errors.New("notifierPlugin cannot be nil") + } + + newConfigs, err := notifierPlugin.PreHookQueueTransformConfigs(ctx, messageConfig) + if err != nil { + telemetry.IncrementInt64Counter(ctx, telemetry.MetricReceiverHookFailed, + tag.Upsert(telemetry.TagNotificationType, n.Type), + tag.Upsert(telemetry.TagReceiverType, receiverType), + tag.Upsert(telemetry.TagHookCondition, telemetry.HookConditionPreHookQueue), + ) - m.ID = uuid.NewString() - m.Status = MessageStatusEnqueued + return Message{}, err + } - m.ReceiverType = receiverType - m.Configs = notificationConfigs + var ( + timeNow = time.Now() + details = make(map[string]interface{}) + ) - details := make(map[string]interface{}) for k, v := range n.Labels { details[k] = v } @@ -102,12 +119,16 @@ func (m *Message) Initialize( details[k] = v } - m.Details = details - - m.MaxTries = DefaultMaxTries - - m.CreatedAt = timeNow - m.UpdatedAt = timeNow + m := &Message{ + ID: uuid.NewString(), + Status: MessageStatusEnqueued, + ReceiverType: receiverType, + Configs: newConfigs, + Details: details, + MaxTries: defaultMaxTries, + CreatedAt: timeNow, + UpdatedAt: timeNow, + } for _, opt := range opts { opt(m) @@ -116,6 +137,38 @@ func (m *Message) Initialize( if m.expiryDuration != 0 { m.ExpiredAt = m.CreatedAt.Add(m.expiryDuration) } + + //TODO fetch template if any, if not exist, check provider type, if exist use the default template, if not pass as-is + // if there is template, render and replace detail with the new one + if n.Template != "" { + var templateBody string + + if template.IsReservedName(n.Template) { + templateBody = notifierPlugin.GetSystemDefaultTemplate() + } + + if templateBody != "" { + renderedDetailString, err := template.RenderBody(templateBody, n) + if err != nil { + return Message{}, errors.ErrInvalid.WithMsgf("failed to render template receiver %s: %s", receiverType, err.Error()) + } + + var messageDetails map[string]interface{} + if err := yaml.Unmarshal([]byte(renderedDetailString), &messageDetails); err != nil { + return Message{}, errors.ErrInvalid.WithMsgf("failed to unmarshal rendered template receiver %s: %s, rendered template: %v", receiverType, err.Error(), renderedDetailString) + } + m.Details = messageDetails + } + } + + m.Details[DetailsKeyNotificationType] = n.Type + + telemetry.IncrementInt64Counter(ctx, telemetry.MetricNotificationMessageCounter, + tag.Upsert(telemetry.TagNotificationType, TypeSubscriber), + tag.Upsert(telemetry.TagMessageStatus, m.Status.String()), + tag.Upsert(telemetry.TagReceiverType, m.ReceiverType)) + + return *m, nil } // MarkFailed update message to the failed state @@ -139,8 +192,3 @@ func (m *Message) MarkPublished(updatedAt time.Time) { m.Status = MessageStatusPublished m.UpdatedAt = updatedAt } - -// AddDetail adds a custom kv string detail -func (m *Message) AddStringDetail(key, value string) { - m.Details[key] = value -} diff --git a/core/notification/message_test.go b/core/notification/message_test.go index 5b896e93..4ad3b035 100644 --- a/core/notification/message_test.go +++ b/core/notification/message_test.go @@ -1,6 +1,7 @@ package notification_test import ( + "context" "errors" "testing" "time" @@ -8,9 +9,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/odpf/siren/core/notification" + "github.com/odpf/siren/core/notification/mocks" + "github.com/stretchr/testify/mock" ) -func TestMessage_Initialize(t *testing.T) { +func TestMessage_InitMessage(t *testing.T) { var ( testID = "some-id" testTimeNow = time.Now() @@ -18,16 +21,20 @@ func TestMessage_Initialize(t *testing.T) { ) testCases := []struct { name string + setup func(*mocks.Notifier) n notification.Notification receiverType string notificationConfigs map[string]interface{} - want *notification.Message - wantErr bool + want notification.Message + errString string }{ { name: "all notification labels and data should be merged to message detail and data takes precedence if key conflict", + setup: func(n *mocks.Notifier) { + n.EXPECT().PreHookQueueTransformConfigs(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("map[string]interface {}")).Return(nil, nil) + }, n: notification.Notification{ - ID: "notification-id", + Type: notification.TypeSubscriber, Labels: map[string]string{ "labelkey1": "value1", "samekey": "label_value", @@ -37,15 +44,15 @@ func TestMessage_Initialize(t *testing.T) { "samekey": "var_value", }, }, - want: ¬ification.Message{ + want: notification.Message{ ID: testID, Status: notification.MessageStatusEnqueued, Details: map[string]interface{}{ - "labelkey1": "value1", - "varkey1": "value1", - "samekey": "var_value", + "labelkey1": "value1", + "varkey1": "value1", + "samekey": "var_value", + notification.DetailsKeyNotificationType: notification.TypeSubscriber, }, - MaxTries: notification.DefaultMaxTries, CreatedAt: testTimeNow, UpdatedAt: testTimeNow, ExpiredAt: testTimeNow.Add(testExpiryDuration), @@ -54,8 +61,15 @@ func TestMessage_Initialize(t *testing.T) { } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - m := ¬ification.Message{} - m.Initialize( + mockNotifierPlugin := new(mocks.Notifier) + + if tc.setup != nil { + tc.setup(mockNotifierPlugin) + } + + m, err := notification.InitMessage( + context.TODO(), + mockNotifierPlugin, tc.n, tc.receiverType, tc.notificationConfigs, @@ -63,8 +77,15 @@ func TestMessage_Initialize(t *testing.T) { notification.InitWithCreateTime(testTimeNow), notification.InitWithExpiryDuration(testExpiryDuration), ) + if err != nil { + if err.Error() != tc.errString { + t.Fatalf("got error %s, expected was %s", err.Error(), tc.errString) + } + } - if diff := cmp.Diff(m, tc.want, cmpopts.IgnoreUnexported(notification.Message{})); diff != "" { + if diff := cmp.Diff(m, tc.want, + cmpopts.IgnoreUnexported(notification.Message{}), + cmpopts.IgnoreFields(notification.Message{}, "MaxTries")); diff != "" { t.Errorf("Notification.ToMessage() diff = %v", diff) } }) @@ -84,7 +105,6 @@ func TestMessage_Mark(t *testing.T) { "labelkey1": "value1", "varkey1": "value1", }, - MaxTries: notification.DefaultMaxTries, CreatedAt: createTime, UpdatedAt: createTime, ExpiredAt: expiredAt, @@ -104,7 +124,9 @@ func TestMessage_Mark(t *testing.T) { m.MarkFailed(testTimeNow, true, err) - if diff := cmp.Diff(m, expectedMessage, cmpopts.IgnoreUnexported(notification.Message{})); diff != "" { + if diff := cmp.Diff(m, expectedMessage, + cmpopts.IgnoreUnexported(notification.Message{}), + cmpopts.IgnoreFields(notification.Message{}, "MaxTries")); diff != "" { t.Errorf("result not match, diff = %v", diff) } }) @@ -120,7 +142,9 @@ func TestMessage_Mark(t *testing.T) { m.MarkPending(testTimeNow) - if diff := cmp.Diff(m, expectedMessage, cmpopts.IgnoreUnexported(notification.Message{})); diff != "" { + if diff := cmp.Diff(m, expectedMessage, + cmpopts.IgnoreUnexported(notification.Message{}), + cmpopts.IgnoreFields(notification.Message{}, "MaxTries")); diff != "" { t.Errorf("result not match, diff = %v", diff) } }) @@ -136,7 +160,9 @@ func TestMessage_Mark(t *testing.T) { m.MarkPublished(testTimeNow) - if diff := cmp.Diff(m, expectedMessage, cmpopts.IgnoreUnexported(notification.Message{})); diff != "" { + if diff := cmp.Diff(m, expectedMessage, + cmpopts.IgnoreUnexported(notification.Message{}), + cmpopts.IgnoreFields(notification.Message{}, "MaxTries")); diff != "" { t.Errorf("result not match, diff = %v", diff) } }) diff --git a/core/notification/mocks/alert_service.go b/core/notification/mocks/alert_service.go new file mode 100644 index 00000000..ca4be0b4 --- /dev/null +++ b/core/notification/mocks/alert_service.go @@ -0,0 +1,77 @@ +// Code generated by mockery v2.16.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// AlertService is an autogenerated mock type for the AlertService type +type AlertService struct { + mock.Mock +} + +type AlertService_Expecter struct { + mock *mock.Mock +} + +func (_m *AlertService) EXPECT() *AlertService_Expecter { + return &AlertService_Expecter{mock: &_m.Mock} +} + +// UpdateSilenceStatus provides a mock function with given fields: ctx, alertIDs, hasSilenced, hasNonSilenced +func (_m *AlertService) UpdateSilenceStatus(ctx context.Context, alertIDs []int64, hasSilenced bool, hasNonSilenced bool) error { + ret := _m.Called(ctx, alertIDs, hasSilenced, hasNonSilenced) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, []int64, bool, bool) error); ok { + r0 = rf(ctx, alertIDs, hasSilenced, hasNonSilenced) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// AlertService_UpdateSilenceStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateSilenceStatus' +type AlertService_UpdateSilenceStatus_Call struct { + *mock.Call +} + +// UpdateSilenceStatus is a helper method to define mock.On call +// - ctx context.Context +// - alertIDs []int64 +// - hasSilenced bool +// - hasNonSilenced bool +func (_e *AlertService_Expecter) UpdateSilenceStatus(ctx interface{}, alertIDs interface{}, hasSilenced interface{}, hasNonSilenced interface{}) *AlertService_UpdateSilenceStatus_Call { + return &AlertService_UpdateSilenceStatus_Call{Call: _e.mock.On("UpdateSilenceStatus", ctx, alertIDs, hasSilenced, hasNonSilenced)} +} + +func (_c *AlertService_UpdateSilenceStatus_Call) Run(run func(ctx context.Context, alertIDs []int64, hasSilenced bool, hasNonSilenced bool)) *AlertService_UpdateSilenceStatus_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]int64), args[2].(bool), args[3].(bool)) + }) + return _c +} + +func (_c *AlertService_UpdateSilenceStatus_Call) Return(_a0 error) *AlertService_UpdateSilenceStatus_Call { + _c.Call.Return(_a0) + return _c +} + +type mockConstructorTestingTNewAlertService interface { + mock.TestingT + Cleanup(func()) +} + +// NewAlertService creates a new instance of AlertService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewAlertService(t mockConstructorTestingTNewAlertService) *AlertService { + mock := &AlertService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/notification/mocks/dispatcher.go b/core/notification/mocks/dispatcher.go new file mode 100644 index 00000000..d869a9f4 --- /dev/null +++ b/core/notification/mocks/dispatcher.go @@ -0,0 +1,103 @@ +// Code generated by mockery v2.16.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + log "github.com/odpf/siren/core/log" + mock "github.com/stretchr/testify/mock" + + notification "github.com/odpf/siren/core/notification" +) + +// Dispatcher is an autogenerated mock type for the Dispatcher type +type Dispatcher struct { + mock.Mock +} + +type Dispatcher_Expecter struct { + mock *mock.Mock +} + +func (_m *Dispatcher) EXPECT() *Dispatcher_Expecter { + return &Dispatcher_Expecter{mock: &_m.Mock} +} + +// PrepareMessage provides a mock function with given fields: ctx, n +func (_m *Dispatcher) PrepareMessage(ctx context.Context, n notification.Notification) ([]notification.Message, []log.Notification, bool, error) { + ret := _m.Called(ctx, n) + + var r0 []notification.Message + if rf, ok := ret.Get(0).(func(context.Context, notification.Notification) []notification.Message); ok { + r0 = rf(ctx, n) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]notification.Message) + } + } + + var r1 []log.Notification + if rf, ok := ret.Get(1).(func(context.Context, notification.Notification) []log.Notification); ok { + r1 = rf(ctx, n) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]log.Notification) + } + } + + var r2 bool + if rf, ok := ret.Get(2).(func(context.Context, notification.Notification) bool); ok { + r2 = rf(ctx, n) + } else { + r2 = ret.Get(2).(bool) + } + + var r3 error + if rf, ok := ret.Get(3).(func(context.Context, notification.Notification) error); ok { + r3 = rf(ctx, n) + } else { + r3 = ret.Error(3) + } + + return r0, r1, r2, r3 +} + +// Dispatcher_PrepareMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrepareMessage' +type Dispatcher_PrepareMessage_Call struct { + *mock.Call +} + +// PrepareMessage is a helper method to define mock.On call +// - ctx context.Context +// - n notification.Notification +func (_e *Dispatcher_Expecter) PrepareMessage(ctx interface{}, n interface{}) *Dispatcher_PrepareMessage_Call { + return &Dispatcher_PrepareMessage_Call{Call: _e.mock.On("PrepareMessage", ctx, n)} +} + +func (_c *Dispatcher_PrepareMessage_Call) Run(run func(ctx context.Context, n notification.Notification)) *Dispatcher_PrepareMessage_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(notification.Notification)) + }) + return _c +} + +func (_c *Dispatcher_PrepareMessage_Call) Return(_a0 []notification.Message, _a1 []log.Notification, _a2 bool, _a3 error) *Dispatcher_PrepareMessage_Call { + _c.Call.Return(_a0, _a1, _a2, _a3) + return _c +} + +type mockConstructorTestingTNewDispatcher interface { + mock.TestingT + Cleanup(func()) +} + +// NewDispatcher creates a new instance of Dispatcher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewDispatcher(t mockConstructorTestingTNewDispatcher) *Dispatcher { + mock := &Dispatcher{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/notification/mocks/idempotency_repository.go b/core/notification/mocks/idempotency_repository.go index b54634c2..0ed1f893 100644 --- a/core/notification/mocks/idempotency_repository.go +++ b/core/notification/mocks/idempotency_repository.go @@ -1,11 +1,11 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks import ( context "context" - idempotency "github.com/odpf/siren/core/idempotency" + notification "github.com/odpf/siren/core/notification" mock "github.com/stretchr/testify/mock" ) @@ -23,11 +23,11 @@ func (_m *IdempotencyRepository) EXPECT() *IdempotencyRepository_Expecter { } // Delete provides a mock function with given fields: _a0, _a1 -func (_m *IdempotencyRepository) Delete(_a0 context.Context, _a1 idempotency.Filter) error { +func (_m *IdempotencyRepository) Delete(_a0 context.Context, _a1 notification.IdempotencyFilter) error { ret := _m.Called(_a0, _a1) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, idempotency.Filter) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, notification.IdempotencyFilter) error); ok { r0 = rf(_a0, _a1) } else { r0 = ret.Error(0) @@ -42,15 +42,15 @@ type IdempotencyRepository_Delete_Call struct { } // Delete is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 idempotency.Filter +// - _a0 context.Context +// - _a1 notification.IdempotencyFilter func (_e *IdempotencyRepository_Expecter) Delete(_a0 interface{}, _a1 interface{}) *IdempotencyRepository_Delete_Call { return &IdempotencyRepository_Delete_Call{Call: _e.mock.On("Delete", _a0, _a1)} } -func (_c *IdempotencyRepository_Delete_Call) Run(run func(_a0 context.Context, _a1 idempotency.Filter)) *IdempotencyRepository_Delete_Call { +func (_c *IdempotencyRepository_Delete_Call) Run(run func(_a0 context.Context, _a1 notification.IdempotencyFilter)) *IdempotencyRepository_Delete_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(idempotency.Filter)) + run(args[0].(context.Context), args[1].(notification.IdempotencyFilter)) }) return _c } @@ -61,15 +61,15 @@ func (_c *IdempotencyRepository_Delete_Call) Return(_a0 error) *IdempotencyRepos } // InsertOnConflictReturning provides a mock function with given fields: _a0, _a1, _a2 -func (_m *IdempotencyRepository) InsertOnConflictReturning(_a0 context.Context, _a1 string, _a2 string) (*idempotency.Idempotency, error) { +func (_m *IdempotencyRepository) InsertOnConflictReturning(_a0 context.Context, _a1 string, _a2 string) (*notification.Idempotency, error) { ret := _m.Called(_a0, _a1, _a2) - var r0 *idempotency.Idempotency - if rf, ok := ret.Get(0).(func(context.Context, string, string) *idempotency.Idempotency); ok { + var r0 *notification.Idempotency + if rf, ok := ret.Get(0).(func(context.Context, string, string) *notification.Idempotency); ok { r0 = rf(_a0, _a1, _a2) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*idempotency.Idempotency) + r0 = ret.Get(0).(*notification.Idempotency) } } @@ -89,9 +89,9 @@ type IdempotencyRepository_InsertOnConflictReturning_Call struct { } // InsertOnConflictReturning is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 string -// - _a2 string +// - _a0 context.Context +// - _a1 string +// - _a2 string func (_e *IdempotencyRepository_Expecter) InsertOnConflictReturning(_a0 interface{}, _a1 interface{}, _a2 interface{}) *IdempotencyRepository_InsertOnConflictReturning_Call { return &IdempotencyRepository_InsertOnConflictReturning_Call{Call: _e.mock.On("InsertOnConflictReturning", _a0, _a1, _a2)} } @@ -103,7 +103,7 @@ func (_c *IdempotencyRepository_InsertOnConflictReturning_Call) Run(run func(_a0 return _c } -func (_c *IdempotencyRepository_InsertOnConflictReturning_Call) Return(_a0 *idempotency.Idempotency, _a1 error) *IdempotencyRepository_InsertOnConflictReturning_Call { +func (_c *IdempotencyRepository_InsertOnConflictReturning_Call) Return(_a0 *notification.Idempotency, _a1 error) *IdempotencyRepository_InsertOnConflictReturning_Call { _c.Call.Return(_a0, _a1) return _c } @@ -128,9 +128,9 @@ type IdempotencyRepository_UpdateSuccess_Call struct { } // UpdateSuccess is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 uint64 -// - _a2 bool +// - _a0 context.Context +// - _a1 uint64 +// - _a2 bool func (_e *IdempotencyRepository_Expecter) UpdateSuccess(_a0 interface{}, _a1 interface{}, _a2 interface{}) *IdempotencyRepository_UpdateSuccess_Call { return &IdempotencyRepository_UpdateSuccess_Call{Call: _e.mock.On("UpdateSuccess", _a0, _a1, _a2)} } diff --git a/core/notification/mocks/log_service.go b/core/notification/mocks/log_service.go new file mode 100644 index 00000000..683460e8 --- /dev/null +++ b/core/notification/mocks/log_service.go @@ -0,0 +1,90 @@ +// Code generated by mockery v2.16.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + log "github.com/odpf/siren/core/log" + mock "github.com/stretchr/testify/mock" +) + +// LogService is an autogenerated mock type for the LogService type +type LogService struct { + mock.Mock +} + +type LogService_Expecter struct { + mock *mock.Mock +} + +func (_m *LogService) EXPECT() *LogService_Expecter { + return &LogService_Expecter{mock: &_m.Mock} +} + +// LogNotifications provides a mock function with given fields: ctx, nlogs +func (_m *LogService) LogNotifications(ctx context.Context, nlogs ...log.Notification) error { + _va := make([]interface{}, len(nlogs)) + for _i := range nlogs { + _va[_i] = nlogs[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, ...log.Notification) error); ok { + r0 = rf(ctx, nlogs...) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// LogService_LogNotifications_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogNotifications' +type LogService_LogNotifications_Call struct { + *mock.Call +} + +// LogNotifications is a helper method to define mock.On call +// - ctx context.Context +// - nlogs ...log.Notification +func (_e *LogService_Expecter) LogNotifications(ctx interface{}, nlogs ...interface{}) *LogService_LogNotifications_Call { + return &LogService_LogNotifications_Call{Call: _e.mock.On("LogNotifications", + append([]interface{}{ctx}, nlogs...)...)} +} + +func (_c *LogService_LogNotifications_Call) Run(run func(ctx context.Context, nlogs ...log.Notification)) *LogService_LogNotifications_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]log.Notification, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(log.Notification) + } + } + run(args[0].(context.Context), variadicArgs...) + }) + return _c +} + +func (_c *LogService_LogNotifications_Call) Return(_a0 error) *LogService_LogNotifications_Call { + _c.Call.Return(_a0) + return _c +} + +type mockConstructorTestingTNewLogService interface { + mock.TestingT + Cleanup(func()) +} + +// NewLogService creates a new instance of LogService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewLogService(t mockConstructorTestingTNewLogService) *LogService { + mock := &LogService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/notification/mocks/repository.go b/core/notification/mocks/repository.go new file mode 100644 index 00000000..b0097f02 --- /dev/null +++ b/core/notification/mocks/repository.go @@ -0,0 +1,83 @@ +// Code generated by mockery v2.16.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + notification "github.com/odpf/siren/core/notification" + mock "github.com/stretchr/testify/mock" +) + +// Repository is an autogenerated mock type for the Repository type +type Repository struct { + mock.Mock +} + +type Repository_Expecter struct { + mock *mock.Mock +} + +func (_m *Repository) EXPECT() *Repository_Expecter { + return &Repository_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function with given fields: _a0, _a1 +func (_m *Repository) Create(_a0 context.Context, _a1 notification.Notification) (notification.Notification, error) { + ret := _m.Called(_a0, _a1) + + var r0 notification.Notification + if rf, ok := ret.Get(0).(func(context.Context, notification.Notification) notification.Notification); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Get(0).(notification.Notification) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, notification.Notification) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Repository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type Repository_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 notification.Notification +func (_e *Repository_Expecter) Create(_a0 interface{}, _a1 interface{}) *Repository_Create_Call { + return &Repository_Create_Call{Call: _e.mock.On("Create", _a0, _a1)} +} + +func (_c *Repository_Create_Call) Run(run func(_a0 context.Context, _a1 notification.Notification)) *Repository_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(notification.Notification)) + }) + return _c +} + +func (_c *Repository_Create_Call) Return(_a0 notification.Notification, _a1 error) *Repository_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +type mockConstructorTestingTNewRepository interface { + mock.TestingT + Cleanup(func()) +} + +// NewRepository creates a new instance of Repository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewRepository(t mockConstructorTestingTNewRepository) *Repository { + mock := &Repository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/notification/mocks/silence_service.go b/core/notification/mocks/silence_service.go new file mode 100644 index 00000000..29d0338a --- /dev/null +++ b/core/notification/mocks/silence_service.go @@ -0,0 +1,86 @@ +// Code generated by mockery v2.16.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + silence "github.com/odpf/siren/core/silence" +) + +// SilenceService is an autogenerated mock type for the SilenceService type +type SilenceService struct { + mock.Mock +} + +type SilenceService_Expecter struct { + mock *mock.Mock +} + +func (_m *SilenceService) EXPECT() *SilenceService_Expecter { + return &SilenceService_Expecter{mock: &_m.Mock} +} + +// List provides a mock function with given fields: ctx, filter +func (_m *SilenceService) List(ctx context.Context, filter silence.Filter) ([]silence.Silence, error) { + ret := _m.Called(ctx, filter) + + var r0 []silence.Silence + if rf, ok := ret.Get(0).(func(context.Context, silence.Filter) []silence.Silence); ok { + r0 = rf(ctx, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]silence.Silence) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, silence.Filter) error); ok { + r1 = rf(ctx, filter) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SilenceService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type SilenceService_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - ctx context.Context +// - filter silence.Filter +func (_e *SilenceService_Expecter) List(ctx interface{}, filter interface{}) *SilenceService_List_Call { + return &SilenceService_List_Call{Call: _e.mock.On("List", ctx, filter)} +} + +func (_c *SilenceService_List_Call) Run(run func(ctx context.Context, filter silence.Filter)) *SilenceService_List_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(silence.Filter)) + }) + return _c +} + +func (_c *SilenceService_List_Call) Return(_a0 []silence.Silence, _a1 error) *SilenceService_List_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +type mockConstructorTestingTNewSilenceService interface { + mock.TestingT + Cleanup(func()) +} + +// NewSilenceService creates a new instance of SilenceService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewSilenceService(t mockConstructorTestingTNewSilenceService) *SilenceService { + mock := &SilenceService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/notification/notification.go b/core/notification/notification.go index aad7489f..8c15532f 100644 --- a/core/notification/notification.go +++ b/core/notification/notification.go @@ -1,42 +1,71 @@ package notification import ( + "context" + "strconv" "time" "github.com/odpf/siren/pkg/errors" ) +const ( + ReceiverIDLabelKey string = "receiver_id" + ValidDurationRequestKey string = "valid_duration" + + TypeReceiver string = "receiver" + TypeSubscriber string = "subscriber" +) + +//go:generate mockery --name=Repository -r --case underscore --with-expecter --structname Repository --filename repository.go --output=./mocks +type Repository interface { + Create(context.Context, Notification) (Notification, error) +} + // Notification is a model of notification +// if type is `receiver`, it is expected for the labels to have +// receiver_id = int type Notification struct { - ID string `json:"id"` - Data map[string]interface{} `json:"data"` - Labels map[string]string `json:"labels"` - ValidDurationString string `json:"valid_duration"` - Template string `json:"template"` - CreatedAt time.Time + ID string `json:"id"` + NamespaceID uint64 `json:"namespace_id"` + Type string `json:"type"` + Data map[string]interface{} `json:"data"` + Labels map[string]string `json:"labels"` + ValidDuration time.Duration `json:"valid_duration"` + Template string `json:"template"` + CreatedAt time.Time `json:"created_at"` + + // won't be stored in notification table, only to propaget this to notification_subscriber + AlertIDs []int64 } -// ToMessage transforms Notification model to one or several Messages -func (n Notification) ToMessage(receiverType string, notificationConfigMap map[string]interface{}) (*Message, error) { - var ( - expiryDuration time.Duration - err error - ) - - if n.ValidDurationString != "" { - expiryDuration, err = time.ParseDuration(n.ValidDurationString) - if err != nil { - return nil, errors.ErrInvalid.WithMsgf(err.Error()) - } +func (n *Notification) EnrichID(id string) { + if n == nil { + return } + n.ID = id - nm := &Message{} - nm.Initialize( - n, - receiverType, - notificationConfigMap, - InitWithExpiryDuration(expiryDuration), - ) + if len(n.Data) == 0 { + n.Data = map[string]interface{}{} + } + + n.Data["id"] = id +} + +func (n Notification) Validate() error { + if n.Type == TypeReceiver { + if v, ok := n.Labels[ReceiverIDLabelKey]; ok { + intVar, err := strconv.ParseInt(v, 0, 64) + if err == nil && intVar != 0 { + return nil + } + } + return errors.ErrInvalid.WithCausef("notification type receiver should have valid receiver_id: %v", n) + } else if n.Type == TypeSubscriber { + if len(n.Labels) != 0 { + return nil + } + return errors.ErrInvalid.WithCausef("notification type subscriber should have labels: %v", n) + } - return nm, nil + return errors.ErrInvalid.WithCausef("invalid notification type: %v", n) } diff --git a/core/notification/notification_test.go b/core/notification/notification_test.go index 9b34a3d4..1c34f8aa 100644 --- a/core/notification/notification_test.go +++ b/core/notification/notification_test.go @@ -3,30 +3,28 @@ package notification_test import ( "testing" - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" "github.com/odpf/siren/core/notification" ) -func TestNotification_ToMessage(t *testing.T) { +func TestNotification_Validate(t *testing.T) { testCases := []struct { name string n notification.Notification receiverType string notificationConfigs map[string]interface{} - want *notification.Message wantErr bool }{ { - name: "should return error if expiry duration is not parsable", + name: "should return error if type is unknown", n: notification.Notification{ - ValidDurationString: "xxx", + Type: "random", }, wantErr: true, }, { - name: "should return message if expiry duration is empty", + name: "should return error if type receiver but has no 'receiver_id' key label", n: notification.Notification{ + Type: notification.TypeReceiver, Labels: map[string]string{ "labelkey1": "value1", }, @@ -34,55 +32,76 @@ func TestNotification_ToMessage(t *testing.T) { "varkey1": "value1", }, }, - want: ¬ification.Message{ - Status: notification.MessageStatusEnqueued, - Details: map[string]interface{}{ - "labelkey1": "value1", - "varkey1": "value1", + wantErr: true, + }, + { + name: "should return error if type receiver but has empty 'receiver_id' label value", + n: notification.Notification{ + Type: notification.TypeReceiver, + Labels: map[string]string{ + "receiver_id": "", + }, + Data: map[string]interface{}{ + "varkey1": "value1", }, }, + wantErr: true, }, { - name: "should return message if expiry duration is parsable", + name: "should return error if type receiver but has 'receiver_id' value is non parsable to integer", n: notification.Notification{ + Type: notification.TypeReceiver, Labels: map[string]string{ - "labelkey1": "value1", + "receiver_id": "xxx", }, Data: map[string]interface{}{ "varkey1": "value1", }, - ValidDurationString: "10m", }, - want: ¬ification.Message{ - Status: notification.MessageStatusEnqueued, - Details: map[string]interface{}{ - "labelkey1": "value1", - "varkey1": "value1", + wantErr: true, + }, + { + name: "should return nil error if type receiver and 'receiver_id' is valid", + n: notification.Notification{ + Type: notification.TypeReceiver, + Labels: map[string]string{ + "receiver_id": "2", + }, + Data: map[string]interface{}{ + "varkey1": "value1", + }, + }, + }, + { + name: "should return error if type subscriber but has no kv labels", + n: notification.Notification{ + Type: notification.TypeSubscriber, + Data: map[string]interface{}{ + "varkey1": "value1", + }, + }, + wantErr: true, + }, + { + name: "should return nil error if type subscriber and has kv labels", + n: notification.Notification{ + Type: notification.TypeSubscriber, + Labels: map[string]string{ + "receiver_id": "xxx", + }, + Data: map[string]interface{}{ + "varkey1": "value1", }, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - got, err := tc.n.ToMessage(tc.receiverType, tc.notificationConfigs) + err := tc.n.Validate() if (err != nil) != tc.wantErr { t.Errorf("Notification.ToMessage() error = %v, wantErr %v", err, tc.wantErr) return } - - if diff := cmp.Diff(got, tc.want, - cmpopts.IgnoreUnexported(notification.Message{}), - cmpopts.IgnoreFields( - notification.Message{}, - "ID", - "MaxTries", - "ExpiredAt", - "CreatedAt", - "UpdatedAt", - ), - ); diff != "" { - t.Errorf("Notification.ToMessage() diff = %v", diff) - } }) } } diff --git a/core/notification/routing.go b/core/notification/routing.go deleted file mode 100644 index b62f9054..00000000 --- a/core/notification/routing.go +++ /dev/null @@ -1,12 +0,0 @@ -package notification - -type RoutingMethod string - -const ( - RoutingMethodReceiver RoutingMethod = "receiver" - RoutingMethodSubscribers RoutingMethod = "subscribers" -) - -func (rm RoutingMethod) String() string { - return string(rm) -} diff --git a/core/notification/service.go b/core/notification/service.go index 6374e713..5ee5d8e0 100644 --- a/core/notification/service.go +++ b/core/notification/service.go @@ -2,26 +2,23 @@ package notification import ( "context" + "fmt" "time" - "github.com/odpf/salt/log" - "go.opencensus.io/tag" + saltlog "github.com/odpf/salt/log" "go.opencensus.io/trace" - "gopkg.in/yaml.v3" - "github.com/odpf/siren/core/idempotency" + "github.com/odpf/siren/core/log" "github.com/odpf/siren/core/receiver" + "github.com/odpf/siren/core/silence" "github.com/odpf/siren/core/subscription" - "github.com/odpf/siren/core/template" "github.com/odpf/siren/pkg/errors" "github.com/odpf/siren/pkg/telemetry" ) -//go:generate mockery --name=IdempotencyRepository -r --case underscore --with-expecter --structname IdempotencyRepository --filename idempotency_repository.go --output=./mocks -type IdempotencyRepository interface { - InsertOnConflictReturning(context.Context, string, string) (*idempotency.Idempotency, error) - UpdateSuccess(context.Context, uint64, bool) error - Delete(context.Context, idempotency.Filter) error +//go:generate mockery --name=Dispatcher -r --case underscore --with-expecter --structname Dispatcher --filename dispatcher.go --output=./mocks +type Dispatcher interface { + PrepareMessage(ctx context.Context, n Notification) ([]Message, []log.Notification, bool, error) } //go:generate mockery --name=SubscriptionService -r --case underscore --with-expecter --structname SubscriptionService --filename subscription_service.go --output=./mocks @@ -34,33 +31,82 @@ type ReceiverService interface { Get(ctx context.Context, id uint64, gopts ...receiver.GetOption) (*receiver.Receiver, error) } +//go:generate mockery --name=SilenceService -r --case underscore --with-expecter --structname SilenceService --filename silence_service.go --output=./mocks +type SilenceService interface { + List(ctx context.Context, filter silence.Filter) ([]silence.Silence, error) +} + +//go:generate mockery --name=AlertService -r --case underscore --with-expecter --structname AlertService --filename alert_service.go --output=./mocks +type AlertService interface { + UpdateSilenceStatus(ctx context.Context, alertIDs []int64, hasSilenced bool, hasNonSilenced bool) error +} + +//go:generate mockery --name=LogService -r --case underscore --with-expecter --structname LogService --filename log_service.go --output=./mocks +type LogService interface { + LogNotifications(ctx context.Context, nlogs ...log.Notification) error +} + // Service is a service for notification domain type Service struct { - logger log.Logger + logger saltlog.Logger q Queuer idempotencyRepository IdempotencyRepository + logService LogService + repository Repository receiverService ReceiverService subscriptionService SubscriptionService + silenceService SilenceService + alertService AlertService notifierPlugins map[string]Notifier + dispatcher map[string]Dispatcher messagingTracer *telemetry.MessagingTracer } +type Deps struct { + IdempotencyRepository IdempotencyRepository + LogService LogService + ReceiverService ReceiverService + SubscriptionService SubscriptionService + SilenceService SilenceService + AlertService AlertService + DispatchReceiverService Dispatcher + DispatchSubscriberService Dispatcher +} + // NewService creates a new notification service func NewService( - logger log.Logger, + logger saltlog.Logger, + repository Repository, q Queuer, - idempotencyRepository IdempotencyRepository, - receiverService ReceiverService, - subscriptionService SubscriptionService, notifierPlugins map[string]Notifier, + deps Deps, ) *Service { + var ( + dispatchReceiverService = deps.DispatchReceiverService + dispatchSubscriberService = deps.DispatchSubscriberService + ) + if deps.DispatchReceiverService == nil { + dispatchReceiverService = NewDispatchReceiverService(deps.ReceiverService, notifierPlugins) + } + if deps.DispatchSubscriberService == nil { + dispatchSubscriberService = NewDispatchSubscriberService(logger, deps.SubscriptionService, deps.SilenceService, notifierPlugins) + } + ns := &Service{ logger: logger, q: q, - idempotencyRepository: idempotencyRepository, - receiverService: receiverService, - subscriptionService: subscriptionService, - notifierPlugins: notifierPlugins, + repository: repository, + idempotencyRepository: deps.IdempotencyRepository, + logService: deps.LogService, + receiverService: deps.ReceiverService, + subscriptionService: deps.SubscriptionService, + silenceService: deps.SilenceService, + alertService: deps.AlertService, + dispatcher: map[string]Dispatcher{ + TypeReceiver: dispatchReceiverService, + TypeSubscriber: dispatchSubscriberService, + }, + notifierPlugins: notifierPlugins, } ns.messagingTracer = telemetry.NewMessagingTracer("default") @@ -71,148 +117,60 @@ func NewService( return ns } -func (ns *Service) getNotifierPlugin(receiverType string) (Notifier, error) { - notifierPlugin, exist := ns.notifierPlugins[receiverType] +func (s *Service) getDispatcherService(notificationType string) (Dispatcher, error) { + selectedDispatcher, exist := s.dispatcher[notificationType] if !exist { - return nil, errors.ErrInvalid.WithMsgf("unsupported receiver type: %q", receiverType) + return nil, errors.ErrInvalid.WithMsgf("unsupported notification type: %q", notificationType) } - return notifierPlugin, nil + return selectedDispatcher, nil } -func (ns *Service) DispatchToReceiver(ctx context.Context, n Notification, receiverID uint64) error { - rcv, err := ns.receiverService.Get(ctx, receiverID, receiver.GetWithData(false)) - if err != nil { +func (s *Service) Dispatch(ctx context.Context, n Notification) error { + if err := n.Validate(); err != nil { return err } - ctx, span := ns.messagingTracer.StartSpan(ctx, "prepare_enqueue", - trace.StringAttribute("messaging.notification_id", n.ID), - trace.StringAttribute("messaging.routing_method", RoutingMethodReceiver.String()), - ) - defer span.End() - - notifierPlugin, err := ns.getNotifierPlugin(rcv.Type) - if err != nil { - return errors.ErrInvalid.WithMsgf("invalid receiver type: %s", err.Error()) - } - - message, err := n.ToMessage(rcv.Type, rcv.Configurations) + no, err := s.repository.Create(ctx, n) if err != nil { return err } - newConfigs, err := notifierPlugin.PreHookQueueTransformConfigs(ctx, message.Configs) - if err != nil { - telemetry.IncrementInt64Counter(ctx, telemetry.MetricReceiverHookFailed, - tag.Upsert(telemetry.TagRoutingMethod, RoutingMethodReceiver.String()), - tag.Upsert(telemetry.TagReceiverType, message.ReceiverType), - tag.Upsert(telemetry.TagHookCondition, telemetry.HookConditionPreHookQueue), - ) + n.EnrichID(no.ID) + dispatcherService, err := s.getDispatcherService(n.Type) + if err != nil { return err } - message.Configs = newConfigs - - message.AddStringDetail(DetailsKeyRoutingMethod, RoutingMethodReceiver.String()) - - span.End() - telemetry.IncrementInt64Counter(ctx, telemetry.MetricNotificationMessageCounter, - tag.Upsert(telemetry.TagRoutingMethod, RoutingMethodReceiver.String()), - tag.Upsert(telemetry.TagMessageStatus, message.Status.String()), - tag.Upsert(telemetry.TagReceiverType, message.ReceiverType), + ctx, span := s.messagingTracer.StartSpan(ctx, "prepare_message", + trace.StringAttribute("messaging.notification_id", n.ID), + trace.StringAttribute("messaging.routing_method", n.Type), ) - - // supported no templating for now - if err := ns.q.Enqueue(ctx, *message); err != nil { - return err - } - - return nil -} - -func (ns *Service) DispatchToSubscribers(ctx context.Context, namespaceID uint64, n Notification) error { - subscriptions, err := ns.subscriptionService.MatchByLabels(ctx, namespaceID, n.Labels) + messages, notificationLogs, hasSilenced, err := dispatcherService.PrepareMessage(ctx, n) + span.End() if err != nil { return err } - if len(subscriptions) == 0 { - telemetry.IncrementInt64Counter(ctx, telemetry.MetricNotificationSubscriberNotFound) - return errors.ErrInvalid.WithMsgf("not matching any subscription") + if len(messages) == 0 && len(notificationLogs) == 0 { + return fmt.Errorf("something wrong and no messages will be sent with notification: %v", n) } - ctx, span := ns.messagingTracer.StartSpan(ctx, "prepare_enqueue", - trace.StringAttribute("messaging.notification_id", n.ID), - trace.StringAttribute("messaging.routing_method", RoutingMethodSubscribers.String()), - ) - defer span.End() - - var messages = make([]Message, 0) - - for _, s := range subscriptions { - for _, rcv := range s.Receivers { - - notifierPlugin, err := ns.getNotifierPlugin(rcv.Type) - if err != nil { - return err - } - - message, err := n.ToMessage(rcv.Type, rcv.Configuration) - if err != nil { - return err - } - - newConfigs, err := notifierPlugin.PreHookQueueTransformConfigs(ctx, message.Configs) - if err != nil { - telemetry.IncrementInt64Counter(ctx, telemetry.MetricReceiverHookFailed, - tag.Upsert(telemetry.TagReceiverType, message.ReceiverType), - tag.Upsert(telemetry.TagRoutingMethod, RoutingMethodSubscribers.String()), - tag.Upsert(telemetry.TagHookCondition, telemetry.HookConditionPreHookQueue), - ) - - return err - } - message.Configs = newConfigs - - message.AddStringDetail(DetailsKeyRoutingMethod, RoutingMethodSubscribers.String()) - - //TODO fetch template if any, if not exist, check provider type, if exist use the default template, if not pass as-is - // if there is template, render and replace detail with the new one - if n.Template != "" { - var templateBody string - - if template.IsReservedName(n.Template) { - templateBody = notifierPlugin.GetSystemDefaultTemplate() - } - - if templateBody != "" { - renderedDetailString, err := template.RenderBody(templateBody, n) - if err != nil { - return errors.ErrInvalid.WithMsgf("failed to render template receiver %s: %s", rcv.Type, err.Error()) - } - - var messageDetails map[string]interface{} - if err := yaml.Unmarshal([]byte(renderedDetailString), &messageDetails); err != nil { - return errors.ErrInvalid.WithMsgf("failed to unmarshal rendered template receiver %s: %s, rendered template: %v", rcv.Type, err.Error(), renderedDetailString) - } - message.Details = messageDetails - } - } - - telemetry.IncrementInt64Counter(ctx, telemetry.MetricNotificationMessageCounter, - tag.Upsert(telemetry.TagRoutingMethod, RoutingMethodSubscribers.String()), - tag.Upsert(telemetry.TagMessageStatus, message.Status.String()), - tag.Upsert(telemetry.TagReceiverType, message.ReceiverType)) + if err := s.logService.LogNotifications(ctx, notificationLogs...); err != nil { + return fmt.Errorf("failed logging notifications: %w", err) + } - messages = append(messages, *message) - } + if err := s.alertService.UpdateSilenceStatus(ctx, n.AlertIDs, hasSilenced, len(messages) != 0); err != nil { + return fmt.Errorf("failed updating silence status: %w", err) } - span.End() + if len(messages) == 0 { + s.logger.Info("no messages to enqueue") + return nil + } - if err := ns.q.Enqueue(ctx, messages...); err != nil { - return err + if err := s.q.Enqueue(ctx, messages...); err != nil { + return fmt.Errorf("failed enqueuing messages: %w", err) } return nil @@ -236,7 +194,7 @@ func (s *Service) MarkIdempotencyAsSuccess(ctx context.Context, id uint64) error } func (s *Service) RemoveIdempotencies(ctx context.Context, TTL time.Duration) error { - return s.idempotencyRepository.Delete(ctx, idempotency.Filter{ + return s.idempotencyRepository.Delete(ctx, IdempotencyFilter{ TTL: TTL, }) } diff --git a/core/notification/service_test.go b/core/notification/service_test.go index 32b60647..51b546ee 100644 --- a/core/notification/service_test.go +++ b/core/notification/service_test.go @@ -4,345 +4,228 @@ import ( "context" "testing" - "github.com/odpf/salt/log" - "github.com/stretchr/testify/mock" - - "github.com/odpf/siren/core/idempotency" + saltlog "github.com/odpf/salt/log" + "github.com/odpf/siren/core/log" "github.com/odpf/siren/core/notification" "github.com/odpf/siren/core/notification/mocks" - "github.com/odpf/siren/core/receiver" - "github.com/odpf/siren/core/subscription" "github.com/odpf/siren/pkg/errors" + "github.com/odpf/siren/plugins/queues" + "github.com/stretchr/testify/mock" ) const testPluginType = "test" -func TestService_DispatchToReceiver(t *testing.T) { +func TestService_CheckAndInsertIdempotency(t *testing.T) { + var ( + scope = "test-scope" + key = "test-key" + ) testCases := []struct { name string - setup func(*mocks.ReceiverService, *mocks.Queuer, *mocks.Notifier) - n notification.Notification + setup func(*mocks.IdempotencyRepository) + scope string + key string wantErr bool }{ { - name: "should return error if failed to transform notification to messages", - setup: func(rs *mocks.ReceiverService, q *mocks.Queuer, _ *mocks.Notifier) { - q.EXPECT().Type().Return("postgresql") - rs.EXPECT().Get(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("receiver.GetOption")).Return(&receiver.Receiver{}, nil) - }, - n: notification.Notification{ - ValidDurationString: "xxx", - }, - wantErr: true, - }, - { - name: "should return error if there is an error when fetching receiver", - setup: func(rs *mocks.ReceiverService, q *mocks.Queuer, _ *mocks.Notifier) { - q.EXPECT().Type().Return("postgresql") - rs.EXPECT().Get(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("receiver.GetOption")).Return(nil, errors.New("some error")) - }, - wantErr: true, - }, - { - name: "should return error if prehook transform config return error", - setup: func(rs *mocks.ReceiverService, q *mocks.Queuer, n *mocks.Notifier) { - q.EXPECT().Type().Return("postgresql") - rs.EXPECT().Get(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("receiver.GetOption")).Return(&receiver.Receiver{ - Type: testPluginType, - Configurations: map[string]interface{}{ - "key": "value", - }, - }, nil) - n.EXPECT().PreHookQueueTransformConfigs(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("map[string]interface {}")).Return(nil, errors.New("invalid config")) + name: "should return error if idempotency exist and success", + setup: func(ir *mocks.IdempotencyRepository) { + ir.EXPECT().InsertOnConflictReturning(mock.AnythingOfType("*context.emptyCtx"), scope, key).Return(nil, errors.ErrConflict) }, + scope: scope, + key: key, wantErr: true, }, { - name: "should return error if enqueue error", - setup: func(rs *mocks.ReceiverService, q *mocks.Queuer, n *mocks.Notifier) { - q.EXPECT().Type().Return("postgresql") - rs.EXPECT().Get(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("receiver.GetOption")).Return(&receiver.Receiver{ - Type: testPluginType, - Configurations: map[string]interface{}{ - "key": "value", - }, - }, nil) - n.EXPECT().PreHookQueueTransformConfigs(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("map[string]interface {}")).Return(map[string]interface{}{ - "key": "value", - }, nil) - q.EXPECT().Enqueue(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Message")).Return(errors.New("some error")) + name: "should return error if repository returning some error", + setup: func(ir *mocks.IdempotencyRepository) { + ir.EXPECT().InsertOnConflictReturning(mock.AnythingOfType("*context.emptyCtx"), scope, key).Return(nil, errors.New("some error")) }, + scope: scope, + key: key, wantErr: true, }, { - name: "should return no error if enqueue success", - setup: func(rs *mocks.ReceiverService, q *mocks.Queuer, n *mocks.Notifier) { - q.EXPECT().Type().Return("postgresql") - rs.EXPECT().Get(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("receiver.GetOption")).Return(&receiver.Receiver{ - Type: testPluginType, - Configurations: map[string]interface{}{ - "key": "value", - }, - }, nil) - n.EXPECT().PreHookQueueTransformConfigs(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("map[string]interface {}")).Return(map[string]interface{}{ - "key": "value", + name: "should return id and nil error if no idempotency exists", + setup: func(ir *mocks.IdempotencyRepository) { + ir.EXPECT().InsertOnConflictReturning(mock.AnythingOfType("*context.emptyCtx"), scope, key).Return(¬ification.Idempotency{ + ID: 1, }, nil) - q.EXPECT().Enqueue(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Message")).Return(nil) }, + scope: scope, + key: key, wantErr: false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - var ( - mockReceiverService = new(mocks.ReceiverService) - mockQueuer = new(mocks.Queuer) - mockNotifier = new(mocks.Notifier) - ) + mockIdempotencyRepository := new(mocks.IdempotencyRepository) if tc.setup != nil { - tc.setup(mockReceiverService, mockQueuer, mockNotifier) + tc.setup(mockIdempotencyRepository) } - ns := notification.NewService( - log.NewNoop(), mockQueuer, nil, mockReceiverService, nil, map[string]notification.Notifier{ - testPluginType: mockNotifier, - }) + ns := notification.NewService(saltlog.NewNoop(), nil, nil, nil, notification.Deps{IdempotencyRepository: mockIdempotencyRepository}) - if err := ns.DispatchToReceiver(context.Background(), tc.n, 1); (err != nil) != tc.wantErr { - t.Errorf("NotificationService.Dispatch() error = %v, wantErr %v", err, tc.wantErr) + _, err := ns.CheckAndInsertIdempotency(context.Background(), tc.scope, tc.key) + + if (err != nil) != tc.wantErr { + t.Errorf("NotificationService.CheckAndInsertIdempotency() error = %v, wantErr %v", err, tc.wantErr) } - mockReceiverService.AssertExpectations(t) - mockQueuer.AssertExpectations(t) - mockNotifier.AssertExpectations(t) + mockIdempotencyRepository.AssertExpectations(t) }) } } -func TestService_DispatchToSubscribers(t *testing.T) { - testCases := []struct { +func TestService_Dispatch(t *testing.T) { + tests := []struct { name string - setup func(*mocks.SubscriptionService, *mocks.Queuer, *mocks.Notifier) n notification.Notification + setup func(notification.Notification, *mocks.Repository, *mocks.LogService, *mocks.AlertService, *mocks.Queuer, *mocks.Dispatcher) wantErr bool }{ { - name: "should return error if there is an error when matching labels", - setup: func(ss *mocks.SubscriptionService, q *mocks.Queuer, _ *mocks.Notifier) { - q.EXPECT().Type().Return("postgresql") - ss.EXPECT().MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")).Return(nil, errors.New("some error")) - }, + name: "should return error if notification type is unknown", + n: notification.Notification{}, wantErr: true, }, { - name: "should return error if receiver type of a receiver is unknown", - setup: func(ss *mocks.SubscriptionService, q *mocks.Queuer, _ *mocks.Notifier) { - q.EXPECT().Type().Return("postgresql") - ss.EXPECT(). - MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")). - Return([]subscription.Subscription{ - { - Receivers: []subscription.Receiver{ - { - Type: "random", - }, - }, - }, - }, nil) + name: "should return error if repository return error", + n: notification.Notification{ + Type: notification.TypeSubscriber, + Labels: map[string]string{ + "k1": "v1", + }, }, - wantErr: true, - }, - { - name: "should return error if there is no matching subscription", - setup: func(ss *mocks.SubscriptionService, q *mocks.Queuer, _ *mocks.Notifier) { - q.EXPECT().Type().Return("postgresql") - ss.EXPECT().MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")).Return(nil, nil) + setup: func(n notification.Notification, r *mocks.Repository, _ *mocks.LogService, _ *mocks.AlertService, _ *mocks.Queuer, _ *mocks.Dispatcher) { + r.EXPECT().Create(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("notification.Notification")).Return(notification.Notification{}, errors.New("some error")) }, wantErr: true, }, { - name: "should return error if failed to transform notification to messages", - setup: func(ss *mocks.SubscriptionService, q *mocks.Queuer, _ *mocks.Notifier) { - q.EXPECT().Type().Return("postgresql") - ss.EXPECT(). - MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")). - Return([]subscription.Subscription{ - { - Receivers: []subscription.Receiver{ - { - Type: testPluginType, - }, - }, - }, - }, nil) - }, + name: "should return error if dispatcher service return error", n: notification.Notification{ - ValidDurationString: "xxx", + Type: notification.TypeSubscriber, + Labels: map[string]string{ + "k1": "v1", + }, + }, + setup: func(n notification.Notification, r *mocks.Repository, _ *mocks.LogService, _ *mocks.AlertService, _ *mocks.Queuer, d *mocks.Dispatcher) { + r.EXPECT().Create(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("notification.Notification")).Return(notification.Notification{}, nil) + d.EXPECT().PrepareMessage(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification")).Return(nil, nil, false, errors.New("some error")) }, wantErr: true, }, { - name: "should return error if receiver config is invalid", - setup: func(ss *mocks.SubscriptionService, q *mocks.Queuer, n *mocks.Notifier) { - q.EXPECT().Type().Return("postgresql") - ss.EXPECT(). - MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")). - Return([]subscription.Subscription{ - { - Receivers: []subscription.Receiver{ - { - Type: testPluginType, - Configuration: map[string]interface{}{ - "key": "value", - }, - }, - }, - }, - }, nil) - n.EXPECT().PreHookQueueTransformConfigs(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("map[string]interface {}")).Return(nil, errors.New("invalid config")) + name: "should return error if dispatcher service return empty results", + n: notification.Notification{ + Type: notification.TypeSubscriber, + Labels: map[string]string{ + "k1": "v1", + }, + }, + setup: func(n notification.Notification, r *mocks.Repository, _ *mocks.LogService, _ *mocks.AlertService, _ *mocks.Queuer, d *mocks.Dispatcher) { + r.EXPECT().Create(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("notification.Notification")).Return(notification.Notification{}, nil) + d.EXPECT().PrepareMessage(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification")).Return(nil, nil, false, nil) }, wantErr: true, }, { - name: "should return error if enqueue error", - setup: func(ss *mocks.SubscriptionService, q *mocks.Queuer, n *mocks.Notifier) { - q.EXPECT().Type().Return("postgresql") - ss.EXPECT(). - MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")). - Return([]subscription.Subscription{ - { - Receivers: []subscription.Receiver{ - { - Type: testPluginType, - Configuration: map[string]interface{}{ - "key": "value", - }, - }, - }, - }, - }, nil) - n.EXPECT().PreHookQueueTransformConfigs(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("map[string]interface {}")).Return(map[string]interface{}{ - "key": "value", - }, nil) - q.EXPECT().Enqueue(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Message")).Return(errors.New("some error")) + name: "should return error if log notifications return error", + n: notification.Notification{ + Type: notification.TypeSubscriber, + Labels: map[string]string{ + "k1": "v1", + }, + }, + setup: func(n notification.Notification, r *mocks.Repository, l *mocks.LogService, _ *mocks.AlertService, _ *mocks.Queuer, d *mocks.Dispatcher) { + r.EXPECT().Create(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("notification.Notification")).Return(notification.Notification{}, nil) + d.EXPECT().PrepareMessage(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification")).Return([]notification.Message{{ID: "123"}}, []log.Notification{{ReceiverID: 123}}, false, nil) + l.EXPECT().LogNotifications(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("log.Notification")).Return(errors.New("some error")) }, wantErr: true, }, { - name: "should return no error if enqueue success", - setup: func(ss *mocks.SubscriptionService, q *mocks.Queuer, n *mocks.Notifier) { - q.EXPECT().Type().Return("postgresql") - ss.EXPECT(). - MatchByLabels(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("map[string]string")). - Return([]subscription.Subscription{ - { - Receivers: []subscription.Receiver{ - { - Type: testPluginType, - Configuration: map[string]interface{}{ - "key": "value", - }, - }, - }, - }, - }, nil) - n.EXPECT().PreHookQueueTransformConfigs(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("map[string]interface {}")).Return(map[string]interface{}{ - "key": "value", - }, nil) - q.EXPECT().Enqueue(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Message")).Return(nil) + name: "should return error if update alerts silence status return error", + n: notification.Notification{ + Type: notification.TypeSubscriber, + Labels: map[string]string{ + "k1": "v1", + }, }, - wantErr: false, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - var ( - mockSubscriptionService = new(mocks.SubscriptionService) - mockQueuer = new(mocks.Queuer) - mockNotifier = new(mocks.Notifier) - ) - - if tc.setup != nil { - tc.setup(mockSubscriptionService, mockQueuer, mockNotifier) - } - - ns := notification.NewService( - log.NewNoop(), mockQueuer, nil, nil, mockSubscriptionService, map[string]notification.Notifier{ - testPluginType: mockNotifier, - }) - - if err := ns.DispatchToSubscribers(context.Background(), 1, tc.n); (err != nil) != tc.wantErr { - t.Errorf("NotificationService.Dispatch() error = %v, wantErr %v", err, tc.wantErr) - } - - mockSubscriptionService.AssertExpectations(t) - mockQueuer.AssertExpectations(t) - mockNotifier.AssertExpectations(t) - }) - } -} - -func TestService_CheckAndInsertIdempotency(t *testing.T) { - var ( - scope = "test-scope" - key = "test-key" - ) - testCases := []struct { - name string - setup func(*mocks.IdempotencyRepository) - scope string - key string - wantErr bool - }{ - { - name: "should return error if idempotency exist and success", - setup: func(ir *mocks.IdempotencyRepository) { - ir.EXPECT().InsertOnConflictReturning(mock.AnythingOfType("*context.emptyCtx"), scope, key).Return(nil, errors.ErrConflict) + setup: func(n notification.Notification, r *mocks.Repository, l *mocks.LogService, a *mocks.AlertService, _ *mocks.Queuer, d *mocks.Dispatcher) { + r.EXPECT().Create(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("notification.Notification")).Return(notification.Notification{}, nil) + d.EXPECT().PrepareMessage(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification")).Return([]notification.Message{{ID: "123"}}, []log.Notification{{ReceiverID: 123}}, false, nil) + l.EXPECT().LogNotifications(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("log.Notification")).Return(nil) + a.EXPECT().UpdateSilenceStatus(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("[]int64"), mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(errors.New("some error")) }, - scope: scope, - key: key, wantErr: true, }, { - name: "should return error if repository returning some error", - setup: func(ir *mocks.IdempotencyRepository) { - ir.EXPECT().InsertOnConflictReturning(mock.AnythingOfType("*context.emptyCtx"), scope, key).Return(nil, errors.New("some error")) + name: "should return error if enqueue return error", + n: notification.Notification{ + Type: notification.TypeSubscriber, + Labels: map[string]string{ + "k1": "v1", + }, + }, + setup: func(n notification.Notification, r *mocks.Repository, l *mocks.LogService, a *mocks.AlertService, q *mocks.Queuer, d *mocks.Dispatcher) { + r.EXPECT().Create(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("notification.Notification")).Return(notification.Notification{}, nil) + d.EXPECT().PrepareMessage(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification")).Return([]notification.Message{{ID: "123"}}, []log.Notification{{ReceiverID: 123}}, false, nil) + l.EXPECT().LogNotifications(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("log.Notification")).Return(nil) + a.EXPECT().UpdateSilenceStatus(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("[]int64"), mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(nil) + q.EXPECT().Enqueue(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Message")).Return(errors.New("some error")) }, - scope: scope, - key: key, wantErr: true, }, { - name: "should return id and nil error if no idempotency exists", - setup: func(ir *mocks.IdempotencyRepository) { - ir.EXPECT().InsertOnConflictReturning(mock.AnythingOfType("*context.emptyCtx"), scope, key).Return(&idempotency.Idempotency{ - ID: 1, - }, nil) + name: "should return no error if enqueue success", + n: notification.Notification{ + Type: notification.TypeSubscriber, + Labels: map[string]string{ + "k1": "v1", + }, + }, + setup: func(n notification.Notification, r *mocks.Repository, l *mocks.LogService, a *mocks.AlertService, q *mocks.Queuer, d *mocks.Dispatcher) { + r.EXPECT().Create(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("notification.Notification")).Return(n, nil) + d.EXPECT().PrepareMessage(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification")).Return([]notification.Message{{ID: "123"}}, []log.Notification{{ReceiverID: 123}}, false, nil) + l.EXPECT().LogNotifications(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("log.Notification")).Return(nil) + a.EXPECT().UpdateSilenceStatus(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("[]int64"), mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(nil) + q.EXPECT().Enqueue(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Message")).Return(nil) }, - scope: scope, - key: key, - wantErr: false, }, } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - mockIdempotencyRepository := new(mocks.IdempotencyRepository) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + mockQueuer = new(mocks.Queuer) + mockRepository = new(mocks.Repository) + mockDispatcher = new(mocks.Dispatcher) + mockLogService = new(mocks.LogService) + mockAlertService = new(mocks.AlertService) + ) - if tc.setup != nil { - tc.setup(mockIdempotencyRepository) + if tt.setup != nil { + tt.setup(tt.n, mockRepository, mockLogService, mockAlertService, mockQueuer, mockDispatcher) } - ns := notification.NewService( - log.NewNoop(), nil, mockIdempotencyRepository, nil, nil, nil) - - _, err := ns.CheckAndInsertIdempotency(context.Background(), tc.scope, tc.key) - - if (err != nil) != tc.wantErr { - t.Errorf("NotificationService.CheckAndInsertIdempotency() error = %v, wantErr %v", err, tc.wantErr) + mockQueuer.EXPECT().Type().Return(queues.KindPostgres.String()) + s := notification.NewService( + saltlog.NewNoop(), + mockRepository, + mockQueuer, + nil, + notification.Deps{ + AlertService: mockAlertService, + LogService: mockLogService, + DispatchReceiverService: mockDispatcher, + DispatchSubscriberService: mockDispatcher, + }, + ) + if err := s.Dispatch(context.TODO(), tt.n); (err != nil) != tt.wantErr { + t.Errorf("Service.Dispatch() error = %v, wantErr %v", err, tt.wantErr) } - - mockIdempotencyRepository.AssertExpectations(t) }) } } diff --git a/core/provider/mocks/provider_repository.go b/core/provider/mocks/provider_repository.go index 91694fc5..594fbf72 100644 --- a/core/provider/mocks/provider_repository.go +++ b/core/provider/mocks/provider_repository.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -42,8 +42,8 @@ type ProviderRepository_Create_Call struct { } // Create is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 *provider.Provider +// - _a0 context.Context +// - _a1 *provider.Provider func (_e *ProviderRepository_Expecter) Create(_a0 interface{}, _a1 interface{}) *ProviderRepository_Create_Call { return &ProviderRepository_Create_Call{Call: _e.mock.On("Create", _a0, _a1)} } @@ -80,8 +80,8 @@ type ProviderRepository_Delete_Call struct { } // Delete is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 uint64 +// - _a0 context.Context +// - _a1 uint64 func (_e *ProviderRepository_Expecter) Delete(_a0 interface{}, _a1 interface{}) *ProviderRepository_Delete_Call { return &ProviderRepository_Delete_Call{Call: _e.mock.On("Delete", _a0, _a1)} } @@ -127,8 +127,8 @@ type ProviderRepository_Get_Call struct { } // Get is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 uint64 +// - _a0 context.Context +// - _a1 uint64 func (_e *ProviderRepository_Expecter) Get(_a0 interface{}, _a1 interface{}) *ProviderRepository_Get_Call { return &ProviderRepository_Get_Call{Call: _e.mock.On("Get", _a0, _a1)} } @@ -174,8 +174,8 @@ type ProviderRepository_List_Call struct { } // List is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 provider.Filter +// - _a0 context.Context +// - _a1 provider.Filter func (_e *ProviderRepository_Expecter) List(_a0 interface{}, _a1 interface{}) *ProviderRepository_List_Call { return &ProviderRepository_List_Call{Call: _e.mock.On("List", _a0, _a1)} } @@ -212,8 +212,8 @@ type ProviderRepository_Update_Call struct { } // Update is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 *provider.Provider +// - _a0 context.Context +// - _a1 *provider.Provider func (_e *ProviderRepository_Expecter) Update(_a0 interface{}, _a1 interface{}) *ProviderRepository_Update_Call { return &ProviderRepository_Update_Call{Call: _e.mock.On("Update", _a0, _a1)} } diff --git a/core/receiver/mocks/config_resolver.go b/core/receiver/mocks/config_resolver.go index 504358fb..aa9fbd35 100644 --- a/core/receiver/mocks/config_resolver.go +++ b/core/receiver/mocks/config_resolver.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -50,8 +50,8 @@ type ConfigResolver_BuildData_Call struct { } // BuildData is a helper method to define mock.On call -// - ctx context.Context -// - configs map[string]interface{} +// - ctx context.Context +// - configs map[string]interface{} func (_e *ConfigResolver_Expecter) BuildData(ctx interface{}, configs interface{}) *ConfigResolver_BuildData_Call { return &ConfigResolver_BuildData_Call{Call: _e.mock.On("BuildData", ctx, configs)} } @@ -97,8 +97,8 @@ type ConfigResolver_PostHookDBTransformConfigs_Call struct { } // PostHookDBTransformConfigs is a helper method to define mock.On call -// - ctx context.Context -// - configs map[string]interface{} +// - ctx context.Context +// - configs map[string]interface{} func (_e *ConfigResolver_Expecter) PostHookDBTransformConfigs(ctx interface{}, configs interface{}) *ConfigResolver_PostHookDBTransformConfigs_Call { return &ConfigResolver_PostHookDBTransformConfigs_Call{Call: _e.mock.On("PostHookDBTransformConfigs", ctx, configs)} } @@ -144,8 +144,8 @@ type ConfigResolver_PreHookDBTransformConfigs_Call struct { } // PreHookDBTransformConfigs is a helper method to define mock.On call -// - ctx context.Context -// - configs map[string]interface{} +// - ctx context.Context +// - configs map[string]interface{} func (_e *ConfigResolver_Expecter) PreHookDBTransformConfigs(ctx interface{}, configs interface{}) *ConfigResolver_PreHookDBTransformConfigs_Call { return &ConfigResolver_PreHookDBTransformConfigs_Call{Call: _e.mock.On("PreHookDBTransformConfigs", ctx, configs)} } diff --git a/core/receiver/mocks/encryptor.go b/core/receiver/mocks/encryptor.go index 6659fae9..8bb1be7f 100644 --- a/core/receiver/mocks/encryptor.go +++ b/core/receiver/mocks/encryptor.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -44,7 +44,7 @@ type Encryptor_Decrypt_Call struct { } // Decrypt is a helper method to define mock.On call -// - str string +// - str string func (_e *Encryptor_Expecter) Decrypt(str interface{}) *Encryptor_Decrypt_Call { return &Encryptor_Decrypt_Call{Call: _e.mock.On("Decrypt", str)} } @@ -88,7 +88,7 @@ type Encryptor_Encrypt_Call struct { } // Encrypt is a helper method to define mock.On call -// - str string +// - str string func (_e *Encryptor_Expecter) Encrypt(str interface{}) *Encryptor_Encrypt_Call { return &Encryptor_Encrypt_Call{Call: _e.mock.On("Encrypt", str)} } diff --git a/core/receiver/mocks/receiver_repository.go b/core/receiver/mocks/receiver_repository.go index da1b234d..eb1948c2 100644 --- a/core/receiver/mocks/receiver_repository.go +++ b/core/receiver/mocks/receiver_repository.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -42,8 +42,8 @@ type ReceiverRepository_Create_Call struct { } // Create is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 *receiver.Receiver +// - _a0 context.Context +// - _a1 *receiver.Receiver func (_e *ReceiverRepository_Expecter) Create(_a0 interface{}, _a1 interface{}) *ReceiverRepository_Create_Call { return &ReceiverRepository_Create_Call{Call: _e.mock.On("Create", _a0, _a1)} } @@ -80,8 +80,8 @@ type ReceiverRepository_Delete_Call struct { } // Delete is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 uint64 +// - _a0 context.Context +// - _a1 uint64 func (_e *ReceiverRepository_Expecter) Delete(_a0 interface{}, _a1 interface{}) *ReceiverRepository_Delete_Call { return &ReceiverRepository_Delete_Call{Call: _e.mock.On("Delete", _a0, _a1)} } @@ -127,8 +127,8 @@ type ReceiverRepository_Get_Call struct { } // Get is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 uint64 +// - _a0 context.Context +// - _a1 uint64 func (_e *ReceiverRepository_Expecter) Get(_a0 interface{}, _a1 interface{}) *ReceiverRepository_Get_Call { return &ReceiverRepository_Get_Call{Call: _e.mock.On("Get", _a0, _a1)} } @@ -174,8 +174,8 @@ type ReceiverRepository_List_Call struct { } // List is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 receiver.Filter +// - _a0 context.Context +// - _a1 receiver.Filter func (_e *ReceiverRepository_Expecter) List(_a0 interface{}, _a1 interface{}) *ReceiverRepository_List_Call { return &ReceiverRepository_List_Call{Call: _e.mock.On("List", _a0, _a1)} } @@ -212,8 +212,8 @@ type ReceiverRepository_Update_Call struct { } // Update is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 *receiver.Receiver +// - _a0 context.Context +// - _a1 *receiver.Receiver func (_e *ReceiverRepository_Expecter) Update(_a0 interface{}, _a1 interface{}) *ReceiverRepository_Update_Call { return &ReceiverRepository_Update_Call{Call: _e.mock.On("Update", _a0, _a1)} } diff --git a/core/rule/mocks/namespace_service.go b/core/rule/mocks/namespace_service.go index daa564ea..c86da659 100644 --- a/core/rule/mocks/namespace_service.go +++ b/core/rule/mocks/namespace_service.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -42,8 +42,8 @@ type NamespaceService_Create_Call struct { } // Create is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 *namespace.Namespace +// - _a0 context.Context +// - _a1 *namespace.Namespace func (_e *NamespaceService_Expecter) Create(_a0 interface{}, _a1 interface{}) *NamespaceService_Create_Call { return &NamespaceService_Create_Call{Call: _e.mock.On("Create", _a0, _a1)} } @@ -80,8 +80,8 @@ type NamespaceService_Delete_Call struct { } // Delete is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 uint64 +// - _a0 context.Context +// - _a1 uint64 func (_e *NamespaceService_Expecter) Delete(_a0 interface{}, _a1 interface{}) *NamespaceService_Delete_Call { return &NamespaceService_Delete_Call{Call: _e.mock.On("Delete", _a0, _a1)} } @@ -127,8 +127,8 @@ type NamespaceService_Get_Call struct { } // Get is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 uint64 +// - _a0 context.Context +// - _a1 uint64 func (_e *NamespaceService_Expecter) Get(_a0 interface{}, _a1 interface{}) *NamespaceService_Get_Call { return &NamespaceService_Get_Call{Call: _e.mock.On("Get", _a0, _a1)} } @@ -174,7 +174,7 @@ type NamespaceService_List_Call struct { } // List is a helper method to define mock.On call -// - _a0 context.Context +// - _a0 context.Context func (_e *NamespaceService_Expecter) List(_a0 interface{}) *NamespaceService_List_Call { return &NamespaceService_List_Call{Call: _e.mock.On("List", _a0)} } @@ -211,8 +211,8 @@ type NamespaceService_Update_Call struct { } // Update is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 *namespace.Namespace +// - _a0 context.Context +// - _a1 *namespace.Namespace func (_e *NamespaceService_Expecter) Update(_a0 interface{}, _a1 interface{}) *NamespaceService_Update_Call { return &NamespaceService_Update_Call{Call: _e.mock.On("Update", _a0, _a1)} } diff --git a/core/rule/mocks/rule_repository.go b/core/rule/mocks/rule_repository.go index 0686d0b1..e335bf59 100644 --- a/core/rule/mocks/rule_repository.go +++ b/core/rule/mocks/rule_repository.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -42,7 +42,7 @@ type RuleRepository_Commit_Call struct { } // Commit is a helper method to define mock.On call -// - ctx context.Context +// - ctx context.Context func (_e *RuleRepository_Expecter) Commit(ctx interface{}) *RuleRepository_Commit_Call { return &RuleRepository_Commit_Call{Call: _e.mock.On("Commit", ctx)} } @@ -88,8 +88,8 @@ type RuleRepository_List_Call struct { } // List is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 rule.Filter +// - _a0 context.Context +// - _a1 rule.Filter func (_e *RuleRepository_Expecter) List(_a0 interface{}, _a1 interface{}) *RuleRepository_List_Call { return &RuleRepository_List_Call{Call: _e.mock.On("List", _a0, _a1)} } @@ -126,8 +126,8 @@ type RuleRepository_Rollback_Call struct { } // Rollback is a helper method to define mock.On call -// - ctx context.Context -// - err error +// - ctx context.Context +// - err error func (_e *RuleRepository_Expecter) Rollback(ctx interface{}, err interface{}) *RuleRepository_Rollback_Call { return &RuleRepository_Rollback_Call{Call: _e.mock.On("Rollback", ctx, err)} } @@ -164,8 +164,8 @@ type RuleRepository_Upsert_Call struct { } // Upsert is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 *rule.Rule +// - _a0 context.Context +// - _a1 *rule.Rule func (_e *RuleRepository_Expecter) Upsert(_a0 interface{}, _a1 interface{}) *RuleRepository_Upsert_Call { return &RuleRepository_Upsert_Call{Call: _e.mock.On("Upsert", _a0, _a1)} } @@ -204,7 +204,7 @@ type RuleRepository_WithTransaction_Call struct { } // WithTransaction is a helper method to define mock.On call -// - ctx context.Context +// - ctx context.Context func (_e *RuleRepository_Expecter) WithTransaction(ctx interface{}) *RuleRepository_WithTransaction_Call { return &RuleRepository_WithTransaction_Call{Call: _e.mock.On("WithTransaction", ctx)} } diff --git a/core/rule/mocks/rule_uploader.go b/core/rule/mocks/rule_uploader.go index 3f00dd47..bcfd32e9 100644 --- a/core/rule/mocks/rule_uploader.go +++ b/core/rule/mocks/rule_uploader.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -46,11 +46,11 @@ type RuleUploader_UpsertRule_Call struct { } // UpsertRule is a helper method to define mock.On call -// - ctx context.Context -// - namespaceURN string -// - prov provider.Provider -// - rl *rule.Rule -// - templateToUpdate *template.Template +// - ctx context.Context +// - namespaceURN string +// - prov provider.Provider +// - rl *rule.Rule +// - templateToUpdate *template.Template func (_e *RuleUploader_Expecter) UpsertRule(ctx interface{}, namespaceURN interface{}, prov interface{}, rl interface{}, templateToUpdate interface{}) *RuleUploader_UpsertRule_Call { return &RuleUploader_UpsertRule_Call{Call: _e.mock.On("UpsertRule", ctx, namespaceURN, prov, rl, templateToUpdate)} } diff --git a/core/rule/mocks/template_service.go b/core/rule/mocks/template_service.go index 97ef0f72..cc038cae 100644 --- a/core/rule/mocks/template_service.go +++ b/core/rule/mocks/template_service.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -43,8 +43,8 @@ type TemplateService_Delete_Call struct { } // Delete is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 string +// - _a0 context.Context +// - _a1 string func (_e *TemplateService_Expecter) Delete(_a0 interface{}, _a1 interface{}) *TemplateService_Delete_Call { return &TemplateService_Delete_Call{Call: _e.mock.On("Delete", _a0, _a1)} } @@ -90,8 +90,8 @@ type TemplateService_GetByName_Call struct { } // GetByName is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 string +// - _a0 context.Context +// - _a1 string func (_e *TemplateService_Expecter) GetByName(_a0 interface{}, _a1 interface{}) *TemplateService_GetByName_Call { return &TemplateService_GetByName_Call{Call: _e.mock.On("GetByName", _a0, _a1)} } @@ -137,8 +137,8 @@ type TemplateService_List_Call struct { } // List is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 template.Filter +// - _a0 context.Context +// - _a1 template.Filter func (_e *TemplateService_Expecter) List(_a0 interface{}, _a1 interface{}) *TemplateService_List_Call { return &TemplateService_List_Call{Call: _e.mock.On("List", _a0, _a1)} } @@ -175,8 +175,8 @@ type TemplateService_Upsert_Call struct { } // Upsert is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 *template.Template +// - _a0 context.Context +// - _a1 *template.Template func (_e *TemplateService_Expecter) Upsert(_a0 interface{}, _a1 interface{}) *TemplateService_Upsert_Call { return &TemplateService_Upsert_Call{Call: _e.mock.On("Upsert", _a0, _a1)} } diff --git a/core/silence/filter.go b/core/silence/filter.go new file mode 100644 index 00000000..87bbd9cd --- /dev/null +++ b/core/silence/filter.go @@ -0,0 +1,9 @@ +package silence + +type Filter struct { + ID string + NamespaceID uint64 + SubscriptionID uint64 + Match map[string]string + SubscriptionMatch map[string]string +} diff --git a/core/silence/mocks/subscription_repository.go b/core/silence/mocks/subscription_repository.go new file mode 100644 index 00000000..15534f55 --- /dev/null +++ b/core/silence/mocks/subscription_repository.go @@ -0,0 +1,213 @@ +// Code generated by mockery v2.16.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + silence "github.com/odpf/siren/core/silence" + mock "github.com/stretchr/testify/mock" +) + +// SubscriptionRepository is an autogenerated mock type for the Repository type +type SubscriptionRepository struct { + mock.Mock +} + +type SubscriptionRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *SubscriptionRepository) EXPECT() *SubscriptionRepository_Expecter { + return &SubscriptionRepository_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function with given fields: _a0, _a1 +func (_m *SubscriptionRepository) Create(_a0 context.Context, _a1 silence.Silence) (string, error) { + ret := _m.Called(_a0, _a1) + + var r0 string + if rf, ok := ret.Get(0).(func(context.Context, silence.Silence) string); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, silence.Silence) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SubscriptionRepository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type SubscriptionRepository_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 silence.Silence +func (_e *SubscriptionRepository_Expecter) Create(_a0 interface{}, _a1 interface{}) *SubscriptionRepository_Create_Call { + return &SubscriptionRepository_Create_Call{Call: _e.mock.On("Create", _a0, _a1)} +} + +func (_c *SubscriptionRepository_Create_Call) Run(run func(_a0 context.Context, _a1 silence.Silence)) *SubscriptionRepository_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(silence.Silence)) + }) + return _c +} + +func (_c *SubscriptionRepository_Create_Call) Return(_a0 string, _a1 error) *SubscriptionRepository_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +// Get provides a mock function with given fields: ctx, id +func (_m *SubscriptionRepository) Get(ctx context.Context, id string) (silence.Silence, error) { + ret := _m.Called(ctx, id) + + var r0 silence.Silence + if rf, ok := ret.Get(0).(func(context.Context, string) silence.Silence); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(silence.Silence) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SubscriptionRepository_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type SubscriptionRepository_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *SubscriptionRepository_Expecter) Get(ctx interface{}, id interface{}) *SubscriptionRepository_Get_Call { + return &SubscriptionRepository_Get_Call{Call: _e.mock.On("Get", ctx, id)} +} + +func (_c *SubscriptionRepository_Get_Call) Run(run func(ctx context.Context, id string)) *SubscriptionRepository_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *SubscriptionRepository_Get_Call) Return(_a0 silence.Silence, _a1 error) *SubscriptionRepository_Get_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +// List provides a mock function with given fields: _a0, _a1 +func (_m *SubscriptionRepository) List(_a0 context.Context, _a1 silence.Filter) ([]silence.Silence, error) { + ret := _m.Called(_a0, _a1) + + var r0 []silence.Silence + if rf, ok := ret.Get(0).(func(context.Context, silence.Filter) []silence.Silence); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]silence.Silence) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, silence.Filter) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SubscriptionRepository_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type SubscriptionRepository_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 silence.Filter +func (_e *SubscriptionRepository_Expecter) List(_a0 interface{}, _a1 interface{}) *SubscriptionRepository_List_Call { + return &SubscriptionRepository_List_Call{Call: _e.mock.On("List", _a0, _a1)} +} + +func (_c *SubscriptionRepository_List_Call) Run(run func(_a0 context.Context, _a1 silence.Filter)) *SubscriptionRepository_List_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(silence.Filter)) + }) + return _c +} + +func (_c *SubscriptionRepository_List_Call) Return(_a0 []silence.Silence, _a1 error) *SubscriptionRepository_List_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +// SoftDelete provides a mock function with given fields: ctx, id +func (_m *SubscriptionRepository) SoftDelete(ctx context.Context, id string) error { + ret := _m.Called(ctx, id) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SubscriptionRepository_SoftDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SoftDelete' +type SubscriptionRepository_SoftDelete_Call struct { + *mock.Call +} + +// SoftDelete is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *SubscriptionRepository_Expecter) SoftDelete(ctx interface{}, id interface{}) *SubscriptionRepository_SoftDelete_Call { + return &SubscriptionRepository_SoftDelete_Call{Call: _e.mock.On("SoftDelete", ctx, id)} +} + +func (_c *SubscriptionRepository_SoftDelete_Call) Run(run func(ctx context.Context, id string)) *SubscriptionRepository_SoftDelete_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *SubscriptionRepository_SoftDelete_Call) Return(_a0 error) *SubscriptionRepository_SoftDelete_Call { + _c.Call.Return(_a0) + return _c +} + +type mockConstructorTestingTNewSubscriptionRepository interface { + mock.TestingT + Cleanup(func()) +} + +// NewSubscriptionRepository creates a new instance of SubscriptionRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewSubscriptionRepository(t mockConstructorTestingTNewSubscriptionRepository) *SubscriptionRepository { + mock := &SubscriptionRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/silence/service.go b/core/silence/service.go new file mode 100644 index 00000000..6afd671f --- /dev/null +++ b/core/silence/service.go @@ -0,0 +1,34 @@ +package silence + +import ( + "context" +) + +type Service struct { + repository Repository +} + +func NewService(repo Repository) *Service { + return &Service{ + repository: repo, + } +} + +func (s *Service) Create(ctx context.Context, sil Silence) (string, error) { + if err := sil.Validate(); err != nil { + return "", err + } + return s.repository.Create(ctx, sil) +} + +func (s *Service) List(ctx context.Context, filter Filter) ([]Silence, error) { + return s.repository.List(ctx, filter) +} + +func (s *Service) Get(ctx context.Context, id string) (Silence, error) { + return s.repository.Get(ctx, id) +} + +func (s *Service) Delete(ctx context.Context, id string) error { + return s.repository.SoftDelete(ctx, id) +} diff --git a/core/silence/service_test.go b/core/silence/service_test.go new file mode 100644 index 00000000..c6e8dc2c --- /dev/null +++ b/core/silence/service_test.go @@ -0,0 +1 @@ +package silence_test diff --git a/core/silence/silence.go b/core/silence/silence.go new file mode 100644 index 00000000..b8debee5 --- /dev/null +++ b/core/silence/silence.go @@ -0,0 +1,85 @@ +package silence + +import ( + "context" + "fmt" + "time" + + "github.com/antonmedv/expr" +) + +const TargetExpressionRuleKey = "rule" + +//go:generate mockery --name=Repository -r --case underscore --with-expecter --structname SubscriptionRepository --filename subscription_repository.go --output=./mocks +type Repository interface { + Create(context.Context, Silence) (string, error) + List(context.Context, Filter) ([]Silence, error) + Get(ctx context.Context, id string) (Silence, error) + SoftDelete(ctx context.Context, id string) error +} + +type Silence struct { + ID string `json:"id"` + NamespaceID uint64 `json:"namespace_id"` + Type string `json:"type"` + TargetID uint64 `json:"target_id"` + TargetExpression map[string]interface{} `json:"target_expression"` + Creator string `json:"creator"` + Comment string `json:"comment"` + CreatedAt time.Time `json:"created_at"` + DeletedAt time.Time `json:"deleted_at"` +} + +func (s Silence) Validate() error { + switch s.Type { + case TypeSubscription: + if s.TargetID == 0 { + return fmt.Errorf("target id cannot be empty or zero for type '%s'", TypeSubscription) + } + case TypeMatchers: + if len(s.TargetExpression) == 0 { + return fmt.Errorf("target expression cannot be empty and should be kv labels for type '%s'", TypeMatchers) + } + default: + return fmt.Errorf("unknown silence type '%s', should be '%s' or '%s'", s.Type, TypeMatchers, TypeSubscription) + } + return nil +} + +func (s Silence) subscriptionRule() (string, error) { + if s.Type != TypeSubscription { + return "", fmt.Errorf("silence id '%s' type is not subscription, type is '%s' instead", s.ID, s.Type) + } + + rule, ok := s.TargetExpression[TargetExpressionRuleKey] + if !ok { + return "", nil + } + + ruleStr := fmt.Sprintf("%s", rule) + + return ruleStr, nil +} + +func (s Silence) EvaluateSubscriptionRule(env interface{}) (bool, error) { + rule, err := s.subscriptionRule() + if err != nil { + return false, err + } + + if rule == "" { + return true, nil + } + + res, err := expr.Eval(rule, env) + if err != nil { + return false, err + } + + resBool, ok := res.(bool) + if !ok { + return false, fmt.Errorf("rule evaluation result is not boolean: %v", res) + } + + return resBool, nil +} diff --git a/core/silence/silence_test.go b/core/silence/silence_test.go new file mode 100644 index 00000000..5eb5963c --- /dev/null +++ b/core/silence/silence_test.go @@ -0,0 +1,212 @@ +package silence_test + +import ( + "testing" + + "github.com/mitchellh/mapstructure" + "github.com/odpf/siren/core/receiver" + "github.com/odpf/siren/core/silence" + "github.com/odpf/siren/core/subscription" + "github.com/stretchr/testify/require" +) + +func TestSilence_Evaluate(t *testing.T) { + failedCases := []struct { + name string + silence silence.Silence + rcv subscription.Receiver + want bool + errString string + }{ + { + name: "silence type that is not subscription type would return error", + silence: silence.Silence{ + ID: "silence-id", + Type: "test", + }, + errString: "silence id 'silence-id' type is not subscription, type is 'test' instead", + }, + { + name: "rule that is not evaluated to boolean would return error", + silence: silence.Silence{ + ID: "silence-id", + Type: silence.TypeSubscription, + TargetID: 12, + TargetExpression: map[string]interface{}{ + "rule": "1 + 1", + }, + }, + rcv: subscription.Receiver{ + ID: 12, + Type: receiver.TypePagerDuty, + }, + errString: "rule evaluation result is not boolean: 2", + }, + { + name: "rule that cannot be evaluated would return error", + silence: silence.Silence{ + ID: "silence-id", + Type: silence.TypeSubscription, + TargetID: 12, + TargetExpression: map[string]interface{}{ + "rule": "test", + }, + }, + rcv: subscription.Receiver{ + ID: 12, + Type: receiver.TypePagerDuty, + }, + errString: "rule evaluation result is not boolean: ", + }, + } + + sucessCases := []struct { + name string + silence silence.Silence + rcv subscription.Receiver + want bool + errString string + }{ + { + name: "match by empty rule would pass", + silence: silence.Silence{ + Type: silence.TypeSubscription, + TargetID: 12, + TargetExpression: map[string]interface{}{ + "rule": "", + }, + }, + rcv: subscription.Receiver{ + ID: 12, + Type: receiver.TypePagerDuty, + }, + want: true, + }, + { + name: "no rule key in target expression would return empty string", + silence: silence.Silence{ + ID: "silence-id", + Type: silence.TypeSubscription, + TargetID: 12, + TargetExpression: map[string]interface{}{}, + }, + rcv: subscription.Receiver{ + ID: 12, + Type: receiver.TypePagerDuty, + }, + want: true, + }, + { + name: "match by `true` rule would pass", + silence: silence.Silence{ + Type: silence.TypeSubscription, + TargetID: 12, + TargetExpression: map[string]interface{}{ + "rule": "true", + }, + }, + rcv: subscription.Receiver{ + ID: 12, + Type: receiver.TypePagerDuty, + }, + want: true, + }, + { + name: "match by receiver id and type would pass", + silence: silence.Silence{ + Type: silence.TypeSubscription, + TargetID: 12, + TargetExpression: map[string]interface{}{ + "rule": "(ID == 12) and (Type == 'pagerduty')", + }, + }, + rcv: subscription.Receiver{ + ID: 12, + Type: receiver.TypePagerDuty, + }, + want: true, + }, + { + name: "match multiple receivers would pass", + silence: silence.Silence{ + Type: silence.TypeSubscription, + TargetID: 12, + TargetExpression: map[string]interface{}{ + "rule": "(ID == 12) or (ID == 16)", + }, + }, + rcv: subscription.Receiver{ + ID: 12, + Type: receiver.TypePagerDuty, + }, + want: true, + }, + } + + tests := append(sucessCases, failedCases...) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mapSubscriptionReceiver := map[string]interface{}{} + + err := mapstructure.Decode(tt.rcv, &mapSubscriptionReceiver) + require.NoError(t, err) + + got, err := tt.silence.EvaluateSubscriptionRule(mapSubscriptionReceiver) + if err != nil { + if err.Error() != tt.errString { + t.Errorf("silence.Silence.Evaluate() error = %v, expected was %v", err, tt.errString) + } + } + if got != tt.want { + t.Errorf("silence.Silence.Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSilence_Validate(t *testing.T) { + tests := []struct { + name string + sil silence.Silence + wantErr bool + }{ + { + name: "should return error if type subscription and target id is empty or zero", + sil: silence.Silence{ + Type: silence.TypeSubscription, + }, + wantErr: true, + }, + { + name: "should return error if type labels and target expression is empty", + sil: silence.Silence{ + Type: silence.TypeMatchers, + }, + wantErr: true, + }, + { + name: "should return no error if type subscription and target id is not empty or zero", + sil: silence.Silence{ + Type: silence.TypeSubscription, + TargetID: 1, + }, + }, + { + name: "should return error if type labels and target expression is not empty", + sil: silence.Silence{ + Type: silence.TypeMatchers, + TargetExpression: map[string]interface{}{ + "k1": "v1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.sil.Validate(); (err != nil) != tt.wantErr { + t.Errorf("Silence.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/core/silence/type.go b/core/silence/type.go new file mode 100644 index 00000000..b0582219 --- /dev/null +++ b/core/silence/type.go @@ -0,0 +1,14 @@ +package silence + +const ( + TypeMatchers = "Matchers" + TypeSubscription = "subscription" +) + +func IsTypeValid(silenceTypeStr string) bool { + if silenceTypeStr == TypeMatchers || + silenceTypeStr == TypeSubscription { + return true + } + return false +} diff --git a/core/subscription/filter.go b/core/subscription/filter.go index a5671c86..8c1f2262 100644 --- a/core/subscription/filter.go +++ b/core/subscription/filter.go @@ -1,6 +1,9 @@ package subscription type Filter struct { - NamespaceID uint64 - Labels map[string]string + NamespaceID uint64 + Match map[string]string + NotificationMatch map[string]string + SilenceID string + IDs []int64 } diff --git a/core/subscription/mocks/log_service.go b/core/subscription/mocks/log_service.go new file mode 100644 index 00000000..763f712c --- /dev/null +++ b/core/subscription/mocks/log_service.go @@ -0,0 +1,84 @@ +// Code generated by mockery v2.16.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// LogService is an autogenerated mock type for the LogService type +type LogService struct { + mock.Mock +} + +type LogService_Expecter struct { + mock *mock.Mock +} + +func (_m *LogService) EXPECT() *LogService_Expecter { + return &LogService_Expecter{mock: &_m.Mock} +} + +// ListSubscriptionIDsBySilenceID provides a mock function with given fields: ctx, silenceID +func (_m *LogService) ListSubscriptionIDsBySilenceID(ctx context.Context, silenceID string) ([]int64, error) { + ret := _m.Called(ctx, silenceID) + + var r0 []int64 + if rf, ok := ret.Get(0).(func(context.Context, string) []int64); ok { + r0 = rf(ctx, silenceID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int64) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, silenceID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// LogService_ListSubscriptionIDsBySilenceID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSubscriptionIDsBySilenceID' +type LogService_ListSubscriptionIDsBySilenceID_Call struct { + *mock.Call +} + +// ListSubscriptionIDsBySilenceID is a helper method to define mock.On call +// - ctx context.Context +// - silenceID string +func (_e *LogService_Expecter) ListSubscriptionIDsBySilenceID(ctx interface{}, silenceID interface{}) *LogService_ListSubscriptionIDsBySilenceID_Call { + return &LogService_ListSubscriptionIDsBySilenceID_Call{Call: _e.mock.On("ListSubscriptionIDsBySilenceID", ctx, silenceID)} +} + +func (_c *LogService_ListSubscriptionIDsBySilenceID_Call) Run(run func(ctx context.Context, silenceID string)) *LogService_ListSubscriptionIDsBySilenceID_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *LogService_ListSubscriptionIDsBySilenceID_Call) Return(_a0 []int64, _a1 error) *LogService_ListSubscriptionIDsBySilenceID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +type mockConstructorTestingTNewLogService interface { + mock.TestingT + Cleanup(func()) +} + +// NewLogService creates a new instance of LogService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewLogService(t mockConstructorTestingTNewLogService) *LogService { + mock := &LogService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/subscription/mocks/namespace_service.go b/core/subscription/mocks/namespace_service.go index daa564ea..c86da659 100644 --- a/core/subscription/mocks/namespace_service.go +++ b/core/subscription/mocks/namespace_service.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -42,8 +42,8 @@ type NamespaceService_Create_Call struct { } // Create is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 *namespace.Namespace +// - _a0 context.Context +// - _a1 *namespace.Namespace func (_e *NamespaceService_Expecter) Create(_a0 interface{}, _a1 interface{}) *NamespaceService_Create_Call { return &NamespaceService_Create_Call{Call: _e.mock.On("Create", _a0, _a1)} } @@ -80,8 +80,8 @@ type NamespaceService_Delete_Call struct { } // Delete is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 uint64 +// - _a0 context.Context +// - _a1 uint64 func (_e *NamespaceService_Expecter) Delete(_a0 interface{}, _a1 interface{}) *NamespaceService_Delete_Call { return &NamespaceService_Delete_Call{Call: _e.mock.On("Delete", _a0, _a1)} } @@ -127,8 +127,8 @@ type NamespaceService_Get_Call struct { } // Get is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 uint64 +// - _a0 context.Context +// - _a1 uint64 func (_e *NamespaceService_Expecter) Get(_a0 interface{}, _a1 interface{}) *NamespaceService_Get_Call { return &NamespaceService_Get_Call{Call: _e.mock.On("Get", _a0, _a1)} } @@ -174,7 +174,7 @@ type NamespaceService_List_Call struct { } // List is a helper method to define mock.On call -// - _a0 context.Context +// - _a0 context.Context func (_e *NamespaceService_Expecter) List(_a0 interface{}) *NamespaceService_List_Call { return &NamespaceService_List_Call{Call: _e.mock.On("List", _a0)} } @@ -211,8 +211,8 @@ type NamespaceService_Update_Call struct { } // Update is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 *namespace.Namespace +// - _a0 context.Context +// - _a1 *namespace.Namespace func (_e *NamespaceService_Expecter) Update(_a0 interface{}, _a1 interface{}) *NamespaceService_Update_Call { return &NamespaceService_Update_Call{Call: _e.mock.On("Update", _a0, _a1)} } diff --git a/core/subscription/mocks/receiver_service.go b/core/subscription/mocks/receiver_service.go index 76b8fa58..7e13371b 100644 --- a/core/subscription/mocks/receiver_service.go +++ b/core/subscription/mocks/receiver_service.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -51,8 +51,8 @@ type ReceiverService_List_Call struct { } // List is a helper method to define mock.On call -// - ctx context.Context -// - flt receiver.Filter +// - ctx context.Context +// - flt receiver.Filter func (_e *ReceiverService_Expecter) List(ctx interface{}, flt interface{}) *ReceiverService_List_Call { return &ReceiverService_List_Call{Call: _e.mock.On("List", ctx, flt)} } diff --git a/core/subscription/mocks/subscription_repository.go b/core/subscription/mocks/subscription_repository.go index fa1fd0c2..f4cc9dd5 100644 --- a/core/subscription/mocks/subscription_repository.go +++ b/core/subscription/mocks/subscription_repository.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -42,8 +42,8 @@ type SubscriptionRepository_Create_Call struct { } // Create is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 *subscription.Subscription +// - _a0 context.Context +// - _a1 *subscription.Subscription func (_e *SubscriptionRepository_Expecter) Create(_a0 interface{}, _a1 interface{}) *SubscriptionRepository_Create_Call { return &SubscriptionRepository_Create_Call{Call: _e.mock.On("Create", _a0, _a1)} } @@ -80,8 +80,8 @@ type SubscriptionRepository_Delete_Call struct { } // Delete is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 uint64 +// - _a0 context.Context +// - _a1 uint64 func (_e *SubscriptionRepository_Expecter) Delete(_a0 interface{}, _a1 interface{}) *SubscriptionRepository_Delete_Call { return &SubscriptionRepository_Delete_Call{Call: _e.mock.On("Delete", _a0, _a1)} } @@ -127,8 +127,8 @@ type SubscriptionRepository_Get_Call struct { } // Get is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 uint64 +// - _a0 context.Context +// - _a1 uint64 func (_e *SubscriptionRepository_Expecter) Get(_a0 interface{}, _a1 interface{}) *SubscriptionRepository_Get_Call { return &SubscriptionRepository_Get_Call{Call: _e.mock.On("Get", _a0, _a1)} } @@ -174,8 +174,8 @@ type SubscriptionRepository_List_Call struct { } // List is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 subscription.Filter +// - _a0 context.Context +// - _a1 subscription.Filter func (_e *SubscriptionRepository_Expecter) List(_a0 interface{}, _a1 interface{}) *SubscriptionRepository_List_Call { return &SubscriptionRepository_List_Call{Call: _e.mock.On("List", _a0, _a1)} } @@ -212,8 +212,8 @@ type SubscriptionRepository_Update_Call struct { } // Update is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 *subscription.Subscription +// - _a0 context.Context +// - _a1 *subscription.Subscription func (_e *SubscriptionRepository_Expecter) Update(_a0 interface{}, _a1 interface{}) *SubscriptionRepository_Update_Call { return &SubscriptionRepository_Update_Call{Call: _e.mock.On("Update", _a0, _a1)} } diff --git a/core/subscription/service.go b/core/subscription/service.go index fd2fa36c..d6aa0986 100644 --- a/core/subscription/service.go +++ b/core/subscription/service.go @@ -8,6 +8,11 @@ import ( "github.com/odpf/siren/pkg/errors" ) +//go:generate mockery --name=LogService -r --case underscore --with-expecter --structname LogService --filename log_service.go --output=./mocks +type LogService interface { + ListSubscriptionIDsBySilenceID(ctx context.Context, silenceID string) ([]int64, error) +} + //go:generate mockery --name=NamespaceService -r --case underscore --with-expecter --structname NamespaceService --filename namespace_service.go --output=./mocks type NamespaceService interface { List(context.Context) ([]namespace.Namespace, error) @@ -25,14 +30,16 @@ type ReceiverService interface { // Service handles business logic type Service struct { repository Repository + logService LogService namespaceService NamespaceService receiverService ReceiverService } // NewService returns service struct -func NewService(repository Repository, namespaceService NamespaceService, receiverService ReceiverService) *Service { +func NewService(repository Repository, logService LogService, namespaceService NamespaceService, receiverService ReceiverService) *Service { svc := &Service{ repository: repository, + logService: logService, namespaceService: namespaceService, receiverService: receiverService, } @@ -41,6 +48,15 @@ func NewService(repository Repository, namespaceService NamespaceService, receiv } func (s *Service) List(ctx context.Context, flt Filter) ([]Subscription, error) { + + if flt.SilenceID != "" { + subscriptionIDs, err := s.logService.ListSubscriptionIDsBySilenceID(ctx, flt.SilenceID) + if err != nil { + return nil, err + } + flt.IDs = subscriptionIDs + } + subscriptions, err := s.repository.List(ctx, flt) if err != nil { return nil, err @@ -100,11 +116,11 @@ func (s *Service) Delete(ctx context.Context, id uint64) error { return nil } -func (s *Service) MatchByLabels(ctx context.Context, namespaceID uint64, labels map[string]string) ([]Subscription, error) { +func (s *Service) MatchByLabels(ctx context.Context, namespaceID uint64, notificationLabels map[string]string) ([]Subscription, error) { // fetch all subscriptions by matching labels. subscriptionsByLabels, err := s.repository.List(ctx, Filter{ - NamespaceID: namespaceID, - Labels: labels, + NamespaceID: namespaceID, + NotificationMatch: notificationLabels, }) if err != nil { return nil, err diff --git a/core/subscription/service_test.go b/core/subscription/service_test.go index 7e847e3d..32c3d228 100644 --- a/core/subscription/service_test.go +++ b/core/subscription/service_test.go @@ -41,8 +41,9 @@ func TestService_List(t *testing.T) { t.Run(tc.Description, func(t *testing.T) { var ( repositoryMock = new(mocks.SubscriptionRepository) + logServiceMock = new(mocks.LogService) ) - svc := subscription.NewService(repositoryMock, nil, nil) + svc := subscription.NewService(repositoryMock, logServiceMock, nil, nil) tc.Setup(repositoryMock) @@ -94,8 +95,9 @@ func TestService_Get(t *testing.T) { t.Run(tc.Description, func(t *testing.T) { var ( repositoryMock = new(mocks.SubscriptionRepository) + logServiceMock = new(mocks.LogService) ) - svc := subscription.NewService(repositoryMock, nil, nil) + svc := subscription.NewService(repositoryMock, logServiceMock, nil, nil) tc.Setup(repositoryMock) @@ -156,11 +158,13 @@ func TestService_Create(t *testing.T) { t.Run(tc.Description, func(t *testing.T) { var ( repositoryMock = new(mocks.SubscriptionRepository) + logServiceMock = new(mocks.LogService) namespaceServiceMock = new(mocks.NamespaceService) receiverServiceMock = new(mocks.ReceiverService) ) svc := subscription.NewService( repositoryMock, + logServiceMock, namespaceServiceMock, receiverServiceMock, ) @@ -231,11 +235,13 @@ func TestService_Update(t *testing.T) { t.Run(tc.Description, func(t *testing.T) { var ( repositoryMock = new(mocks.SubscriptionRepository) + logServiceMock = new(mocks.LogService) namespaceServiceMock = new(mocks.NamespaceService) receiverServiceMock = new(mocks.ReceiverService) ) svc := subscription.NewService( repositoryMock, + logServiceMock, namespaceServiceMock, receiverServiceMock, ) @@ -292,11 +298,13 @@ func TestService_Delete(t *testing.T) { t.Run(tc.Description, func(t *testing.T) { var ( repositoryMock = new(mocks.SubscriptionRepository) + logServiceMock = new(mocks.LogService) namespaceServiceMock = new(mocks.NamespaceService) receiverServiceMock = new(mocks.ReceiverService) ) svc := subscription.NewService( repositoryMock, + logServiceMock, namespaceServiceMock, receiverServiceMock, ) diff --git a/core/subscription/subscription.go b/core/subscription/subscription.go index 9b285e79..982d688f 100644 --- a/core/subscription/subscription.go +++ b/core/subscription/subscription.go @@ -2,7 +2,10 @@ package subscription import ( context "context" + "fmt" "time" + + "github.com/odpf/siren/core/silence" ) //go:generate mockery --name=Repository -r --case underscore --with-expecter --structname SubscriptionRepository --filename subscription_repository.go --output=./mocks @@ -31,3 +34,51 @@ type Subscription struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +func (s Subscription) ReceiversAsMap() map[uint64]Receiver { + var m = make(map[uint64]Receiver) + for _, rcv := range s.Receivers { + m[rcv.ID] = rcv + } + return m +} + +func (s Subscription) SilenceReceivers(silences []silence.Silence) (map[uint64][]silence.Silence, []Receiver, error) { + var ( + nonSilencedReceiversMap = map[uint64]Receiver{} + silencedReceiversMap = map[uint64][]silence.Silence{} + ) + + if len(silences) == 0 { + return nil, s.Receivers, nil + } + + // evaluate all receivers of subscribers with all matched silences + for _, sil := range silences { + for _, rcv := range s.Receivers { + isSilenced, err := sil.EvaluateSubscriptionRule(rcv) + if err != nil { + return nil, nil, fmt.Errorf("error evaluating subscription receiver %v: %w", rcv, err) + } + + if isSilenced { + if len(silencedReceiversMap) == 0 { + silencedReceiversMap = make(map[uint64][]silence.Silence) + } + silencedReceiversMap[rcv.ID] = append(silencedReceiversMap[rcv.ID], sil) + } else { + nonSilencedReceiversMap[rcv.ID] = rcv + } + } + } + + var nonSilencedReceivers []Receiver + for k, v := range nonSilencedReceiversMap { + // remove if non silenced receivers are part of silenced receivers + if _, ok := silencedReceiversMap[k]; !ok { + nonSilencedReceivers = append(nonSilencedReceivers, v) + } + } + + return silencedReceiversMap, nonSilencedReceivers, nil +} diff --git a/core/template/mocks/template_repository.go b/core/template/mocks/template_repository.go index 339441e5..73a43756 100644 --- a/core/template/mocks/template_repository.go +++ b/core/template/mocks/template_repository.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -42,8 +42,8 @@ type TemplateRepository_Delete_Call struct { } // Delete is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 string +// - _a0 context.Context +// - _a1 string func (_e *TemplateRepository_Expecter) Delete(_a0 interface{}, _a1 interface{}) *TemplateRepository_Delete_Call { return &TemplateRepository_Delete_Call{Call: _e.mock.On("Delete", _a0, _a1)} } @@ -89,8 +89,8 @@ type TemplateRepository_GetByName_Call struct { } // GetByName is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 string +// - _a0 context.Context +// - _a1 string func (_e *TemplateRepository_Expecter) GetByName(_a0 interface{}, _a1 interface{}) *TemplateRepository_GetByName_Call { return &TemplateRepository_GetByName_Call{Call: _e.mock.On("GetByName", _a0, _a1)} } @@ -136,8 +136,8 @@ type TemplateRepository_List_Call struct { } // List is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 template.Filter +// - _a0 context.Context +// - _a1 template.Filter func (_e *TemplateRepository_Expecter) List(_a0 interface{}, _a1 interface{}) *TemplateRepository_List_Call { return &TemplateRepository_List_Call{Call: _e.mock.On("List", _a0, _a1)} } @@ -174,8 +174,8 @@ type TemplateRepository_Upsert_Call struct { } // Upsert is a helper method to define mock.On call -// - _a0 context.Context -// - _a1 *template.Template +// - _a0 context.Context +// - _a1 *template.Template func (_e *TemplateRepository_Expecter) Upsert(_a0 interface{}, _a1 interface{}) *TemplateRepository_Upsert_Call { return &TemplateRepository_Upsert_Call{Call: _e.mock.On("Upsert", _a0, _a1)} } diff --git a/docker-compose.yaml b/docker-compose.yaml index afea3feb..18dc4e70 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,7 @@ version: "3" services: db: - image: "postgres:12" + image: "postgres:13" container_name: "siren_postgres" ports: - "5432:5432" diff --git a/docker/otel-collector-config.yaml b/docker/otel-collector-config.yaml index 7fa58188..5e8ac5e1 100644 --- a/docker/otel-collector-config.yaml +++ b/docker/otel-collector-config.yaml @@ -5,10 +5,6 @@ processors: batch: exporters: - prometheusremotewrite: - endpoint: "http://localhost:9000/api/v1/push" - tls: - insecure: true otlp: endpoint: https://otlp.nr-data.net:4317 headers: diff --git a/docs/docs/concepts/plugin.md b/docs/docs/concepts/plugin.md index b98c8c12..502fa9fe 100644 --- a/docs/docs/concepts/plugin.md +++ b/docs/docs/concepts/plugin.md @@ -103,8 +103,8 @@ Data - template - metricValue - metricName -- generatorUrl -- numAlertsFiring +- generator_url +- num_alerts_firing - dashboard - playbook - summary diff --git a/docs/docs/guides/deployment.md b/docs/docs/guides/deployment.md index 07cc1b2a..3ada363b 100644 --- a/docs/docs/guides/deployment.md +++ b/docs/docs/guides/deployment.md @@ -8,7 +8,7 @@ There are several approaches to setup Siren Server ## General pre-requisites -- PostgreSQL (version 12 or above) +- PostgreSQL (version 13 or above) - Monitoring Providers - Ex: CortexMetrics diff --git a/docs/docs/tour/2alerting_rules_subscriptions_overview.md b/docs/docs/tour/2alerting_rules_subscriptions_overview.md index 9b711cfd..5cb93b51 100644 --- a/docs/docs/tour/2alerting_rules_subscriptions_overview.md +++ b/docs/docs/tour/2alerting_rules_subscriptions_overview.md @@ -527,7 +527,7 @@ For details on a alert, try: siren alert view We also expect notifications have been published to the receiver id `2`. You can check a new notification is already added in `./out-file-sink2.json` with this value. ```json -{"environment":"integration","generatorUrl":"","groupKey":"{}:{severity=\"WARNING\"}","metricName":"test_alert","metricValue":"1","numAlertsFiring":1,"resource":"test_alert","routing_method":"subscribers","service":"some-service","severity":"WARNING","status":"firing","team":"odpf","template":"alert_test"} +{"environment":"integration","generator_url":"","groupKey":"{}:{severity=\"WARNING\"}","metricName":"test_alert","metricValue":"1","num_alerts_firing":1,"resource":"test_alert","routing_method":"subscribers","service":"some-service","severity":"WARNING","status":"firing","team":"odpf","template":"alert_test"} ``` ## What Next? diff --git a/go.mod b/go.mod index f4d4929b..27f16a5d 100644 --- a/go.mod +++ b/go.mod @@ -21,8 +21,8 @@ require ( github.com/lib/pq v1.10.4 github.com/mcuadros/go-defaults v1.2.0 github.com/mitchellh/mapstructure v1.5.0 - github.com/newrelic/go-agent/v3 v3.12.0 - github.com/newrelic/go-agent/v3/integrations/nrgrpc v1.3.1 + github.com/newrelic/go-agent/v3 v3.20.2 + github.com/newrelic/go-agent/v3/integrations/nrgrpc v1.3.2 github.com/newrelic/newrelic-opencensus-exporter-go v0.4.0 github.com/odpf/salt v0.2.5-0.20221122033807-b6caa1b617bf github.com/ory/dockertest/v3 v3.9.1 @@ -38,10 +38,11 @@ require ( google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 google.golang.org/grpc v1.51.0 google.golang.org/protobuf v1.28.1 - gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 ) +require github.com/antonmedv/expr v1.9.0 + require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/huandu/xstrings v1.3.3 // indirect @@ -182,4 +183,5 @@ require ( google.golang.org/api v0.103.0 // indirect google.golang.org/appengine v1.6.7 // indirect gopkg.in/ini.v1 v1.66.6 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 8acd3768..705c438a 100644 --- a/go.sum +++ b/go.sum @@ -262,6 +262,8 @@ github.com/amir/raidman v0.0.0-20170415203553-1ccc43bfb9c9/go.mod h1:eliMa/PW+RD github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/antihax/optional v0.0.0-20180407024304-ca021399b1a6/go.mod h1:V8iCPQYkqmusNa815XgQio277wI47sdRh1dUOLdyC6Q= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antonmedv/expr v1.9.0 h1:j4HI3NHEdgDnN9p6oI6Ndr0G5QryMY0FNxT4ONrFDGU= +github.com/antonmedv/expr v1.9.0/go.mod h1:5qsM3oLGDND7sDmQGDXHkYfkjYMUX14qsgqmHhwGEk8= github.com/apache/arrow/go/arrow v0.0.0-20191024131854-af6fa24be0db/go.mod h1:VTxUBvSJ3s3eHAg65PNgrsn5BtqCRPdmyXh6rAfdxN0= github.com/apache/arrow/go/arrow v0.0.0-20210818145353-234c94e4ce64/go.mod h1:2qMFB56yOP3KzkB3PbYZ4AlUFg3a88F67TIx5lB/WwY= github.com/apache/arrow/go/arrow v0.0.0-20211013220434-5962184e7a30/go.mod h1:Q7yQnSMnLvcXlZ8RV+jwz/6y1rQTqbX6C82SndT52Zs= @@ -618,6 +620,7 @@ github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CL github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= github.com/dave/jennifer v1.2.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhrIygKg= github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -759,6 +762,8 @@ github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXt github.com/gabriel-vasile/mimetype v1.3.1/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8= github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8= github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -1505,6 +1510,8 @@ github.com/lightstep/lightstep-tracer-go v0.18.0/go.mod h1:jlF1pusYV4pidLvZ+XD0U github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/linuxkit/virtsock v0.0.0-20201010232012-f8cee7dfc7a3/go.mod h1:3r6x7q95whyfWQpmGZTu3gk3v2YkMi05HEzl7Tf7YEo= github.com/lovoo/gcloud-opentracing v0.3.0/go.mod h1:ZFqk2y38kMDDikZPAK7ynTTGuyt17nSPdS3K5e+ZTBY= +github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/iostat v1.1.0/go.mod h1:rEPNA0xXgjHQjuI5Cy05sLlS2oRcSlWHRLrvh/AQ+Pg= @@ -1560,6 +1567,7 @@ github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzp github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= @@ -1694,10 +1702,10 @@ github.com/ncw/swift v1.0.50/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ github.com/ncw/swift v1.0.52/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM= github.com/neo4j/neo4j-go-driver v1.8.1-0.20200803113522-b626aa943eba/go.mod h1:ncO5VaFWh0Nrt+4KT4mOZboaczBZcLuHrG+/sUeP8gI= github.com/newrelic/go-agent/v3 v3.3.0/go.mod h1:H28zDNUC0U/b7kLoY4EFOhuth10Xu/9dchozUiOseQQ= -github.com/newrelic/go-agent/v3 v3.12.0 h1:tcDo0Q8qRWAJqb9uykfmM8pxGSbv0HqSS3q1+PzdhAo= -github.com/newrelic/go-agent/v3 v3.12.0/go.mod h1:1A1dssWBwzB7UemzRU6ZVaGDsI+cEn5/bNxI0wiYlIc= -github.com/newrelic/go-agent/v3/integrations/nrgrpc v1.3.1 h1:/ar1Omo9luapTJYWXt86oQGBpWwpWF92x+UuYU9v/7o= -github.com/newrelic/go-agent/v3/integrations/nrgrpc v1.3.1/go.mod h1:2q0u6qkNJ4ClDt920A4r+NpcO370lFze1NF4OPJjAks= +github.com/newrelic/go-agent/v3 v3.20.2 h1:EqFMriW3Bv3on4tqKzI+fJmNYOEG55yw54v6yv8L+x8= +github.com/newrelic/go-agent/v3 v3.20.2/go.mod h1:rT6ZUxJc5rQbWLyCtjqQCOcfb01lKRFbc1yMQkcboWM= +github.com/newrelic/go-agent/v3/integrations/nrgrpc v1.3.2 h1:SBtZAkWapxfAxYVZHagHxsrbRt+ULkgxi0mbn6TMRM8= +github.com/newrelic/go-agent/v3/integrations/nrgrpc v1.3.2/go.mod h1:h5GGcju9OuzmRdKa5glKh/SrMs1HhZyvXrf0mEPv70k= github.com/newrelic/go-agent/v3/integrations/nrpq v1.1.1 h1:HlVcLXw7ZZPjeRx3lQUAN8qfpJVDmuq4L237M1+PS8A= github.com/newrelic/go-agent/v3/integrations/nrpq v1.1.1/go.mod h1:UvI7Z0Dok/36E44UiTysh9HQZudDdpiChbe3+eqSB0I= github.com/newrelic/newrelic-opencensus-exporter-go v0.4.0 h1:BjzhyzSrzc8/WtyZDWBF8XATW4M92EoZiy38kgL3gfo= @@ -1963,6 +1971,7 @@ github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqn github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/retailnext/hllpp v1.0.1-0.20180308014038-101a6d2f8b52/go.mod h1:RDpi1RftBQPUCDRw6SmxeaREsAaRKnOclghuzp/WRzc= +github.com/rivo/tview v0.0.0-20200219210816-cd38d7432498/go.mod h1:6lkG1x+13OShEf0EaOCaTQYyB7d5nSbb181KtjlS+84= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -1993,6 +2002,7 @@ github.com/samuel/go-zookeeper v0.0.0-20190810000440-0ceca61e4d75/go.mod h1:gi+0 github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= github.com/samuel/go-zookeeper v0.0.0-20200724154423-2164a8ac840e/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= github.com/samuel/go-zookeeper v0.0.0-20201211165307-7117e9ea2414/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/sanity-io/litter v1.2.0/go.mod h1:JF6pZUFgu2Q0sBZ+HSV35P8TVPI1TTzEwyu9FXAw2W4= github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4= github.com/satori/go.uuid v0.0.0-20160603004225-b111a074d5ef/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= @@ -2100,6 +2110,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.0/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -2621,6 +2632,7 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/api/api.go b/internal/api/api.go index 635bfdc1..7817f3fa 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -10,6 +10,7 @@ import ( "github.com/odpf/siren/core/provider" "github.com/odpf/siren/core/receiver" "github.com/odpf/siren/core/rule" + "github.com/odpf/siren/core/silence" "github.com/odpf/siren/core/subscription" "github.com/odpf/siren/core/template" ) @@ -73,13 +74,20 @@ type TemplateService interface { //go:generate mockery --name=NotificationService -r --case underscore --with-expecter --structname NotificationService --filename notification_service.go --output=./mocks type NotificationService interface { - DispatchToReceiver(ctx context.Context, n notification.Notification, receiverID uint64) error - DispatchToSubscribers(ctx context.Context, namespaceID uint64, n notification.Notification) error + Dispatch(ctx context.Context, n notification.Notification) error CheckAndInsertIdempotency(ctx context.Context, scope, key string) (uint64, error) MarkIdempotencyAsSuccess(ctx context.Context, id uint64) error RemoveIdempotencies(ctx context.Context, TTL time.Duration) error } +//go:generate mockery --name=SilenceService -r --case underscore --with-expecter --structname SilenceService --filename silence_service.go --output=./mocks +type SilenceService interface { + Create(ctx context.Context, sil silence.Silence) (string, error) + List(ctx context.Context, filter silence.Filter) ([]silence.Silence, error) + Get(ctx context.Context, id string) (silence.Silence, error) + Delete(ctx context.Context, id string) error +} + type Deps struct { TemplateService TemplateService RuleService RuleService @@ -89,4 +97,5 @@ type Deps struct { ReceiverService ReceiverService SubscriptionService SubscriptionService NotificationService NotificationService + SilenceService SilenceService } diff --git a/internal/api/mocks/notification_service.go b/internal/api/mocks/notification_service.go index fe71f3d3..00fd9488 100644 --- a/internal/api/mocks/notification_service.go +++ b/internal/api/mocks/notification_service.go @@ -70,13 +70,13 @@ func (_c *NotificationService_CheckAndInsertIdempotency_Call) Return(_a0 uint64, return _c } -// DispatchToReceiver provides a mock function with given fields: ctx, n, receiverID -func (_m *NotificationService) DispatchToReceiver(ctx context.Context, n notification.Notification, receiverID uint64) error { - ret := _m.Called(ctx, n, receiverID) +// Dispatch provides a mock function with given fields: ctx, n +func (_m *NotificationService) Dispatch(ctx context.Context, n notification.Notification) error { + ret := _m.Called(ctx, n) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, notification.Notification, uint64) error); ok { - r0 = rf(ctx, n, receiverID) + if rf, ok := ret.Get(0).(func(context.Context, notification.Notification) error); ok { + r0 = rf(ctx, n) } else { r0 = ret.Error(0) } @@ -84,66 +84,26 @@ func (_m *NotificationService) DispatchToReceiver(ctx context.Context, n notific return r0 } -// NotificationService_DispatchToReceiver_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DispatchToReceiver' -type NotificationService_DispatchToReceiver_Call struct { +// NotificationService_Dispatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Dispatch' +type NotificationService_Dispatch_Call struct { *mock.Call } -// DispatchToReceiver is a helper method to define mock.On call +// Dispatch is a helper method to define mock.On call // - ctx context.Context // - n notification.Notification -// - receiverID uint64 -func (_e *NotificationService_Expecter) DispatchToReceiver(ctx interface{}, n interface{}, receiverID interface{}) *NotificationService_DispatchToReceiver_Call { - return &NotificationService_DispatchToReceiver_Call{Call: _e.mock.On("DispatchToReceiver", ctx, n, receiverID)} +func (_e *NotificationService_Expecter) Dispatch(ctx interface{}, n interface{}) *NotificationService_Dispatch_Call { + return &NotificationService_Dispatch_Call{Call: _e.mock.On("Dispatch", ctx, n)} } -func (_c *NotificationService_DispatchToReceiver_Call) Run(run func(ctx context.Context, n notification.Notification, receiverID uint64)) *NotificationService_DispatchToReceiver_Call { +func (_c *NotificationService_Dispatch_Call) Run(run func(ctx context.Context, n notification.Notification)) *NotificationService_Dispatch_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(notification.Notification), args[2].(uint64)) + run(args[0].(context.Context), args[1].(notification.Notification)) }) return _c } -func (_c *NotificationService_DispatchToReceiver_Call) Return(_a0 error) *NotificationService_DispatchToReceiver_Call { - _c.Call.Return(_a0) - return _c -} - -// DispatchToSubscribers provides a mock function with given fields: ctx, namespaceID, n -func (_m *NotificationService) DispatchToSubscribers(ctx context.Context, namespaceID uint64, n notification.Notification) error { - ret := _m.Called(ctx, namespaceID, n) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, uint64, notification.Notification) error); ok { - r0 = rf(ctx, namespaceID, n) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// NotificationService_DispatchToSubscribers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DispatchToSubscribers' -type NotificationService_DispatchToSubscribers_Call struct { - *mock.Call -} - -// DispatchToSubscribers is a helper method to define mock.On call -// - ctx context.Context -// - namespaceID uint64 -// - n notification.Notification -func (_e *NotificationService_Expecter) DispatchToSubscribers(ctx interface{}, namespaceID interface{}, n interface{}) *NotificationService_DispatchToSubscribers_Call { - return &NotificationService_DispatchToSubscribers_Call{Call: _e.mock.On("DispatchToSubscribers", ctx, namespaceID, n)} -} - -func (_c *NotificationService_DispatchToSubscribers_Call) Run(run func(ctx context.Context, namespaceID uint64, n notification.Notification)) *NotificationService_DispatchToSubscribers_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(uint64), args[2].(notification.Notification)) - }) - return _c -} - -func (_c *NotificationService_DispatchToSubscribers_Call) Return(_a0 error) *NotificationService_DispatchToSubscribers_Call { +func (_c *NotificationService_Dispatch_Call) Return(_a0 error) *NotificationService_Dispatch_Call { _c.Call.Return(_a0) return _c } diff --git a/internal/api/mocks/silence_service.go b/internal/api/mocks/silence_service.go new file mode 100644 index 00000000..0715715d --- /dev/null +++ b/internal/api/mocks/silence_service.go @@ -0,0 +1,213 @@ +// Code generated by mockery v2.16.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + silence "github.com/odpf/siren/core/silence" + mock "github.com/stretchr/testify/mock" +) + +// SilenceService is an autogenerated mock type for the SilenceService type +type SilenceService struct { + mock.Mock +} + +type SilenceService_Expecter struct { + mock *mock.Mock +} + +func (_m *SilenceService) EXPECT() *SilenceService_Expecter { + return &SilenceService_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function with given fields: ctx, sil +func (_m *SilenceService) Create(ctx context.Context, sil silence.Silence) (string, error) { + ret := _m.Called(ctx, sil) + + var r0 string + if rf, ok := ret.Get(0).(func(context.Context, silence.Silence) string); ok { + r0 = rf(ctx, sil) + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, silence.Silence) error); ok { + r1 = rf(ctx, sil) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SilenceService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type SilenceService_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - sil silence.Silence +func (_e *SilenceService_Expecter) Create(ctx interface{}, sil interface{}) *SilenceService_Create_Call { + return &SilenceService_Create_Call{Call: _e.mock.On("Create", ctx, sil)} +} + +func (_c *SilenceService_Create_Call) Run(run func(ctx context.Context, sil silence.Silence)) *SilenceService_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(silence.Silence)) + }) + return _c +} + +func (_c *SilenceService_Create_Call) Return(_a0 string, _a1 error) *SilenceService_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +// Delete provides a mock function with given fields: ctx, id +func (_m *SilenceService) Delete(ctx context.Context, id string) error { + ret := _m.Called(ctx, id) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SilenceService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type SilenceService_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *SilenceService_Expecter) Delete(ctx interface{}, id interface{}) *SilenceService_Delete_Call { + return &SilenceService_Delete_Call{Call: _e.mock.On("Delete", ctx, id)} +} + +func (_c *SilenceService_Delete_Call) Run(run func(ctx context.Context, id string)) *SilenceService_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *SilenceService_Delete_Call) Return(_a0 error) *SilenceService_Delete_Call { + _c.Call.Return(_a0) + return _c +} + +// Get provides a mock function with given fields: ctx, id +func (_m *SilenceService) Get(ctx context.Context, id string) (silence.Silence, error) { + ret := _m.Called(ctx, id) + + var r0 silence.Silence + if rf, ok := ret.Get(0).(func(context.Context, string) silence.Silence); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(silence.Silence) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SilenceService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type SilenceService_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *SilenceService_Expecter) Get(ctx interface{}, id interface{}) *SilenceService_Get_Call { + return &SilenceService_Get_Call{Call: _e.mock.On("Get", ctx, id)} +} + +func (_c *SilenceService_Get_Call) Run(run func(ctx context.Context, id string)) *SilenceService_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *SilenceService_Get_Call) Return(_a0 silence.Silence, _a1 error) *SilenceService_Get_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +// List provides a mock function with given fields: ctx, filter +func (_m *SilenceService) List(ctx context.Context, filter silence.Filter) ([]silence.Silence, error) { + ret := _m.Called(ctx, filter) + + var r0 []silence.Silence + if rf, ok := ret.Get(0).(func(context.Context, silence.Filter) []silence.Silence); ok { + r0 = rf(ctx, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]silence.Silence) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, silence.Filter) error); ok { + r1 = rf(ctx, filter) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SilenceService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type SilenceService_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - ctx context.Context +// - filter silence.Filter +func (_e *SilenceService_Expecter) List(ctx interface{}, filter interface{}) *SilenceService_List_Call { + return &SilenceService_List_Call{Call: _e.mock.On("List", ctx, filter)} +} + +func (_c *SilenceService_List_Call) Run(run func(ctx context.Context, filter silence.Filter)) *SilenceService_List_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(silence.Filter)) + }) + return _c +} + +func (_c *SilenceService_List_Call) Return(_a0 []silence.Silence, _a1 error) *SilenceService_List_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +type mockConstructorTestingTNewSilenceService interface { + mock.TestingT + Cleanup(func()) +} + +// NewSilenceService creates a new instance of SilenceService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewSilenceService(t mockConstructorTestingTNewSilenceService) *SilenceService { + mock := &SilenceService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/api/v1beta1/alert.go b/internal/api/v1beta1/alert.go index 8e83453d..23df4b60 100644 --- a/internal/api/v1beta1/alert.go +++ b/internal/api/v1beta1/alert.go @@ -2,12 +2,10 @@ package v1beta1 import ( "context" - "fmt" "time" "github.com/odpf/siren/core/alert" "github.com/odpf/siren/core/notification" - "github.com/odpf/siren/core/template" sirenv1beta1 "github.com/odpf/siren/proto/odpf/siren/v1beta1" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -18,6 +16,7 @@ func (s *GRPCServer) ListAlerts(ctx context.Context, req *sirenv1beta1.ListAlert ProviderID: req.GetProviderId(), StartTime: int64(req.GetStartTime()), EndTime: int64(req.GetEndTime()), + // SilenceID: req.GetSilenced(), }) if err != nil { return nil, s.generateRPCErr(err) @@ -26,14 +25,15 @@ func (s *GRPCServer) ListAlerts(ctx context.Context, req *sirenv1beta1.ListAlert items := []*sirenv1beta1.Alert{} for _, alert := range alerts { item := &sirenv1beta1.Alert{ - Id: alert.ID, - ProviderId: alert.ProviderID, - ResourceName: alert.ResourceName, - MetricName: alert.MetricName, - MetricValue: alert.MetricValue, - Severity: alert.Severity, - Rule: alert.Rule, - TriggeredAt: timestamppb.New(alert.TriggeredAt), + Id: alert.ID, + ProviderId: alert.ProviderID, + ResourceName: alert.ResourceName, + MetricName: alert.MetricName, + MetricValue: alert.MetricValue, + Severity: alert.Severity, + Rule: alert.Rule, + TriggeredAt: timestamppb.New(alert.TriggeredAt), + SilenceStatus: alert.SilenceStatus, } items = append(items, item) } @@ -87,99 +87,14 @@ func (s *GRPCServer) createAlerts(ctx context.Context, providerType string, prov if len(createdAlerts) > 0 { // Publish to notification service - n := AlertsToNotification(createdAlerts, firingLen, time.Now()) + n := notification.BuildFromAlerts(createdAlerts, firingLen, time.Now()) - if err := s.notificationService.DispatchToSubscribers(ctx, namespaceID, n); err != nil { + if err := s.notificationService.Dispatch(ctx, n); err != nil { s.logger.Warn("failed to send alert as notification", "err", err, "notification", n) } } else { - s.logger.Warn("failed to send alert a as notification, empty created alerts") + s.logger.Warn("failed to send alert as notification, empty created alerts") } return items, nil } - -// Transform alerts and populate Data and Labels to be interpolated to the system-default template -// .Data -// - id -// - status "FIRING"/"RESOLVED" -// - resource -// - template -// - metric_value -// - metric_name -// - generatorUrl -// - numAlertsFiring -// - dashboard -// - playbook -// - summary -// .Labels -// - severity "WARNING"/"CRITICAL" -// - alertname -// - (others labels defined in rules) -func AlertsToNotification( - as []alert.Alert, - firingLen int, - createdTime time.Time, -) notification.Notification { - sampleAlert := as[0] - id := "cortex-" + sampleAlert.Fingerprint - - data := map[string]interface{}{} - - mergedAnnotations := map[string][]string{} - for _, a := range as { - for k, v := range a.Annotations { - mergedAnnotations[k] = append(mergedAnnotations[k], v) - } - } - - // make unique - for k, v := range mergedAnnotations { - mergedAnnotations[k] = removeDuplicateStringValues(v) - } - - // render annotations - for k, vSlice := range mergedAnnotations { - for _, v := range vSlice { - if _, ok := data[k]; ok { - data[k] = fmt.Sprintf("%s\n%s", data[k], v) - } else { - data[k] = v - } - } - } - - data["status"] = sampleAlert.Status - data["generatorUrl"] = sampleAlert.GeneratorURL - data["numAlertsFiring"] = firingLen - data["id"] = id - - labels := map[string]string{} - - for _, a := range as { - for k, v := range a.Labels { - labels[k] = v - } - } - - return notification.Notification{ - ID: id, - Data: data, - Labels: labels, - Template: template.ReservedName_SystemDefault, - CreatedAt: createdTime, - } -} - -func removeDuplicateStringValues(strSlice []string) []string { - keys := make(map[string]bool) - list := []string{} - - for _, v := range strSlice { - if _, value := keys[v]; !value { - keys[v] = true - list = append(list, v) - } - } - return list -} diff --git a/internal/api/v1beta1/alert_test.go b/internal/api/v1beta1/alert_test.go index ac08840a..1ef929af 100644 --- a/internal/api/v1beta1/alert_test.go +++ b/internal/api/v1beta1/alert_test.go @@ -162,6 +162,7 @@ func TestGRPCServer_CreateAlertHistory(t *testing.T) { dummyAlerts := []alert.Alert{{ ID: 1, ProviderID: 1, + NamespaceID: 1, ResourceName: "foo", MetricName: "bar", MetricValue: "30", @@ -171,7 +172,7 @@ func TestGRPCServer_CreateAlertHistory(t *testing.T) { }} mockedAlertService.EXPECT().CreateAlerts(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("string"), mock.AnythingOfType("uint64"), mock.AnythingOfType("uint64"), payload). Return(dummyAlerts, 1, nil).Once() - mockNotificationService.EXPECT().DispatchToSubscribers(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("notification.Notification")).Return(nil) + mockNotificationService.EXPECT().Dispatch(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("notification.Notification")).Return(nil) dummyGRPCServer := v1beta1.NewGRPCServer(nil, log.NewNoop(), api.HeadersConfig{}, &api.Deps{AlertService: mockedAlertService, NotificationService: mockNotificationService}) @@ -269,6 +270,7 @@ func TestGRPCServer_CreateAlertHistory(t *testing.T) { dummyAlerts := []alert.Alert{{ ID: 1, ProviderID: 1, + NamespaceID: 1, ResourceName: "foo", MetricName: "bar", MetricValue: "30", @@ -278,7 +280,7 @@ func TestGRPCServer_CreateAlertHistory(t *testing.T) { }} mockedAlertService.EXPECT().CreateAlerts(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("string"), mock.AnythingOfType("uint64"), mock.AnythingOfType("uint64"), payload). Return(dummyAlerts, 1, nil).Once() - mockNotificationService.EXPECT().DispatchToSubscribers(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("notification.Notification")).Return(nil) + mockNotificationService.EXPECT().Dispatch(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("notification.Notification")).Return(nil) dummyGRPCServer := v1beta1.NewGRPCServer(nil, log.NewNoop(), api.HeadersConfig{}, &api.Deps{AlertService: mockedAlertService, NotificationService: mockNotificationService}) @@ -446,6 +448,7 @@ func TestGRPCServer_CreateAlertHistory(t *testing.T) { dummyAlerts := []alert.Alert{{ ProviderID: 1, + NamespaceID: 1, ResourceName: "foo", MetricName: "bar", MetricValue: "30", @@ -458,7 +461,7 @@ func TestGRPCServer_CreateAlertHistory(t *testing.T) { mockedAlertService.EXPECT().CreateAlerts(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("string"), mock.AnythingOfType("uint64"), mock.AnythingOfType("uint64"), payload). Return(dummyAlerts, 2, nil).Once() - mockNotificationService.EXPECT().DispatchToSubscribers(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("uint64"), mock.AnythingOfType("notification.Notification")).Return(nil) + mockNotificationService.EXPECT().Dispatch(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("notification.Notification")).Return(nil) res, err := dummyGRPCServer.CreateAlerts(context.Background(), dummyReq) assert.Equal(t, 1, len(res.GetAlerts())) diff --git a/internal/api/v1beta1/notification.go b/internal/api/v1beta1/notification.go index 4ecd7c6a..1abef106 100644 --- a/internal/api/v1beta1/notification.go +++ b/internal/api/v1beta1/notification.go @@ -3,8 +3,6 @@ package v1beta1 import ( "context" - "github.com/mitchellh/mapstructure" - "github.com/odpf/siren/core/notification" "github.com/odpf/siren/internal/api" "github.com/odpf/siren/pkg/errors" @@ -33,13 +31,12 @@ func (s *GRPCServer) NotifyReceiver(ctx context.Context, req *sirenv1beta1.Notif } } - n := notification.Notification{} - if err := mapstructure.Decode(payloadMap, &n); err != nil { - err = errors.ErrInvalid.WithMsgf("failed to parse payload to notification: %s", err.Error()) + n, err := notification.BuildTypeReceiver(req.GetId(), payloadMap) + if err != nil { return nil, s.generateRPCErr(err) } - if err := s.notificationService.DispatchToReceiver(ctx, n, req.GetId()); err != nil { + if err := s.notificationService.Dispatch(ctx, n); err != nil { return nil, s.generateRPCErr(err) } diff --git a/internal/api/v1beta1/notification_test.go b/internal/api/v1beta1/notification_test.go index 73b7ce1c..452faab9 100644 --- a/internal/api/v1beta1/notification_test.go +++ b/internal/api/v1beta1/notification_test.go @@ -27,7 +27,7 @@ func TestGRPCServer_NotifyReceiver(t *testing.T) { idempotencyKey: "test", setup: func(ns *mocks.NotificationService) { ns.EXPECT().CheckAndInsertIdempotency(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(1, nil) - ns.EXPECT().DispatchToReceiver(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification"), mock.AnythingOfType("uint64")).Return(errors.ErrInvalid) + ns.EXPECT().Dispatch(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification")).Return(errors.ErrInvalid) }, errString: "rpc error: code = InvalidArgument desc = request is not valid", }, @@ -36,7 +36,7 @@ func TestGRPCServer_NotifyReceiver(t *testing.T) { idempotencyKey: "test", setup: func(ns *mocks.NotificationService) { ns.EXPECT().CheckAndInsertIdempotency(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(1, nil) - ns.EXPECT().DispatchToReceiver(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification"), mock.AnythingOfType("uint64")).Return(errors.New("some error")) + ns.EXPECT().Dispatch(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification")).Return(errors.New("some error")) }, errString: "rpc error: code = Internal desc = some unexpected error occurred", }, @@ -61,7 +61,7 @@ func TestGRPCServer_NotifyReceiver(t *testing.T) { setup: func(ns *mocks.NotificationService) { ns.EXPECT().CheckAndInsertIdempotency(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(1, nil) ns.EXPECT().MarkIdempotencyAsSuccess(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("uint64")).Return(errors.New("some error")) - ns.EXPECT().DispatchToReceiver(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification"), mock.AnythingOfType("uint64")).Return(nil) + ns.EXPECT().Dispatch(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification")).Return(nil) }, errString: "rpc error: code = Internal desc = some unexpected error occurred", }, @@ -71,13 +71,13 @@ func TestGRPCServer_NotifyReceiver(t *testing.T) { setup: func(ns *mocks.NotificationService) { ns.EXPECT().CheckAndInsertIdempotency(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(1, nil) ns.EXPECT().MarkIdempotencyAsSuccess(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("uint64")).Return(nil) - ns.EXPECT().DispatchToReceiver(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification"), mock.AnythingOfType("uint64")).Return(nil) + ns.EXPECT().Dispatch(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification")).Return(nil) }, }, { name: "should return OK response if notify receiver succeed without idempotency", setup: func(ns *mocks.NotificationService) { - ns.EXPECT().DispatchToReceiver(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification"), mock.AnythingOfType("uint64")).Return(nil) + ns.EXPECT().Dispatch(mock.AnythingOfType("*context.valueCtx"), mock.AnythingOfType("notification.Notification")).Return(nil) }, }, } diff --git a/internal/api/v1beta1/silence.go b/internal/api/v1beta1/silence.go new file mode 100644 index 00000000..2b426f4d --- /dev/null +++ b/internal/api/v1beta1/silence.go @@ -0,0 +1,92 @@ +package v1beta1 + +import ( + "context" + + "github.com/odpf/siren/core/silence" + sirenv1beta1 "github.com/odpf/siren/proto/odpf/siren/v1beta1" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func (s *GRPCServer) CreateSilence(ctx context.Context, req *sirenv1beta1.CreateSilenceRequest) (*sirenv1beta1.CreateSilenceResponse, error) { + id, err := s.silenceService.Create(ctx, silence.Silence{ + NamespaceID: req.GetNamespaceId(), + Type: req.GetType(), + TargetID: req.GetTargetId(), + TargetExpression: req.GetTargetExpression().AsMap(), + }) + if err != nil { + return nil, s.generateRPCErr(err) + } + + return &sirenv1beta1.CreateSilenceResponse{ + Id: id, + }, nil +} + +func (s *GRPCServer) ListSilences(ctx context.Context, req *sirenv1beta1.ListSilencesRequest) (*sirenv1beta1.ListSilencesResponse, error) { + silences, err := s.silenceService.List(ctx, silence.Filter{ + NamespaceID: req.GetNamespaceId(), + SubscriptionID: req.GetSubscriptionId(), + Match: req.GetMatch(), + SubscriptionMatch: req.GetSubscriptionMatch(), + }) + if err != nil { + return nil, s.generateRPCErr(err) + } + + var silencesProto []*sirenv1beta1.Silence + for _, si := range silences { + targetExpression, err := structpb.NewStruct(si.TargetExpression) + if err != nil { + return nil, s.generateRPCErr(err) + } + + silencesProto = append(silencesProto, &sirenv1beta1.Silence{ + Id: si.ID, + NamespaceId: si.NamespaceID, + Type: si.Type, + TargetId: si.TargetID, + TargetExpression: targetExpression, + CreatedAt: timestamppb.New(si.CreatedAt), + DeletedAt: timestamppb.New(si.DeletedAt), + }) + } + + return &sirenv1beta1.ListSilencesResponse{ + Silences: silencesProto, + }, nil +} + +func (s *GRPCServer) GetSilence(ctx context.Context, req *sirenv1beta1.GetSilenceRequest) (*sirenv1beta1.GetSilenceResponse, error) { + sil, err := s.silenceService.Get(ctx, req.GetId()) + if err != nil { + return nil, s.generateRPCErr(err) + } + + targetExpression, err := structpb.NewStruct(sil.TargetExpression) + if err != nil { + return nil, s.generateRPCErr(err) + } + + return &sirenv1beta1.GetSilenceResponse{ + Silence: &sirenv1beta1.Silence{ + Id: sil.ID, + NamespaceId: sil.NamespaceID, + Type: sil.Type, + TargetId: sil.TargetID, + TargetExpression: targetExpression, + CreatedAt: timestamppb.New(sil.CreatedAt), + DeletedAt: timestamppb.New(sil.DeletedAt), + }, + }, nil +} + +func (s *GRPCServer) ExpireSilence(ctx context.Context, req *sirenv1beta1.ExpireSilenceRequest) (*sirenv1beta1.ExpireSilenceResponse, error) { + if err := s.silenceService.Delete(ctx, req.GetId()); err != nil { + return nil, s.generateRPCErr(err) + } + + return &sirenv1beta1.ExpireSilenceResponse{}, nil +} diff --git a/internal/api/v1beta1/silence_test.go b/internal/api/v1beta1/silence_test.go new file mode 100644 index 00000000..3503bf9e --- /dev/null +++ b/internal/api/v1beta1/silence_test.go @@ -0,0 +1,289 @@ +package v1beta1_test + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/odpf/salt/log" + "github.com/odpf/siren/core/silence" + "github.com/odpf/siren/internal/api" + "github.com/odpf/siren/internal/api/mocks" + "github.com/odpf/siren/internal/api/v1beta1" + "github.com/odpf/siren/pkg/errors" + sirenv1beta1 "github.com/odpf/siren/proto/odpf/siren/v1beta1" + "github.com/stretchr/testify/mock" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestGRPCServer_CreateSilence(t *testing.T) { + mockSilenceData := silence.Silence{ + NamespaceID: 1, + Type: silence.TypeMatchers, + TargetExpression: map[string]interface{}{ + "key1": "value1", + }, + } + + tests := []struct { + name string + setup func(*mocks.SilenceService) + req *sirenv1beta1.CreateSilenceRequest + want *sirenv1beta1.CreateSilenceResponse + wantErr bool + }{ + { + name: "return silence id when successfully created silence", + setup: func(ss *mocks.SilenceService) { + ss.EXPECT().Create(mock.AnythingOfType("*context.emptyCtx"), mockSilenceData).Return("123", nil) + }, + req: &sirenv1beta1.CreateSilenceRequest{ + NamespaceId: mockSilenceData.NamespaceID, + Type: mockSilenceData.Type, + TargetExpression: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "key1": structpb.NewStringValue("value1"), + }, + }, + }, + want: &sirenv1beta1.CreateSilenceResponse{ + Id: "123", + }, + }, + { + name: "return error if service create return error", + setup: func(ss *mocks.SilenceService) { + ss.EXPECT().Create(mock.AnythingOfType("*context.emptyCtx"), mockSilenceData).Return("", errors.New("some error")) + }, + req: &sirenv1beta1.CreateSilenceRequest{ + NamespaceId: mockSilenceData.NamespaceID, + Type: mockSilenceData.Type, + TargetExpression: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "key1": structpb.NewStringValue("value1"), + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + ctx := context.TODO() + t.Run(tt.name, func(t *testing.T) { + mockSilenceService := new(mocks.SilenceService) + + if tt.setup != nil { + tt.setup(mockSilenceService) + } + + s := v1beta1.NewGRPCServer(nil, log.NewNoop(), api.HeadersConfig{}, &api.Deps{SilenceService: mockSilenceService}) + got, err := s.CreateSilence(ctx, tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("GRPCServer.CreateSilence() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GRPCServer.CreateSilence() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGRPCServer_ListSilences(t *testing.T) { + mockSilenceData := silence.Silence{ + NamespaceID: 1, + Type: silence.TypeMatchers, + TargetExpression: map[string]interface{}{ + "key1": "value1", + }, + } + + tests := []struct { + name string + setup func(*mocks.SilenceService) + want []*sirenv1beta1.Silence + wantErr bool + }{ + { + name: "return silences when successfully list silences", + setup: func(ss *mocks.SilenceService) { + ss.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("silence.Filter")).Return([]silence.Silence{ + mockSilenceData, + }, nil) + }, + want: []*sirenv1beta1.Silence{ + { + NamespaceId: mockSilenceData.NamespaceID, + Type: mockSilenceData.Type, + TargetExpression: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "key1": structpb.NewStringValue("value1"), + }, + }, + CreatedAt: timestamppb.New(time.Time{}), + }, + }, + }, + { + name: "return error if service list return error", + setup: func(ss *mocks.SilenceService) { + ss.EXPECT().List(mock.AnythingOfType("*context.emptyCtx"), mock.AnythingOfType("silence.Filter")).Return(nil, errors.New("some error")) + }, + wantErr: true, + }, + } + for _, tt := range tests { + ctx := context.TODO() + t.Run(tt.name, func(t *testing.T) { + mockSilenceService := new(mocks.SilenceService) + + if tt.setup != nil { + tt.setup(mockSilenceService) + } + + s := v1beta1.NewGRPCServer(nil, log.NewNoop(), api.HeadersConfig{}, &api.Deps{SilenceService: mockSilenceService}) + got, err := s.ListSilences(ctx, &sirenv1beta1.ListSilencesRequest{}) + if (err != nil) != tt.wantErr { + t.Errorf("GRPCServer.ListSilences() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if diff := cmp.Diff(got.GetSilences(), tt.want, protocmp.Transform(), protocmp.IgnoreFields(&sirenv1beta1.Silence{}, "id", "deleted_at")); diff != "" { + t.Errorf("GRPCServer.ListSilences() diff = %v", diff) + } + }) + } +} + +func TestGRPCServer_GetSilence(t *testing.T) { + mockSilenceData := silence.Silence{ + ID: "silence-id", + NamespaceID: 1, + Type: silence.TypeMatchers, + TargetExpression: map[string]interface{}{ + "key1": "value1", + }, + } + + tests := []struct { + name string + setup func(*mocks.SilenceService) + req *sirenv1beta1.GetSilenceRequest + want *sirenv1beta1.Silence + wantErr bool + }{ + { + name: "return silence when successfully get silence", + setup: func(ss *mocks.SilenceService) { + ss.EXPECT().Get(mock.AnythingOfType("*context.emptyCtx"), mockSilenceData.ID).Return(mockSilenceData, nil) + }, + req: &sirenv1beta1.GetSilenceRequest{ + Id: mockSilenceData.ID, + }, + want: &sirenv1beta1.Silence{ + NamespaceId: mockSilenceData.NamespaceID, + Type: mockSilenceData.Type, + TargetExpression: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "key1": structpb.NewStringValue("value1"), + }, + }, + CreatedAt: timestamppb.New(time.Time{}), + }, + }, + { + name: "return error if service get return error", + setup: func(ss *mocks.SilenceService) { + ss.EXPECT().Get(mock.AnythingOfType("*context.emptyCtx"), mockSilenceData.ID).Return(silence.Silence{}, errors.New("some error")) + }, + req: &sirenv1beta1.GetSilenceRequest{ + Id: mockSilenceData.ID, + }, + wantErr: true, + }, + } + for _, tt := range tests { + ctx := context.TODO() + t.Run(tt.name, func(t *testing.T) { + mockSilenceService := new(mocks.SilenceService) + + if tt.setup != nil { + tt.setup(mockSilenceService) + } + + s := v1beta1.NewGRPCServer(nil, log.NewNoop(), api.HeadersConfig{}, &api.Deps{SilenceService: mockSilenceService}) + got, err := s.GetSilence(ctx, tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("GRPCServer.GetSilence() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(got.GetSilence(), tt.want, protocmp.Transform(), protocmp.IgnoreFields(&sirenv1beta1.Silence{}, "id", "deleted_at")); diff != "" { + t.Errorf("GRPCServer.GetSilence() diff = %v", diff) + } + }) + } +} + +func TestGRPCServer_ExpireSilence(t *testing.T) { + mockSilenceData := silence.Silence{ + ID: "silence-id", + NamespaceID: 1, + Type: silence.TypeMatchers, + TargetExpression: map[string]interface{}{ + "key1": "value1", + }, + } + + tests := []struct { + name string + setup func(*mocks.SilenceService) + req *sirenv1beta1.ExpireSilenceRequest + want *sirenv1beta1.ExpireSilenceResponse + wantErr bool + }{ + { + name: "return success when successfully deleted silence", + setup: func(ss *mocks.SilenceService) { + ss.EXPECT().Delete(mock.AnythingOfType("*context.emptyCtx"), mockSilenceData.ID).Return(nil) + }, + req: &sirenv1beta1.ExpireSilenceRequest{ + Id: mockSilenceData.ID, + }, + want: &sirenv1beta1.ExpireSilenceResponse{}, + }, + { + name: "return error if service delete return error", + setup: func(ss *mocks.SilenceService) { + ss.EXPECT().Delete(mock.AnythingOfType("*context.emptyCtx"), mockSilenceData.ID).Return(errors.New("some error")) + }, + req: &sirenv1beta1.ExpireSilenceRequest{ + Id: mockSilenceData.ID, + }, + wantErr: true, + }, + } + for _, tt := range tests { + ctx := context.TODO() + t.Run(tt.name, func(t *testing.T) { + mockSilenceService := new(mocks.SilenceService) + + if tt.setup != nil { + tt.setup(mockSilenceService) + } + + s := v1beta1.NewGRPCServer(nil, log.NewNoop(), api.HeadersConfig{}, &api.Deps{SilenceService: mockSilenceService}) + got, err := s.ExpireSilence(ctx, tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("GRPCServer.ExpireSilence() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GRPCServer.ExpireSilence() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/api/v1beta1/subscription.go b/internal/api/v1beta1/subscription.go index c18ba457..0ca6eab5 100644 --- a/internal/api/v1beta1/subscription.go +++ b/internal/api/v1beta1/subscription.go @@ -9,8 +9,13 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -func (s *GRPCServer) ListSubscriptions(ctx context.Context, _ *sirenv1beta1.ListSubscriptionsRequest) (*sirenv1beta1.ListSubscriptionsResponse, error) { - subscriptions, err := s.subscriptionService.List(ctx, subscription.Filter{}) +func (s *GRPCServer) ListSubscriptions(ctx context.Context, req *sirenv1beta1.ListSubscriptionsRequest) (*sirenv1beta1.ListSubscriptionsResponse, error) { + subscriptions, err := s.subscriptionService.List(ctx, subscription.Filter{ + NamespaceID: req.GetNamespaceId(), + SilenceID: req.GetSilenceId(), + Match: req.GetMatch(), + NotificationMatch: req.GetNotificationMatch(), + }) if err != nil { return nil, s.generateRPCErr(err) } diff --git a/internal/api/v1beta1/v1beta1.go b/internal/api/v1beta1/v1beta1.go index eb2b1e58..3df04096 100644 --- a/internal/api/v1beta1/v1beta1.go +++ b/internal/api/v1beta1/v1beta1.go @@ -24,6 +24,7 @@ type GRPCServer struct { receiverService api.ReceiverService subscriptionService api.SubscriptionService notificationService api.NotificationService + silenceService api.SilenceService } func NewGRPCServer( @@ -43,6 +44,7 @@ func NewGRPCServer( receiverService: apiDeps.ReceiverService, subscriptionService: apiDeps.SubscriptionService, notificationService: apiDeps.NotificationService, + silenceService: apiDeps.SilenceService, } } diff --git a/internal/jobs/mocks/notification_service.go b/internal/jobs/mocks/notification_service.go index ce6164ba..c5203263 100644 --- a/internal/jobs/mocks/notification_service.go +++ b/internal/jobs/mocks/notification_service.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -43,8 +43,8 @@ type NotificationService_RemoveIdempotencies_Call struct { } // RemoveIdempotencies is a helper method to define mock.On call -// - ctx context.Context -// - TTL time.Duration +// - ctx context.Context +// - TTL time.Duration func (_e *NotificationService_Expecter) RemoveIdempotencies(ctx interface{}, TTL interface{}) *NotificationService_RemoveIdempotencies_Call { return &NotificationService_RemoveIdempotencies_Call{Call: _e.mock.On("RemoveIdempotencies", ctx, TTL)} } diff --git a/internal/store/model/alerts.go b/internal/store/model/alerts.go index 4dd8539f..c65cb465 100644 --- a/internal/store/model/alerts.go +++ b/internal/store/model/alerts.go @@ -8,17 +8,18 @@ import ( ) type Alert struct { - ID uint64 `db:"id"` - NamespaceID sql.NullInt64 `db:"namespace_id"` - ProviderID uint64 `db:"provider_id"` - ResourceName string `db:"resource_name"` - MetricName string `db:"metric_name"` - MetricValue string `db:"metric_value"` - Severity string `db:"severity"` - Rule string `db:"rule"` - TriggeredAt time.Time `db:"triggered_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID uint64 `db:"id"` + NamespaceID sql.NullInt64 `db:"namespace_id"` + ProviderID uint64 `db:"provider_id"` + ResourceName string `db:"resource_name"` + MetricName string `db:"metric_name"` + MetricValue string `db:"metric_value"` + Severity string `db:"severity"` + Rule string `db:"rule"` + TriggeredAt time.Time `db:"triggered_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + SilenceStatus sql.NullString `db:"silence_status"` } func (a *Alert) FromDomain(alrt alert.Alert) { @@ -32,6 +33,7 @@ func (a *Alert) FromDomain(alrt alert.Alert) { a.TriggeredAt = alrt.TriggeredAt a.CreatedAt = alrt.CreatedAt a.UpdatedAt = alrt.UpdatedAt + a.SilenceStatus = sql.NullString{Valid: true, String: alrt.SilenceStatus} if alrt.NamespaceID == 0 { a.NamespaceID = sql.NullInt64{ @@ -47,16 +49,17 @@ func (a *Alert) FromDomain(alrt alert.Alert) { func (a *Alert) ToDomain() *alert.Alert { alrt := &alert.Alert{ - ID: a.ID, - ProviderID: a.ProviderID, - ResourceName: a.ResourceName, - MetricName: a.MetricName, - MetricValue: a.MetricValue, - Severity: a.Severity, - Rule: a.Rule, - TriggeredAt: a.TriggeredAt, - CreatedAt: a.CreatedAt, - UpdatedAt: a.UpdatedAt, + ID: a.ID, + ProviderID: a.ProviderID, + ResourceName: a.ResourceName, + MetricName: a.MetricName, + MetricValue: a.MetricValue, + Severity: a.Severity, + Rule: a.Rule, + TriggeredAt: a.TriggeredAt, + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, + SilenceStatus: a.SilenceStatus.String, } if a.NamespaceID.Valid { diff --git a/internal/store/model/idempotency.go b/internal/store/model/idempotency.go index ff3c8dc4..a683b30d 100644 --- a/internal/store/model/idempotency.go +++ b/internal/store/model/idempotency.go @@ -3,7 +3,7 @@ package model import ( "time" - "github.com/odpf/siren/core/idempotency" + "github.com/odpf/siren/core/notification" ) type Idempotency struct { @@ -15,8 +15,8 @@ type Idempotency struct { UpdatedAt time.Time `db:"updated_at"` } -func (i *Idempotency) ToDomain() *idempotency.Idempotency { - return &idempotency.Idempotency{ +func (i *Idempotency) ToDomain() *notification.Idempotency { + return ¬ification.Idempotency{ ID: i.ID, Scope: i.Scope, Key: i.Key, diff --git a/internal/store/model/log.go b/internal/store/model/log.go new file mode 100644 index 00000000..9ecad2cc --- /dev/null +++ b/internal/store/model/log.go @@ -0,0 +1,56 @@ +package model + +import ( + "database/sql" + "time" + + "github.com/lib/pq" + "github.com/odpf/siren/core/log" +) + +type NotificationLog struct { + ID string `db:"id"` + NamespaceID sql.NullInt64 `db:"namespace_id"` + NotificationID string `db:"notification_id"` + SubscriptionID uint64 `db:"subscription_id"` + ReceiverID sql.NullInt64 `db:"receiver_id"` + AlertIDs pq.Int64Array `db:"alert_ids"` + SilenceIDs pq.StringArray `db:"silence_ids"` + CreatedAt time.Time `db:"created_at"` +} + +func (ns *NotificationLog) FromDomain(d log.Notification) { + ns.ID = d.ID + + if d.NamespaceID == 0 { + ns.NamespaceID = sql.NullInt64{Valid: false} + } else { + ns.NamespaceID = sql.NullInt64{Valid: true, Int64: int64(d.NamespaceID)} + } + + ns.NotificationID = d.NotificationID + ns.SubscriptionID = d.SubscriptionID + ns.AlertIDs = pq.Int64Array(d.AlertIDs) + ns.SilenceIDs = pq.StringArray(d.SilenceIDs) + + if d.ReceiverID == 0 { + ns.ReceiverID = sql.NullInt64{Valid: false} + } else { + ns.ReceiverID = sql.NullInt64{Valid: true, Int64: int64(d.ReceiverID)} + } + + ns.CreatedAt = d.CreatedAt +} + +func (ns *NotificationLog) ToDomain() log.Notification { + return log.Notification{ + ID: ns.ID, + NamespaceID: uint64(ns.NamespaceID.Int64), + NotificationID: ns.NotificationID, + SubscriptionID: ns.SubscriptionID, + ReceiverID: uint64(ns.ReceiverID.Int64), + AlertIDs: ns.AlertIDs, + SilenceIDs: ns.SilenceIDs, + CreatedAt: ns.CreatedAt, + } +} diff --git a/internal/store/model/notification.go b/internal/store/model/notification.go new file mode 100644 index 00000000..12a00920 --- /dev/null +++ b/internal/store/model/notification.go @@ -0,0 +1,55 @@ +package model + +import ( + "database/sql" + "time" + + "github.com/odpf/siren/core/notification" + "github.com/odpf/siren/pkg/pgc" +) + +type Notification struct { + ID string `db:"id"` + NamespaceID sql.NullInt64 `db:"namespace_id"` + Type string `db:"type"` + Data pgc.StringInterfaceMap `db:"data"` + Labels pgc.StringStringMap `db:"labels"` + ValidDuration pgc.TimeDuration `db:"valid_duration"` + Template sql.NullString `db:"template"` + CreatedAt time.Time `db:"created_at"` +} + +func (n *Notification) FromDomain(d notification.Notification) { + n.ID = d.ID + n.Type = d.Type + n.Data = d.Data + n.Labels = d.Labels + n.ValidDuration = pgc.TimeDuration(d.ValidDuration) + + if d.NamespaceID == 0 { + n.NamespaceID = sql.NullInt64{Valid: false} + } else { + n.NamespaceID = sql.NullInt64{Int64: int64(d.NamespaceID), Valid: true} + } + + if d.Template == "" { + n.Template = sql.NullString{Valid: false} + } else { + n.Template = sql.NullString{String: d.Template, Valid: true} + } + + n.CreatedAt = d.CreatedAt +} + +func (n *Notification) ToDomain() notification.Notification { + return notification.Notification{ + ID: n.ID, + NamespaceID: uint64(n.NamespaceID.Int64), + Type: n.Type, + Data: n.Data, + Labels: n.Labels, + ValidDuration: time.Duration(n.ValidDuration), + Template: n.Template.String, + CreatedAt: n.CreatedAt, + } +} diff --git a/internal/store/model/silence.go b/internal/store/model/silence.go new file mode 100644 index 00000000..50e48c53 --- /dev/null +++ b/internal/store/model/silence.go @@ -0,0 +1,67 @@ +package model + +import ( + "database/sql" + "time" + + "github.com/odpf/siren/core/silence" + "github.com/odpf/siren/pkg/pgc" +) + +type Silence struct { + ID string `db:"id"` + NamespaceID uint64 `db:"namespace_id"` + Type string `db:"type"` + TargetID sql.NullInt64 `db:"target_id"` + TargetExpression pgc.StringInterfaceMap `db:"target_expression"` + Creator sql.NullString `db:"creator"` + Comment sql.NullString `db:"comment"` + CreatedAt time.Time `db:"created_at"` + DeletedAt sql.NullTime `db:"deleted_at"` +} + +func (s *Silence) FromDomain(sil silence.Silence) { + s.ID = sil.ID + s.NamespaceID = sil.NamespaceID + s.Type = sil.Type + + if sil.TargetID == 0 { + s.TargetID = sql.NullInt64{Valid: false} + } else { + s.TargetID = sql.NullInt64{Int64: int64(sil.TargetID), Valid: true} + } + + s.TargetExpression = pgc.StringInterfaceMap(sil.TargetExpression) + + if sil.Creator == "" { + s.Creator = sql.NullString{Valid: false} + } else { + s.Creator = sql.NullString{String: sil.Creator, Valid: true} + } + + if sil.Comment == "" { + s.Comment = sql.NullString{Valid: false} + } else { + s.Comment = sql.NullString{String: sil.Comment, Valid: true} + } + + s.CreatedAt = sil.CreatedAt + + if sil.DeletedAt.IsZero() { + s.DeletedAt = sql.NullTime{Valid: false} + } else { + s.DeletedAt = sql.NullTime{Time: sil.DeletedAt, Valid: true} + } +} + +func (s *Silence) ToDomain() *silence.Silence { + return &silence.Silence{ + ID: s.ID, + NamespaceID: s.NamespaceID, + Type: s.Type, + TargetID: uint64(s.TargetID.Int64), + TargetExpression: s.TargetExpression, + CreatedAt: s.CreatedAt, + DeletedAt: s.DeletedAt.Time, + } +} diff --git a/internal/store/postgres/alerts.go b/internal/store/postgres/alerts.go index 6b04b524..3d2d8421 100644 --- a/internal/store/postgres/alerts.go +++ b/internal/store/postgres/alerts.go @@ -5,6 +5,7 @@ import ( "time" sq "github.com/Masterminds/squirrel" + "github.com/lib/pq" "github.com/odpf/siren/core/alert" "github.com/odpf/siren/internal/store/model" "github.com/odpf/siren/pkg/errors" @@ -17,6 +18,9 @@ INSERT INTO alerts (provider_id, namespace_id, resource_name, metric_name, metri RETURNING * ` +const alertUpdateBulkSilenceQuery = ` +UPDATE alerts SET silence_status = $1, updated_at = now() WHERE id = any($2)` + var alertListQueryBuilder = sq.Select( "id", "provider_id", @@ -28,6 +32,7 @@ var alertListQueryBuilder = sq.Select( "triggered_at", "created_at", "updated_at", + "silence_status", ).From("alerts") // AlertRepository talks to the store to read or insert data @@ -68,6 +73,11 @@ func (r AlertRepository) Create(ctx context.Context, alrt alert.Alert) (alert.Al func (r AlertRepository) List(ctx context.Context, flt alert.Filter) ([]alert.Alert, error) { var queryBuilder = alertListQueryBuilder + + if len(flt.IDs) != 0 { + queryBuilder = queryBuilder.Where("id = any(?)", pq.Array(flt.IDs)) + } + if flt.NamespaceID != 0 { queryBuilder = queryBuilder.Where("namespace_id = ?", flt.NamespaceID) } @@ -106,3 +116,15 @@ func (r AlertRepository) List(ctx context.Context, flt alert.Filter) ([]alert.Al return alertsDomain, nil } + +func (r AlertRepository) BulkUpdateSilence(ctx context.Context, alertIDs []int64, silenceStatus string) error { + sqlAlertIDs := pq.Array(alertIDs) + if _, err := r.client.ExecContext(ctx, pgc.OpUpdate, r.tableName, alertUpdateBulkSilenceQuery, + silenceStatus, + sqlAlertIDs, + ); err != nil { + return err + } + + return nil +} diff --git a/internal/store/postgres/alerts_test.go b/internal/store/postgres/alerts_test.go index e86cc04c..97022bae 100644 --- a/internal/store/postgres/alerts_test.go +++ b/internal/store/postgres/alerts_test.go @@ -34,6 +34,7 @@ func (s *AlertsRepositoryTestSuite) SetupSuite() { dockertestx.PostgresWithDetail( pgUser, pgPass, pgDBName, ), + dockertestx.PostgresWithVersionTag("13"), ) if err != nil { s.T().Fatal(err) @@ -55,6 +56,7 @@ func (s *AlertsRepositoryTestSuite) SetupSuite() { s.ctx = context.TODO() s.Require().NoError(migrate(s.ctx, logger, s.client, dbConfig)) + s.repository = postgres.NewAlertRepository(s.client) _, err = bootstrapProvider(s.client) @@ -64,8 +66,8 @@ func (s *AlertsRepositoryTestSuite) SetupSuite() { } func (s *AlertsRepositoryTestSuite) SetupTest() { - var err error - if err = bootstrapAlert(s.client); err != nil { + _, err := bootstrapAlert(s.client) + if err != nil { s.T().Fatal(err) } } @@ -202,6 +204,76 @@ func (s *AlertsRepositoryTestSuite) TestCreate() { } } +func (s *AlertsRepositoryTestSuite) TestBulkUpdateSilence() { + type testCase struct { + Description string + SilenceStatus string + ExpectedAlerts []alert.Alert + ErrString string + } + + var testCases = []testCase{ + { + Description: "should update 2 alerts to silence", + SilenceStatus: alert.SilenceStatusTotal, + ExpectedAlerts: []alert.Alert{ + { + ID: 2, + ProviderID: 1, + ResourceName: "odpf-kafka-2", + MetricName: "cpu_usage_user", + MetricValue: "97.95", + Severity: "WARNING", + Rule: "cpu-usage", + SilenceStatus: alert.SilenceStatusTotal, + }, + { + ID: 3, + ProviderID: 1, + ResourceName: "odpf-kafka-1", + MetricName: "cpu_usage_user", + MetricValue: "98.30", + Severity: "CRITICAL", + Rule: "cpu-usage", + SilenceStatus: alert.SilenceStatusTotal, + }, + }, + }, + { + Description: "should return error foreign key if provider id does not exist", + ErrString: "err", + }, + } + + for _, tc := range testCases { + s.Run(tc.Description, func() { + err := s.repository.BulkUpdateSilence(s.ctx, []int64{2, 3}, tc.SilenceStatus) + if err != nil { + if err.Error() != tc.ErrString { + s.T().Fatalf("got error %s, expected was %s", err.Error(), tc.ErrString) + } + } + if len(tc.ExpectedAlerts) != 0 { + alerts, err := s.repository.List(s.ctx, alert.Filter{ + SilenceID: "", + }) + s.Assert().NoError(err) + + var silencedAlerts []alert.Alert + for _, al := range alerts { + if al.SilenceStatus != "" { + silencedAlerts = append(silencedAlerts, al) + } + } + + if diff := cmp.Diff(silencedAlerts, tc.ExpectedAlerts, cmpopts.IgnoreFields(alert.Alert{}, "TriggeredAt", "CreatedAt", "UpdatedAt")); diff != "" { + s.T().Fatalf("got diff %v", diff) + } + } + }) + } +} + func TestAlertsRepository(t *testing.T) { suite.Run(t, new(AlertsRepositoryTestSuite)) } diff --git a/internal/store/postgres/bootstrap_test.go b/internal/store/postgres/bootstrap_test.go index cd0a3e84..5661f8d4 100644 --- a/internal/store/postgres/bootstrap_test.go +++ b/internal/store/postgres/bootstrap_test.go @@ -7,12 +7,15 @@ import ( "os" "github.com/odpf/salt/db" - "github.com/odpf/salt/log" + saltlog "github.com/odpf/salt/log" "github.com/odpf/siren/core/alert" + "github.com/odpf/siren/core/log" "github.com/odpf/siren/core/namespace" + "github.com/odpf/siren/core/notification" "github.com/odpf/siren/core/provider" "github.com/odpf/siren/core/receiver" "github.com/odpf/siren/core/rule" + "github.com/odpf/siren/core/silence" "github.com/odpf/siren/core/subscription" "github.com/odpf/siren/core/template" "github.com/odpf/siren/internal/store/postgres" @@ -43,7 +46,7 @@ func purgeDocker(pool *dockertest.Pool, resource *dockertest.Resource) error { return nil } -func migrate(ctx context.Context, logger log.Logger, client *pgc.Client, dbConf db.Config) error { +func migrate(ctx context.Context, logger saltlog.Logger, client *pgc.Client, dbConf db.Config) error { var queries = []string{ "DROP SCHEMA public CASCADE", "CREATE SCHEMA public", @@ -156,28 +159,30 @@ func bootstrapReceiver(client *pgc.Client) ([]receiver.Receiver, error) { return insertedData, nil } -func bootstrapAlert(client *pgc.Client) error { +func bootstrapAlert(client *pgc.Client) ([]alert.Alert, error) { filePath := "./testdata/mock-alert.json" testFixtureJSON, err := os.ReadFile(filePath) if err != nil { - return err + return nil, err } var data []alert.Alert if err = json.Unmarshal(testFixtureJSON, &data); err != nil { - return err + return nil, err } repo := postgres.NewAlertRepository(client) + var createdAlerts []alert.Alert for _, d := range data { - _, err := repo.Create(context.Background(), d) + alrt, err := repo.Create(context.Background(), d) if err != nil { - return err + return nil, err } + createdAlerts = append(createdAlerts, alrt) } - return nil + return createdAlerts, nil } func bootstrapTemplate(client *pgc.Client) ([]template.Template, error) { @@ -260,3 +265,107 @@ func bootstrapSubscription(client *pgc.Client) ([]subscription.Subscription, err return insertedData, nil } + +func bootstrapNotification(client *pgc.Client) ([]notification.Notification, error) { + filePath := "./testdata/mock-notification.json" + testFixtureJSON, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + var data []notification.Notification + if err = json.Unmarshal(testFixtureJSON, &data); err != nil { + return nil, err + } + + repo := postgres.NewNotificationRepository(client) + + var insertedData []notification.Notification + for _, d := range data { + newD, err := repo.Create(context.Background(), d) + if err != nil { + return nil, err + } + + insertedData = append(insertedData, newD) + } + + return insertedData, nil +} + +func bootstrapSilence(client *pgc.Client) ([]string, error) { + filePath := "./testdata/mock-silence.json" + testFixtureJSON, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + var data []silence.Silence + if err = json.Unmarshal(testFixtureJSON, &data); err != nil { + return nil, err + } + + repo := postgres.NewSilenceRepository(client) + + var silenceIDs []string + for _, d := range data { + id, err := repo.Create(context.Background(), d) + if err != nil { + return nil, err + } + + silenceIDs = append(silenceIDs, id) + } + + return silenceIDs, nil +} + +func bootstrapNotificationLog( + client *pgc.Client, + namespaces []namespace.EncryptedNamespace, + subscriptions []subscription.Subscription, + receivers []receiver.Receiver, + silenceIDs []string, + notifications []notification.Notification, + alerts []alert.Alert, +) error { + var data = []log.Notification{ + { + NamespaceID: namespaces[0].ID, + NotificationID: notifications[0].ID, + SubscriptionID: subscriptions[0].ID, + ReceiverID: receivers[0].ID, + AlertIDs: []int64{int64(alerts[0].ID)}, + SilenceIDs: []string{silenceIDs[0], silenceIDs[1]}, + }, + { + NamespaceID: namespaces[1].ID, + NotificationID: notifications[1].ID, + SubscriptionID: subscriptions[1].ID, + ReceiverID: receivers[1].ID, + AlertIDs: []int64{int64(alerts[1].ID)}, + SilenceIDs: []string{silenceIDs[1]}, + }, + { + NamespaceID: namespaces[2].ID, + NotificationID: notifications[1].ID, + SubscriptionID: subscriptions[2].ID, + AlertIDs: []int64{int64(alerts[0].ID), int64(alerts[2].ID)}, + SilenceIDs: []string{silenceIDs[0]}, + }, + { + NamespaceID: namespaces[2].ID, + NotificationID: notifications[1].ID, + SubscriptionID: subscriptions[2].ID, + AlertIDs: []int64{int64(alerts[0].ID), int64(alerts[2].ID)}, + }, + } + + repo := postgres.NewLogRepository(client) + + if err := repo.BulkCreate(context.Background(), data); err != nil { + return err + } + + return nil +} diff --git a/internal/store/postgres/idempotency.go b/internal/store/postgres/idempotency.go index 68f248ac..81826df1 100644 --- a/internal/store/postgres/idempotency.go +++ b/internal/store/postgres/idempotency.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/odpf/siren/core/idempotency" + "github.com/odpf/siren/core/notification" "github.com/odpf/siren/internal/store/model" "github.com/odpf/siren/pkg/errors" "github.com/odpf/siren/pkg/pgc" @@ -36,7 +36,7 @@ func NewIdempotencyRepository(client *pgc.Client) *IdempotencyRepository { return &IdempotencyRepository{client, "idempotencies"} } -func (r *IdempotencyRepository) InsertOnConflictReturning(ctx context.Context, scope, key string) (*idempotency.Idempotency, error) { +func (r *IdempotencyRepository) InsertOnConflictReturning(ctx context.Context, scope, key string) (*notification.Idempotency, error) { var idempotencyModel model.Idempotency if err := r.client.QueryRowxContext(ctx, pgc.OpInsert, r.tableName, idempotencyInsertQuery, scope, key, @@ -67,7 +67,7 @@ func (r *IdempotencyRepository) UpdateSuccess(ctx context.Context, id uint64, su return nil } -func (r *IdempotencyRepository) Delete(ctx context.Context, filter idempotency.Filter) error { +func (r *IdempotencyRepository) Delete(ctx context.Context, filter notification.IdempotencyFilter) error { if filter.TTL == 0 { return errors.ErrInvalid.WithCausef("cannot delete with ttl 0") diff --git a/internal/store/postgres/idempotency_test.go b/internal/store/postgres/idempotency_test.go index 45ef1fdf..0a559a9c 100644 --- a/internal/store/postgres/idempotency_test.go +++ b/internal/store/postgres/idempotency_test.go @@ -13,7 +13,7 @@ import ( "github.com/ory/dockertest/v3" "github.com/stretchr/testify/suite" - "github.com/odpf/siren/core/idempotency" + "github.com/odpf/siren/core/notification" "github.com/odpf/siren/internal/store/postgres" "github.com/odpf/siren/pkg/errors" "github.com/odpf/siren/pkg/pgc" @@ -36,6 +36,7 @@ func (s *IdempotencyRepositoryTestSuite) SetupSuite() { dockertestx.PostgresWithDetail( pgUser, pgPass, pgDBName, ), + dockertestx.PostgresWithVersionTag("13"), ) if err != nil { s.T().Fatal(err) @@ -80,7 +81,7 @@ func (s *IdempotencyRepositoryTestSuite) cleanup() error { } func (s *IdempotencyRepositoryTestSuite) TestInsertReturnOnConflict() { - data1 := &idempotency.Idempotency{ + data1 := ¬ification.Idempotency{ Scope: "a-scope", Key: "key-1", } @@ -96,14 +97,14 @@ func (s *IdempotencyRepositoryTestSuite) TestInsertReturnOnConflict() { res, err := s.repository.InsertOnConflictReturning(context.Background(), data1.Scope, data1.Key) s.Assert().NoError(err) - if diff := cmp.Diff(data1, res, cmpopts.IgnoreFields(idempotency.Idempotency{}, "UpdatedAt")); diff != "" { + if diff := cmp.Diff(data1, res, cmpopts.IgnoreFields(notification.Idempotency{}, "UpdatedAt")); diff != "" { s.T().Error(diff) } }) } func (s *IdempotencyRepositoryTestSuite) TestUpdateSuccess() { - data := &idempotency.Idempotency{ + data := ¬ification.Idempotency{ Scope: "a-scope", Key: "existing-key-1", } @@ -153,14 +154,14 @@ func (s *IdempotencyRepositoryTestSuite) TestDelete() { s.Require().Nil(err) s.Run("should return not found if no rows outside TTL", func() { - err := s.repository.Delete(s.ctx, idempotency.Filter{ + err := s.repository.Delete(s.ctx, notification.IdempotencyFilter{ TTL: time.Hour * time.Duration(10000), }) s.Assert().EqualError(err, errors.ErrNotFound.Error()) }) s.Run("should remove all idempotencies that are outside TTL", func() { - err := s.repository.Delete(s.ctx, idempotency.Filter{ + err := s.repository.Delete(s.ctx, notification.IdempotencyFilter{ TTL: time.Second * time.Duration(60), }) s.Assert().Nil(err) diff --git a/internal/store/postgres/log.go b/internal/store/postgres/log.go new file mode 100644 index 00000000..269311bf --- /dev/null +++ b/internal/store/postgres/log.go @@ -0,0 +1,156 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/odpf/siren/core/log" + "github.com/odpf/siren/internal/store/model" + "github.com/odpf/siren/pkg/errors" + "github.com/odpf/siren/pkg/pgc" +) + +const notificationLogTableName = "notification_log" + +const notificationLogInsertNamedQuery = ` +INSERT INTO notification_log + (namespace_id, notification_id, subscription_id, alert_ids, receiver_id, silence_ids, created_at) + VALUES (:namespace_id, :notification_id, :subscription_id, :alert_ids, :receiver_id, :silence_ids, now()) +` + +// LogRepository talks to the store to read or insert data +type LogRepository struct { + client *pgc.Client +} + +// NewLogRepository returns LogRepository struct +func NewLogRepository(client *pgc.Client) *LogRepository { + return &LogRepository{ + client: client, + } +} + +func (r *LogRepository) ListAlertIDsBySilenceID(ctx context.Context, silenceID string) ([]int64, error) { + rows, err := r.client.QueryxContext(ctx, pgc.OpSelectAll, notificationLogTableName, fmt.Sprintf(` + SELECT distinct unnest(alert_ids) AS alert_ids FROM %s WHERE silence_ids @> '{%s}' + `, notificationLogTableName, silenceID)) + if err != nil { + return nil, err + } + defer rows.Close() + + var alertIDs []int64 + for rows.Next() { + var alertID int64 + if err := rows.Scan(&alertID); err != nil { + return nil, err + } + + alertIDs = append(alertIDs, alertID) + } + + return alertIDs, nil +} + +func (r *LogRepository) ListSubscriptionIDsBySilenceID(ctx context.Context, silenceID string) ([]int64, error) { + rows, err := r.client.QueryxContext(ctx, pgc.OpSelectAll, notificationLogTableName, fmt.Sprintf(` + SELECT distinct subscription_id FROM %s WHERE silence_ids @> '{%s}' + `, notificationLogTableName, silenceID)) + if err != nil { + return nil, err + } + defer rows.Close() + + var subscriptionIDs []int64 + for rows.Next() { + var subscriptionID int64 + if err := rows.Scan(&subscriptionID); err != nil { + return nil, err + } + + subscriptionIDs = append(subscriptionIDs, subscriptionID) + } + + return subscriptionIDs, nil +} + +func (r *LogRepository) BulkCreate(ctx context.Context, nss []log.Notification) error { + nssModel := []model.NotificationLog{} + for _, ns := range nss { + nsModel := new(model.NotificationLog) + nsModel.FromDomain(ns) + + nssModel = append(nssModel, *nsModel) + } + + res, err := r.client.NamedExecContext(ctx, pgc.OpInsert, notificationLogTableName, notificationLogInsertNamedQuery, nssModel) + if err != nil { + return err + } + rowsAffected, err := res.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return errors.New("no rows affected when inserting notification subscribers") + } + return nil +} + +// func (r *SubscriptionRepository) Get(ctx context.Context, id uint64) (*subscription.Subscription, error) { +// query, args, err := subscriptionListQueryBuilder.Where("id = ?", id).PlaceholderFormat(sq.Dollar).ToSql() +// if err != nil { +// return nil, err +// } + +// var subscriptionModel model.Subscription +// if err := r.client.QueryRowxContext(ctx, pgc.OpSelect, r.tableName, query, args...).StructScan(&subscriptionModel); err != nil { +// if errors.Is(err, sql.ErrNoRows) { +// return nil, subscription.NotFoundError{ID: id} +// } +// return nil, err +// } + +// return subscriptionModel.ToDomain(), nil +// } + +// func (r *SubscriptionRepository) Update(ctx context.Context, sub *subscription.Subscription) error { +// if sub == nil { +// return errors.New("subscription domain is nil") +// } + +// subscriptionModel := new(model.Subscription) +// subscriptionModel.FromDomain(*sub) + +// var newSubscriptionModel model.Subscription +// if err := r.client.QueryRowxContext(ctx, pgc.OpUpdate, r.tableName, subscriptionUpdateQuery, +// subscriptionModel.ID, +// subscriptionModel.NamespaceID, +// subscriptionModel.URN, +// subscriptionModel.Receiver, +// subscriptionModel.Match, +// ).StructScan(&newSubscriptionModel); err != nil { +// err := pgc.CheckError(err) +// if errors.Is(err, sql.ErrNoRows) { +// return subscription.NotFoundError{ID: subscriptionModel.ID} +// } +// if errors.Is(err, pgc.ErrDuplicateKey) { +// return subscription.ErrDuplicate +// } +// if errors.Is(err, pgc.ErrForeignKeyViolation) { +// return subscription.ErrRelation +// } +// return err +// } + +// *sub = *newSubscriptionModel.ToDomain() + +// return nil +// } + +// func (r *SubscriptionRepository) Delete(ctx context.Context, id uint64) error { +// if _, err := r.client.ExecContext(ctx, pgc.OpDelete, r.tableName, subscriptionDeleteQuery, id); err != nil { +// return err +// } +// return nil +// } diff --git a/internal/store/postgres/log_test.go b/internal/store/postgres/log_test.go new file mode 100644 index 00000000..72e0a1ca --- /dev/null +++ b/internal/store/postgres/log_test.go @@ -0,0 +1,254 @@ +package postgres_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/odpf/salt/db" + "github.com/odpf/salt/dockertestx" + saltlog "github.com/odpf/salt/log" + "github.com/odpf/siren/core/alert" + "github.com/odpf/siren/core/log" + "github.com/odpf/siren/core/namespace" + "github.com/odpf/siren/core/notification" + "github.com/odpf/siren/core/receiver" + "github.com/odpf/siren/core/subscription" + "github.com/odpf/siren/internal/store/postgres" + "github.com/odpf/siren/pkg/pgc" + "github.com/ory/dockertest/v3" + "github.com/stretchr/testify/suite" +) + +type LogRepositoryTestSuite struct { + suite.Suite + ctx context.Context + client *pgc.Client + pool *dockertest.Pool + resource *dockertest.Resource + repository *postgres.LogRepository + + namespaces []namespace.EncryptedNamespace + subscriptions []subscription.Subscription + receivers []receiver.Receiver + silencesIDs []string + notifications []notification.Notification + alerts []alert.Alert +} + +func (s *LogRepositoryTestSuite) SetupSuite() { + var err error + + logger := saltlog.NewZap() + dpg, err := dockertestx.CreatePostgres( + dockertestx.PostgresWithDetail( + pgUser, pgPass, pgDBName, + ), + dockertestx.PostgresWithVersionTag("13"), + ) + if err != nil { + s.T().Fatal(err) + } + + s.pool = dpg.GetPool() + s.resource = dpg.GetResource() + + dbConfig.URL = dpg.GetExternalConnString() + dbc, err := db.New(dbConfig) + if err != nil { + s.T().Fatal(err) + } + + s.client, err = pgc.NewClient(logger, dbc) + if err != nil { + s.T().Fatal(err) + } + + s.ctx = context.TODO() + s.Require().NoError(migrate(s.ctx, logger, s.client, dbConfig)) + s.repository = postgres.NewLogRepository(s.client) + + _, err = bootstrapProvider(s.client) + if err != nil { + s.T().Fatal(err) + } + s.namespaces, err = bootstrapNamespace(s.client) + if err != nil { + s.T().Fatal(err) + } + s.subscriptions, err = bootstrapSubscription(s.client) + if err != nil { + s.T().Fatal(err) + } + s.receivers, err = bootstrapReceiver(s.client) + if err != nil { + s.T().Fatal(err) + } + s.notifications, err = bootstrapNotification(s.client) + if err != nil { + s.T().Fatal(err) + } + s.alerts, err = bootstrapAlert(s.client) + if err != nil { + s.T().Fatal(err) + } + s.silencesIDs, err = bootstrapSilence(s.client) + if err != nil { + s.T().Fatal(err) + } +} + +func (s *LogRepositoryTestSuite) SetupTest() { + if err := bootstrapNotificationLog( + s.client, + s.namespaces, + s.subscriptions, + s.receivers, + s.silencesIDs, + s.notifications, + s.alerts, + ); err != nil { + s.T().Fatal(err) + } +} + +func (s *LogRepositoryTestSuite) TearDownSuite() { + // Clean tests + if err := purgeDocker(s.pool, s.resource); err != nil { + s.T().Fatal(err) + } +} + +func (s *LogRepositoryTestSuite) TearDownTest() { + if err := s.cleanup(); err != nil { + s.T().Fatal(err) + } +} + +func (s *LogRepositoryTestSuite) cleanup() error { + queries := []string{ + "TRUNCATE TABLE notification_log RESTART IDENTITY CASCADE", + } + return execQueries(context.TODO(), s.client, queries) +} + +func (s *LogRepositoryTestSuite) TestCreate() { + type testCase struct { + Description string + ItemsToCreate []log.Notification + ErrString string + } + + var testCases = []testCase{ + { + Description: "should create a notification_log", + ItemsToCreate: []log.Notification{ + { + NamespaceID: 1, + NotificationID: s.notifications[0].ID, + SubscriptionID: 1, + }, + }, + }, + { + Description: "should return error if a notification_log is invalid", + ItemsToCreate: []log.Notification{{ + NamespaceID: 1111, + NotificationID: "nid", + SubscriptionID: 1111, + AlertIDs: []int64{11}, + }}, + ErrString: "pq: insert or update on table \"notification_log\" violates foreign key constraint \"notification_log_notification_id_fkey\"", + }, + } + + for _, tc := range testCases { + s.Run(tc.Description, func() { + err := s.repository.BulkCreate(s.ctx, tc.ItemsToCreate) + if err != nil { + if err.Error() != tc.ErrString { + s.T().Fatalf("got error %s, expected was %s", err.Error(), tc.ErrString) + } + } + }) + } +} + +func (s *LogRepositoryTestSuite) TestListAlertIDsBySilenceID() { + tests := []struct { + name string + silenceID string + want []int64 + wantErrString string + }{ + { + name: "should return list of alert id if silence id exist", + silenceID: s.silencesIDs[0], + want: []int64{ + int64(s.alerts[0].ID), + int64(s.alerts[2].ID), + }, + }, + { + name: "should return nil if silence id does not exist", + silenceID: "abc", + }, + } + for _, tt := range tests { + s.Run(tt.name, func() { + r := postgres.NewLogRepository(s.client) + got, err := r.ListAlertIDsBySilenceID(context.TODO(), tt.silenceID) + if err != nil { + s.Assert().Equal(tt.wantErrString, err.Error()) + return + } + if diff := cmp.Diff(got, tt.want, cmpopts.SortSlices(func(x int64, y int64) bool { + return x < y + })); diff != "" { + s.T().Errorf("LogRepository.ListAlertIDsBySilenceID() diff = %v", diff) + } + }) + } +} + +func (s *LogRepositoryTestSuite) TestListSubscriptionIDsBySilenceID() { + tests := []struct { + name string + silenceID string + want []int64 + wantErrString string + }{ + { + name: "should return list of subscription id if silence id exist", + silenceID: s.silencesIDs[0], + want: []int64{ + int64(s.subscriptions[0].ID), + int64(s.subscriptions[2].ID), + }, + }, + { + name: "should return nil if silence id does not exist", + silenceID: "abc", + }, + } + for _, tt := range tests { + s.Run(tt.name, func() { + r := postgres.NewLogRepository(s.client) + got, err := r.ListSubscriptionIDsBySilenceID(context.TODO(), tt.silenceID) + if err != nil { + s.Assert().Equal(tt.wantErrString, err.Error()) + return + } + if diff := cmp.Diff(got, tt.want, cmpopts.SortSlices(func(x int64, y int64) bool { + return x < y + })); diff != "" { + s.T().Errorf("LogRepository.ListSubscriptionIDsBySilenceID() diff = %v", diff) + } + }) + } +} + +func TestLogRepository(t *testing.T) { + suite.Run(t, new(LogRepositoryTestSuite)) +} diff --git a/internal/store/postgres/migrations/000002_create_index_subscription_matcher.up.sql b/internal/store/postgres/migrations/000002_create_index_subscription_matcher.up.sql index b8897bdd..1c7ba8d6 100644 --- a/internal/store/postgres/migrations/000002_create_index_subscription_matcher.up.sql +++ b/internal/store/postgres/migrations/000002_create_index_subscription_matcher.up.sql @@ -1 +1 @@ -CREATE INDEX IF NOT EXISTS subscriptions_idx_match ON subscriptions USING GIN(match jsonb_path_ops); \ No newline at end of file +CREATE INDEX IF NOT EXISTS subscriptions_idx_match ON subscriptions USING GIN(match jsonb_path_ops); \ No newline at end of file diff --git a/internal/store/postgres/migrations/000004_create_index_subscription_namespace.up.sql b/internal/store/postgres/migrations/000004_create_index_subscription_namespace.up.sql index 21bf8b6a..eb09d05a 100644 --- a/internal/store/postgres/migrations/000004_create_index_subscription_namespace.up.sql +++ b/internal/store/postgres/migrations/000004_create_index_subscription_namespace.up.sql @@ -1 +1 @@ -CREATE INDEX IF NOT EXISTS subscriptions_idx_namespace ON subscriptions(namespace_id); \ No newline at end of file +CREATE INDEX IF NOT EXISTS subscriptions_idx_namespace ON subscriptions(namespace_id); diff --git a/internal/store/postgres/migrations/000006_create_notifications.down.sql b/internal/store/postgres/migrations/000006_create_notifications.down.sql new file mode 100644 index 00000000..6df35ec6 --- /dev/null +++ b/internal/store/postgres/migrations/000006_create_notifications.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS notifications_idx_labels; +DROP INDEX IF EXISTS notifications_idx_template; + +DROP TABLE IF EXISTS notifications; \ No newline at end of file diff --git a/internal/store/postgres/migrations/000006_create_notifications.up.sql b/internal/store/postgres/migrations/000006_create_notifications.up.sql new file mode 100644 index 00000000..84025457 --- /dev/null +++ b/internal/store/postgres/migrations/000006_create_notifications.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS notifications ( + id text PRIMARY KEY DEFAULT gen_random_uuid(), + namespace_id bigint, + type text, + data jsonb, + labels jsonb, + valid_duration text, + template text, + created_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS notifications_idx_labels ON notifications USING GIN(labels jsonb_path_ops); +CREATE INDEX IF NOT EXISTS notifications_idx_template ON notifications (template); \ No newline at end of file diff --git a/internal/store/postgres/migrations/000007_create_silences.down.sql b/internal/store/postgres/migrations/000007_create_silences.down.sql new file mode 100644 index 00000000..8cc484c5 --- /dev/null +++ b/internal/store/postgres/migrations/000007_create_silences.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS silences_idx_namespace_id_type_target_id; +DROP INDEX IF EXISTS silences_idx_namespace_id_type_target_expression; + +DROP TABLE IF EXISTS subscriptions; \ No newline at end of file diff --git a/internal/store/postgres/migrations/000007_create_silences.up.sql b/internal/store/postgres/migrations/000007_create_silences.up.sql new file mode 100644 index 00000000..f4dbdcd5 --- /dev/null +++ b/internal/store/postgres/migrations/000007_create_silences.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS silences ( + id text PRIMARY KEY DEFAULT gen_random_uuid(), + namespace_id bigint REFERENCES namespaces(id), + type text, + target_id text, + target_expression jsonb, + creator text, + comment text, + created_at timestamptz NOT NULL, + deleted_at timestamptz +); + +CREATE INDEX IF NOT EXISTS silences_idx_namespace_id_type_target_id ON silences (namespace_id, type, target_id) WHERE type = 'subscription'; +CREATE INDEX IF NOT EXISTS silences_idx_namespace_id_type ON silences (namespace_id, type); +CREATE INDEX IF NOT EXISTS silences_idx_target_expression ON silences USING GIN(target_expression jsonb_path_ops) WHERE type = 'labels'; diff --git a/internal/store/postgres/migrations/000008_create_notification_log.down.sql b/internal/store/postgres/migrations/000008_create_notification_log.down.sql new file mode 100644 index 00000000..3ebb9220 --- /dev/null +++ b/internal/store/postgres/migrations/000008_create_notification_log.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS notification_log; \ No newline at end of file diff --git a/internal/store/postgres/migrations/000008_create_notification_log.up.sql b/internal/store/postgres/migrations/000008_create_notification_log.up.sql new file mode 100644 index 00000000..4729ae21 --- /dev/null +++ b/internal/store/postgres/migrations/000008_create_notification_log.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS notification_log ( + id text PRIMARY KEY DEFAULT gen_random_uuid(), + namespace_id bigint, + notification_id text REFERENCES notifications(id), + subscription_id bigint, + receiver_id bigint, + alert_ids bigint[], + silence_ids text[], + created_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS notification_log_idx_silence_ids ON notification_log USING GIN(silence_ids); \ No newline at end of file diff --git a/internal/store/postgres/migrations/000009_add_alert_silence_status_column.down.sql b/internal/store/postgres/migrations/000009_add_alert_silence_status_column.down.sql new file mode 100644 index 00000000..15bcacb0 --- /dev/null +++ b/internal/store/postgres/migrations/000009_add_alert_silence_status_column.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE + alerts +DROP COLUMN IF EXISTS silence_status; \ No newline at end of file diff --git a/internal/store/postgres/migrations/000009_add_alert_silence_status_column.up.sql b/internal/store/postgres/migrations/000009_add_alert_silence_status_column.up.sql new file mode 100644 index 00000000..e679e280 --- /dev/null +++ b/internal/store/postgres/migrations/000009_add_alert_silence_status_column.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE + alerts +ADD COLUMN IF NOT EXISTS silence_status text; \ No newline at end of file diff --git a/internal/store/postgres/namespace_test.go b/internal/store/postgres/namespace_test.go index 904402e9..aac920ec 100644 --- a/internal/store/postgres/namespace_test.go +++ b/internal/store/postgres/namespace_test.go @@ -34,6 +34,7 @@ func (s *NamespaceRepositoryTestSuite) SetupSuite() { dockertestx.PostgresWithDetail( pgUser, pgPass, pgDBName, ), + dockertestx.PostgresWithVersionTag("13"), ) if err != nil { s.T().Fatal(err) diff --git a/internal/store/postgres/notification.go b/internal/store/postgres/notification.go new file mode 100644 index 00000000..11eaea68 --- /dev/null +++ b/internal/store/postgres/notification.go @@ -0,0 +1,48 @@ +package postgres + +import ( + "context" + + "github.com/odpf/siren/core/notification" + "github.com/odpf/siren/internal/store/model" + "github.com/odpf/siren/pkg/pgc" +) + +const notificationInsertQuery = ` +INSERT INTO notifications (namespace_id, type, data, labels, valid_duration, template, created_at) + VALUES ($1, $2, $3, $4, $5, $6, now()) +RETURNING * +` + +// NotificationRepository talks to the store to read or insert data +type NotificationRepository struct { + client *pgc.Client + tableName string +} + +// NewNotificationRepository returns NotificationRepository struct +func NewNotificationRepository(client *pgc.Client) *NotificationRepository { + return &NotificationRepository{ + client: client, + tableName: "notifications", + } +} + +func (r *NotificationRepository) Create(ctx context.Context, n notification.Notification) (notification.Notification, error) { + nModel := new(model.Notification) + nModel.FromDomain(n) + + var newNModel model.Notification + if err := r.client.QueryRowxContext(ctx, pgc.OpInsert, r.tableName, notificationInsertQuery, + nModel.NamespaceID, + nModel.Type, + nModel.Data, + nModel.Labels, + nModel.ValidDuration, + nModel.Template, + ).StructScan(&newNModel); err != nil { + return notification.Notification{}, err + } + + return newNModel.ToDomain(), nil +} diff --git a/internal/store/postgres/notification_test.go b/internal/store/postgres/notification_test.go new file mode 100644 index 00000000..1e3db292 --- /dev/null +++ b/internal/store/postgres/notification_test.go @@ -0,0 +1,129 @@ +package postgres_test + +import ( + "context" + "testing" + "time" + + "github.com/odpf/salt/db" + "github.com/odpf/salt/dockertestx" + "github.com/odpf/salt/log" + "github.com/odpf/siren/core/notification" + "github.com/odpf/siren/internal/store/postgres" + "github.com/odpf/siren/pkg/pgc" + "github.com/ory/dockertest/v3" + "github.com/stretchr/testify/suite" +) + +type NotificationRepositoryTestSuite struct { + suite.Suite + ctx context.Context + client *pgc.Client + pool *dockertest.Pool + resource *dockertest.Resource + repository *postgres.NotificationRepository +} + +func (s *NotificationRepositoryTestSuite) SetupSuite() { + var err error + + logger := log.NewZap() + dpg, err := dockertestx.CreatePostgres( + dockertestx.PostgresWithDetail( + pgUser, pgPass, pgDBName, + ), + dockertestx.PostgresWithVersionTag("13"), + ) + if err != nil { + s.T().Fatal(err) + } + + s.pool = dpg.GetPool() + s.resource = dpg.GetResource() + + dbConfig.URL = dpg.GetExternalConnString() + dbc, err := db.New(dbConfig) + if err != nil { + s.T().Fatal(err) + } + + s.client, err = pgc.NewClient(logger, dbc) + if err != nil { + s.T().Fatal(err) + } + + s.ctx = context.TODO() + s.Require().NoError(migrate(s.ctx, logger, s.client, dbConfig)) + s.repository = postgres.NewNotificationRepository(s.client) +} + +func (s *NotificationRepositoryTestSuite) TearDownSuite() { + // Clean tests + if err := purgeDocker(s.pool, s.resource); err != nil { + s.T().Fatal(err) + } +} + +func (s *NotificationRepositoryTestSuite) TearDownTest() { + if err := s.cleanup(); err != nil { + s.T().Fatal(err) + } +} + +func (s *NotificationRepositoryTestSuite) cleanup() error { + queries := []string{ + "TRUNCATE TABLE notifications RESTART IDENTITY CASCADE", + } + return execQueries(context.TODO(), s.client, queries) +} + +func (s *NotificationRepositoryTestSuite) TestCreate() { + type testCase struct { + Description string + NotificationToCreate notification.Notification + ErrString string + } + + var testCases = []testCase{ + { + Description: "should create a notification", + NotificationToCreate: notification.Notification{ + NamespaceID: 1, + Type: notification.TypeReceiver, + Data: map[string]interface{}{}, + Labels: map[string]string{}, + CreatedAt: time.Now(), + }, + }, + { + Description: "should return error if a notification is invalid", + NotificationToCreate: notification.Notification{ + NamespaceID: 1, + Type: notification.TypeReceiver, + Data: map[string]interface{}{ + "k1": func(x chan struct{}) { + <-x + }, + }, + Labels: map[string]string{}, + CreatedAt: time.Now(), + }, + ErrString: "sql: converting argument $3 type: json: unsupported type: func(chan struct {})", + }, + } + + for _, tc := range testCases { + s.Run(tc.Description, func() { + _, err := s.repository.Create(s.ctx, tc.NotificationToCreate) + if err != nil { + if err.Error() != tc.ErrString { + s.T().Fatalf("got error %s, expected was %s", err.Error(), tc.ErrString) + } + } + }) + } +} + +func TestNotificationRepository(t *testing.T) { + suite.Run(t, new(NotificationRepositoryTestSuite)) +} diff --git a/internal/store/postgres/provider_test.go b/internal/store/postgres/provider_test.go index da6b5a75..13838c7f 100644 --- a/internal/store/postgres/provider_test.go +++ b/internal/store/postgres/provider_test.go @@ -33,6 +33,7 @@ func (s *ProviderRepositoryTestSuite) SetupSuite() { dockertestx.PostgresWithDetail( pgUser, pgPass, pgDBName, ), + dockertestx.PostgresWithVersionTag("13"), ) if err != nil { s.T().Fatal(err) diff --git a/internal/store/postgres/receiver_test.go b/internal/store/postgres/receiver_test.go index 01196f95..03d58f19 100644 --- a/internal/store/postgres/receiver_test.go +++ b/internal/store/postgres/receiver_test.go @@ -33,6 +33,7 @@ func (s *ReceiverRepositoryTestSuite) SetupSuite() { dockertestx.PostgresWithDetail( pgUser, pgPass, pgDBName, ), + dockertestx.PostgresWithVersionTag("13"), ) if err != nil { s.T().Fatal(err) diff --git a/internal/store/postgres/rule_test.go b/internal/store/postgres/rule_test.go index 41d4eb02..5fef9e55 100644 --- a/internal/store/postgres/rule_test.go +++ b/internal/store/postgres/rule_test.go @@ -33,6 +33,7 @@ func (s *RuleRepositoryTestSuite) SetupSuite() { dockertestx.PostgresWithDetail( pgUser, pgPass, pgDBName, ), + dockertestx.PostgresWithVersionTag("13"), ) if err != nil { s.T().Fatal(err) diff --git a/internal/store/postgres/silence.go b/internal/store/postgres/silence.go new file mode 100644 index 00000000..735bb48f --- /dev/null +++ b/internal/store/postgres/silence.go @@ -0,0 +1,148 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + sq "github.com/Masterminds/squirrel" + "github.com/odpf/siren/core/silence" + "github.com/odpf/siren/internal/store/model" + "github.com/odpf/siren/pkg/errors" + "github.com/odpf/siren/pkg/pgc" +) + +const silenceInsertQuery = ` +INSERT INTO silences (namespace_id, type, target_id, target_expression, creator, comment, created_at) + VALUES ($1, $2, $3, $4, $5, $6, now()) +RETURNING * +` + +var silenceListQueryBuilder = sq.Select( + "id", + "namespace_id", + "type", + "target_id", + "target_expression", + "creator", + "comment", + "created_at", + "deleted_at", +).From("silences") + +const silenceSoftDeleteQuery = ` +UPDATE silences SET deleted_at=now() +WHERE id = $1 AND deleted_at IS NULL +RETURNING * +` + +// SilenceRepository talks to the store to read or insert data +type SilenceRepository struct { + client *pgc.Client + tableName string +} + +// NewSilenceRepository returns repository struct +func NewSilenceRepository(client *pgc.Client) *SilenceRepository { + return &SilenceRepository{client, "silences"} +} + +func (r *SilenceRepository) Create(ctx context.Context, s silence.Silence) (string, error) { + sModel := new(model.Silence) + sModel.FromDomain(s) + + var newSModel model.Silence + if err := r.client.QueryRowxContext(ctx, pgc.OpInsert, r.tableName, silenceInsertQuery, + sModel.NamespaceID, + sModel.Type, + sModel.TargetID, + sModel.TargetExpression, + sModel.Creator, + sModel.Comment, + ).StructScan(&newSModel); err != nil { + err = pgc.CheckError(err) + if errors.Is(err, pgc.ErrForeignKeyViolation) { + return "", errors.ErrInvalid.WithMsgf(err.Error()) + } + return "", err + } + + return newSModel.ID, nil +} + +func (r *SilenceRepository) List(ctx context.Context, flt silence.Filter) ([]silence.Silence, error) { + var queryBuilder = silenceListQueryBuilder + + queryBuilder = queryBuilder.Where("deleted_at IS NULL") + + if flt.NamespaceID != 0 { + queryBuilder = queryBuilder.Where("namespace_id = ?", flt.NamespaceID) + } + + if flt.SubscriptionID != 0 { + queryBuilder = queryBuilder.Where("target_id = ?", flt.SubscriptionID) + } + + if len(flt.Match) != 0 { + labelsJSON, err := json.Marshal(flt.Match) + if err != nil { + return nil, errors.ErrInvalid.WithCausef("problem marshalling json match to string with err: %s", err.Error()) + } + queryBuilder = queryBuilder.Where(fmt.Sprintf("target_expression @> '%s'::jsonb", string(json.RawMessage(labelsJSON)))) + } + + if len(flt.SubscriptionMatch) != 0 { + labelsJSON, err := json.Marshal(flt.SubscriptionMatch) + if err != nil { + return nil, errors.ErrInvalid.WithCausef("problem marshalling json subscription labels to string with err: %s", err.Error()) + } + queryBuilder = queryBuilder.Where(fmt.Sprintf("target_expression <@ '%s'::jsonb", string(json.RawMessage(labelsJSON)))) + } + + query, args, err := queryBuilder.PlaceholderFormat(sq.Dollar).ToSql() + if err != nil { + return nil, err + } + + rows, err := r.client.QueryxContext(ctx, pgc.OpSelectAll, r.tableName, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var silencesDomain []silence.Silence + for rows.Next() { + var silenceModel model.Silence + if err := rows.StructScan(&silenceModel); err != nil { + return nil, err + } + + silencesDomain = append(silencesDomain, *silenceModel.ToDomain()) + } + + return silencesDomain, nil +} + +func (r *SilenceRepository) Get(ctx context.Context, id string) (silence.Silence, error) { + queryBuilder := silenceListQueryBuilder.Where("deleted_at IS NULL") + + query, args, err := queryBuilder.Where("id = ?", id).PlaceholderFormat(sq.Dollar).ToSql() + if err != nil { + return silence.Silence{}, err + } + + var modelSilence model.Silence + if err := r.client.GetContext(ctx, pgc.OpSelect, r.tableName, &modelSilence, query, args...); err != nil { + return silence.Silence{}, err + } + + return *modelSilence.ToDomain(), nil +} + +func (r *SilenceRepository) SoftDelete(ctx context.Context, id string) error { + if _, err := r.client.ExecContext(ctx, pgc.OpDelete, r.tableName, silenceSoftDeleteQuery, id); err != nil { + return err + } + + return nil +} diff --git a/internal/store/postgres/silence_test.go b/internal/store/postgres/silence_test.go new file mode 100644 index 00000000..f61b37aa --- /dev/null +++ b/internal/store/postgres/silence_test.go @@ -0,0 +1,416 @@ +package postgres_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/odpf/salt/db" + "github.com/odpf/salt/dockertestx" + "github.com/odpf/salt/log" + "github.com/odpf/siren/core/silence" + "github.com/odpf/siren/internal/store/postgres" + "github.com/odpf/siren/pkg/pgc" + "github.com/ory/dockertest/v3" + "github.com/stretchr/testify/suite" +) + +type SilenceRepositoryTestSuite struct { + suite.Suite + ctx context.Context + client *pgc.Client + pool *dockertest.Pool + resource *dockertest.Resource + repository *postgres.SilenceRepository + silenceIDs []string +} + +func (s *SilenceRepositoryTestSuite) SetupSuite() { + var err error + + logger := log.NewZap() + dpg, err := dockertestx.CreatePostgres( + dockertestx.PostgresWithDetail( + pgUser, pgPass, pgDBName, + ), + dockertestx.PostgresWithVersionTag("13"), + ) + if err != nil { + s.T().Fatal(err) + } + + s.pool = dpg.GetPool() + s.resource = dpg.GetResource() + + dbConfig.URL = dpg.GetExternalConnString() + dbc, err := db.New(dbConfig) + if err != nil { + s.T().Fatal(err) + } + + s.client, err = pgc.NewClient(logger, dbc) + if err != nil { + s.T().Fatal(err) + } + + s.ctx = context.TODO() + s.Require().NoError(migrate(s.ctx, logger, s.client, dbConfig)) + s.repository = postgres.NewSilenceRepository(s.client) + + _, err = bootstrapProvider(s.client) + if err != nil { + s.T().Fatal(err) + } + _, err = bootstrapNamespace(s.client) + if err != nil { + s.T().Fatal(err) + } + _, err = bootstrapSubscription(s.client) + if err != nil { + s.T().Fatal(err) + } +} + +func (s *SilenceRepositoryTestSuite) SetupTest() { + + var err error + s.silenceIDs, err = bootstrapSilence(s.client) + if err != nil { + s.T().Fatal(err) + } +} + +func (s *SilenceRepositoryTestSuite) TearDownSuite() { + // Clean tests + if err := purgeDocker(s.pool, s.resource); err != nil { + s.T().Fatal(err) + } +} + +func (s *SilenceRepositoryTestSuite) TearDownTest() { + if err := s.cleanup(); err != nil { + s.T().Fatal(err) + } +} + +func (s *SilenceRepositoryTestSuite) cleanup() error { + queries := []string{ + "TRUNCATE TABLE silences RESTART IDENTITY CASCADE", + } + return execQueries(context.TODO(), s.client, queries) +} + +func (s *SilenceRepositoryTestSuite) TestCreate() { + type testCase struct { + Description string + SilenceToCreate silence.Silence + ErrString string + } + + var testCases = []testCase{ + { + Description: "should create a silence type subscription", + SilenceToCreate: silence.Silence{ + NamespaceID: 1, + Type: silence.TypeSubscription, + TargetID: 1, + TargetExpression: map[string]interface{}{ + "rule": "true", + }, + }, + }, + { + Description: "should create a silence type labels", + SilenceToCreate: silence.Silence{ + NamespaceID: 1, + Type: silence.TypeMatchers, + TargetExpression: map[string]interface{}{ + "key1": "val1", + }, + }, + }, + { + Description: "should return error if a silence is invalid", + SilenceToCreate: silence.Silence{ + NamespaceID: 111, + Type: silence.TypeMatchers, + TargetExpression: map[string]interface{}{ + "key1": "val1", + }, + }, + ErrString: "foreign key violation [key (namespace_id)=(111) is not present in table \"namespaces\".]", + }, + } + + for _, tc := range testCases { + s.Run(tc.Description, func() { + _, err := s.repository.Create(s.ctx, tc.SilenceToCreate) + if err != nil { + if err.Error() != tc.ErrString { + s.T().Fatalf("got error %s, expected was %s", err.Error(), tc.ErrString) + } + } + }) + } +} + +func (s *SilenceRepositoryTestSuite) TestList() { + type testCase struct { + description string + expectedSilence []silence.Silence + filter silence.Filter + errString string + } + + var testCases = []testCase{ + { + description: "should get all silences", + expectedSilence: []silence.Silence{ + { + NamespaceID: 1, + Type: "subscription", + TargetID: 2, + TargetExpression: map[string]interface{}{ + "rule": "", + }, + }, + { + NamespaceID: 2, + Type: "labels", + TargetExpression: map[string]interface{}{ + "key1": "value1", + }, + }, + { + NamespaceID: 3, + Type: "labels", + TargetExpression: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + }, + { + NamespaceID: 2, + Type: "labels", + TargetExpression: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + }, + }, + }, + }, + { + description: "should return correct silences when filtered with namespace_id", + filter: silence.Filter{ + NamespaceID: 2, + }, + expectedSilence: []silence.Silence{ + { + NamespaceID: 2, + Type: "labels", + TargetExpression: map[string]interface{}{ + "key1": "value1", + }, + }, + { + NamespaceID: 2, + Type: "labels", + TargetExpression: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + }, + }, + }, + }, + { + description: "should return correct silences when filtered with subscription_id", + filter: silence.Filter{ + SubscriptionID: 2, + }, + expectedSilence: []silence.Silence{ + { + NamespaceID: 1, + Type: "subscription", + TargetID: 2, + TargetExpression: map[string]interface{}{ + "rule": "", + }, + }, + }, + }, + { + description: "should return correct silences when filtered with labels", + filter: silence.Filter{ + Match: map[string]string{ + "key1": "value1", + }, + }, + expectedSilence: []silence.Silence{ + { + NamespaceID: 2, + Type: "labels", + TargetExpression: map[string]interface{}{ + "key1": "value1", + }, + }, + { + NamespaceID: 3, + Type: "labels", + TargetExpression: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + }, + { + NamespaceID: 2, + Type: "labels", + TargetExpression: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + }, + }, + }, + }, + { + description: "should return correct silences when filtered with subscription labels", + filter: silence.Filter{ + SubscriptionMatch: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + expectedSilence: []silence.Silence{ + { + NamespaceID: 2, + Type: "labels", + TargetExpression: map[string]interface{}{ + "key1": "value1", + }, + }, + { + NamespaceID: 3, + Type: "labels", + TargetExpression: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + }, + }, + }, + } + + for _, tc := range testCases { + s.Run(tc.description, func() { + got, err := s.repository.List(s.ctx, tc.filter) + if tc.errString != "" { + if err.Error() != tc.errString { + s.T().Fatalf("got error %s, expected was %s", err.Error(), tc.errString) + } + } + if diff := cmp.Diff(got, tc.expectedSilence, cmpopts.IgnoreFields(silence.Silence{}, + "CreatedAt", "ID")); diff != "" { + s.T().Fatalf("got diff %+v", diff) + } + }) + } +} + +func (s *SilenceRepositoryTestSuite) TestGet() { + type testCase struct { + Description string + ID string + ExpectedSilence silence.Silence + ErrString string + } + + var testCases = []testCase{ + { + + Description: "should get a silences", + ID: s.silenceIDs[0], + ExpectedSilence: silence.Silence{ + ID: "", + NamespaceID: 1, + Type: "subscription", + TargetID: 2, + TargetExpression: map[string]interface{}{ + "rule": "", + }, + }, + }, + { + + Description: "should return error not found when id not exist", + ID: "not-exist", + ExpectedSilence: silence.Silence{ + ID: "", + NamespaceID: 1, + Type: "subscription", + TargetID: 2, + TargetExpression: map[string]interface{}{ + "rule": "", + }, + }, + ErrString: "sql: no rows in result set", + }, + } + + for _, tc := range testCases { + s.Run(tc.Description, func() { + got, err := s.repository.Get(s.ctx, tc.ID) + if tc.ErrString != "" { + if err.Error() != tc.ErrString { + s.T().Fatalf("got error %s, expected was %s", err.Error(), tc.ErrString) + } + } else if diff := cmp.Diff(got, tc.ExpectedSilence, cmpopts.IgnoreFields(silence.Silence{}, + "CreatedAt", "ID")); diff != "" { + s.T().Fatalf("got diff %+v", diff) + } + }) + } +} + +func (s *SilenceRepositoryTestSuite) TestSoftDelete() { + s.Run("should delete an entry if success", func() { + id, err := s.repository.Create(s.ctx, silence.Silence{ + NamespaceID: 1, + Type: silence.TypeMatchers, + TargetExpression: map[string]interface{}{ + "key1": "value1", + }, + }) + s.Require().NoError(err) + + silences, err := s.repository.List(s.ctx, silence.Filter{}) + s.Require().NoError(err) + s.Require().Equal(5, len(silences)) + + err = s.repository.SoftDelete(s.ctx, id) + s.Assert().NoError(err) + + silences, err = s.repository.List(s.ctx, silence.Filter{}) + s.Require().NoError(err) + s.Require().Equal(4, len(silences)) + }) + + s.Run("should not delete anything and return nil error if id not found", func() { + silences, err := s.repository.List(s.ctx, silence.Filter{}) + s.Require().NoError(err) + s.Require().Equal(4, len(silences)) + + err = s.repository.SoftDelete(s.ctx, "random") + s.Assert().NoError(err) + + silences, err = s.repository.List(s.ctx, silence.Filter{}) + s.Require().NoError(err) + s.Require().Equal(4, len(silences)) + }) +} + +func TestSilenceRepository(t *testing.T) { + suite.Run(t, new(SilenceRepositoryTestSuite)) +} diff --git a/internal/store/postgres/subscription.go b/internal/store/postgres/subscription.go index 9bc058ef..3af9bb06 100644 --- a/internal/store/postgres/subscription.go +++ b/internal/store/postgres/subscription.go @@ -7,6 +7,7 @@ import ( "fmt" sq "github.com/Masterminds/squirrel" + "github.com/lib/pq" "github.com/odpf/siren/core/subscription" "github.com/odpf/siren/internal/store/model" "github.com/odpf/siren/pkg/errors" @@ -56,14 +57,18 @@ func NewSubscriptionRepository(client *pgc.Client) *SubscriptionRepository { func (r *SubscriptionRepository) List(ctx context.Context, flt subscription.Filter) ([]subscription.Subscription, error) { var queryBuilder = subscriptionListQueryBuilder + if len(flt.IDs) != 0 { + queryBuilder = queryBuilder.Where("id = any(?)", pq.Array(flt.IDs)) + } + if flt.NamespaceID != 0 { queryBuilder = queryBuilder.Where("namespace_id = ?", flt.NamespaceID) } - if len(flt.Labels) != 0 { - labelsJSON, err := json.Marshal(flt.Labels) + if len(flt.NotificationMatch) != 0 { + labelsJSON, err := json.Marshal(flt.NotificationMatch) if err != nil { - return nil, errors.ErrInvalid.WithCausef("problem marshalling json to string with err: %s", err.Error()) + return nil, errors.ErrInvalid.WithCausef("problem marshalling notification labels json to string with err: %s", err.Error()) } queryBuilder = queryBuilder.Where(fmt.Sprintf("match <@ '%s'::jsonb", string(json.RawMessage(labelsJSON)))) } @@ -86,14 +91,6 @@ func (r *SubscriptionRepository) List(ctx context.Context, flt subscription.Filt return nil, err } - // If filter by Labels and namespace ID exist, filter by namespace should be done in app - // to make use of search by labels with GIN index - if len(flt.Labels) != 0 && flt.NamespaceID != 0 { - if subscriptionModel.NamespaceID != flt.NamespaceID { - continue - } - } - subscriptionsDomain = append(subscriptionsDomain, *subscriptionModel.ToDomain()) } diff --git a/internal/store/postgres/subscription_test.go b/internal/store/postgres/subscription_test.go index e39dd6b7..4ae4da8f 100644 --- a/internal/store/postgres/subscription_test.go +++ b/internal/store/postgres/subscription_test.go @@ -33,6 +33,7 @@ func (s *SubscriptionRepositoryTestSuite) SetupSuite() { dockertestx.PostgresWithDetail( pgUser, pgPass, pgDBName, ), + dockertestx.PostgresWithVersionTag("13"), ) if err != nil { s.T().Fatal(err) @@ -54,6 +55,7 @@ func (s *SubscriptionRepositoryTestSuite) SetupSuite() { s.ctx = context.TODO() s.Require().NoError(migrate(s.ctx, logger, s.client, dbConfig)) + s.repository = postgres.NewSubscriptionRepository(s.client) _, err = bootstrapProvider(s.client) @@ -188,7 +190,7 @@ func (s *SubscriptionRepositoryTestSuite) TestList() { { Description: "should get filtered subscriptions by match labels", Filter: subscription.Filter{ - Labels: map[string]string{ + NotificationMatch: map[string]string{ "environment": "production", "severity": "CRITICAL", "team": "odpf-app", diff --git a/internal/store/postgres/template_test.go b/internal/store/postgres/template_test.go index 6614b791..6e9d4376 100644 --- a/internal/store/postgres/template_test.go +++ b/internal/store/postgres/template_test.go @@ -33,6 +33,7 @@ func (s *TemplateRepositoryTestSuite) SetupSuite() { dockertestx.PostgresWithDetail( pgUser, pgPass, pgDBName, ), + dockertestx.PostgresWithVersionTag("13"), ) if err != nil { s.T().Fatal(err) diff --git a/internal/store/postgres/testdata/mock-notification-log.json b/internal/store/postgres/testdata/mock-notification-log.json new file mode 100644 index 00000000..41ee7c1b --- /dev/null +++ b/internal/store/postgres/testdata/mock-notification-log.json @@ -0,0 +1,21 @@ +[ + { + "namespace_id": 1, + "notification_id": "receiver", + "subscription_id": 2, + "receiver_id": 3, + "alert_ids": [], + "silence_ids": [] + }, + { + "namespace_id": 1, + "type": "subscriber", + "data": { + "data-key": "data-value" + }, + "labels": { + "label-key": "label-value" + }, + "template": "" + } +] \ No newline at end of file diff --git a/internal/store/postgres/testdata/mock-notification.json b/internal/store/postgres/testdata/mock-notification.json new file mode 100644 index 00000000..1e18e99c --- /dev/null +++ b/internal/store/postgres/testdata/mock-notification.json @@ -0,0 +1,22 @@ +[ + { + "namespace_id": 1, + "type": "receiver", + "data": { + "data-key": "data-value" + }, + "labels": {}, + "template": "" + }, + { + "namespace_id": 1, + "type": "subscriber", + "data": { + "data-key": "data-value" + }, + "labels": { + "label-key": "label-value" + }, + "template": "" + } +] \ No newline at end of file diff --git a/internal/store/postgres/testdata/mock-silence.json b/internal/store/postgres/testdata/mock-silence.json new file mode 100644 index 00000000..e8d1a177 --- /dev/null +++ b/internal/store/postgres/testdata/mock-silence.json @@ -0,0 +1,34 @@ +[ + { + "namespace_id": 1, + "type": "subscription", + "target_id": 2, + "target_expression": { + "rule": "" + } + }, + { + "namespace_id": 2, + "type": "labels", + "target_expression": { + "key1": "value1" + } + }, + { + "namespace_id": 3, + "type": "labels", + "target_expression": { + "key1": "value1", + "key2": "value2" + } + }, + { + "namespace_id": 2, + "type": "labels", + "target_expression": { + "key1": "value1", + "key2": "value2", + "key3": "value3" + } + } +] \ No newline at end of file diff --git a/pkg/pgc/type.go b/pkg/pgc/type.go index 0dc1124a..1d769d8f 100644 --- a/pkg/pgc/type.go +++ b/pkg/pgc/type.go @@ -4,12 +4,12 @@ import ( "database/sql/driver" "encoding/json" "errors" + "fmt" + "time" ) type StringInterfaceMap map[string]interface{} -type StringStringMap map[string]string - func (m *StringInterfaceMap) Scan(value interface{}) error { if value == nil { m = new(StringInterfaceMap) @@ -29,6 +29,8 @@ func (a StringInterfaceMap) Value() (driver.Value, error) { return json.Marshal(a) } +type StringStringMap map[string]string + func (m *StringStringMap) Scan(value interface{}) error { if value == nil { m = new(StringStringMap) @@ -47,3 +49,29 @@ func (a StringStringMap) Value() (driver.Value, error) { } return json.Marshal(a) } + +type TimeDuration time.Duration + +func (t *TimeDuration) Scan(value interface{}) error { + if value == nil { + return nil + } + + valueStr, ok := value.(string) + if !ok { + return errors.New("failed type assertion to string") + } + + dur, err := time.ParseDuration(valueStr) + if err != nil { + return fmt.Errorf("error parsing duration '%s': %w", valueStr, err) + } + + *t = TimeDuration(dur) + + return nil +} + +func (t TimeDuration) Value() (driver.Value, error) { + return time.Duration(t).String(), nil +} diff --git a/pkg/telemetry/application.go b/pkg/telemetry/application.go index 5b62ba1a..92ea49e1 100644 --- a/pkg/telemetry/application.go +++ b/pkg/telemetry/application.go @@ -14,10 +14,10 @@ const ( ) var ( - TagReceiverType = tag.MustNewKey("receiver_type") - TagRoutingMethod = tag.MustNewKey("routing_method") - TagMessageStatus = tag.MustNewKey("status") - TagHookCondition = tag.MustNewKey("hook_condition") + TagReceiverType = tag.MustNewKey("receiver_type") + TagNotificationType = tag.MustNewKey("notification_type") + TagMessageStatus = tag.MustNewKey("status") + TagHookCondition = tag.MustNewKey("hook_condition") MetricNotificationMessageQueueTime = stats.Int64("notification.message.queue.time", "time of message from enqueued to be picked up", stats.UnitMilliseconds) @@ -40,7 +40,7 @@ func setupApplicationViews() error { &view.View{ Name: MetricNotificationMessageCounter.Name(), Description: MetricNotificationMessageCounter.Description(), - TagKeys: []tag.Key{TagReceiverType, TagRoutingMethod, TagMessageStatus}, + TagKeys: []tag.Key{TagReceiverType, TagNotificationType, TagMessageStatus}, Measure: MetricNotificationMessageCounter, Aggregation: view.Count(), }, @@ -48,7 +48,7 @@ func setupApplicationViews() error { Name: MetricNotificationSubscriberNotFound.Name(), Description: MetricNotificationSubscriberNotFound.Description(), Measure: MetricNotificationSubscriberNotFound, - Aggregation: view.Sum(), + Aggregation: view.Count(), }, &view.View{ Name: MetricReceiverHookFailed.Name(), diff --git a/plugins/providers/cortex/alert.go b/plugins/providers/cortex/alert.go index 18d89387..2383c857 100644 --- a/plugins/providers/cortex/alert.go +++ b/plugins/providers/cortex/alert.go @@ -1,8 +1,6 @@ package cortex -import ( - "github.com/odpf/siren/core/alert" -) +import "errors" // GroupAlert contract is cortex/prometheus webhook_config contract // https://prometheus.io/docs/alerting/latest/configuration/#webhook_config @@ -23,8 +21,26 @@ type Alert struct { EndsAt string `mapstructure:"endsAt"` } -func isValidCortexAlert(alrt alert.Alert) bool { - return !(alrt.ResourceName == "" || alrt.Rule == "" || - alrt.MetricValue == "" || alrt.MetricName == "" || - alrt.Severity == "") +func (a Alert) Validate() error { + if _, ok := a.Labels["severity"]; !ok { + return errors.New("'severity' label is missing") + } + + if _, ok := a.Annotations["resource"]; !ok { + return errors.New("'resource' annotation is missing") + } + + if _, ok := a.Annotations["template"]; !ok { + return errors.New("'template' annotation is missing") + } + + if _, ok := a.Annotations["metric_value"]; !ok { + return errors.New("'metric_value' annotation is missing") + } + + if _, ok := a.Annotations["metric_name"]; !ok { + return errors.New("'metric_name' annotation is missing") + } + + return nil } diff --git a/plugins/providers/cortex/mocks/cortex_caller.go b/plugins/providers/cortex/mocks/cortex_caller.go index 6527a7a3..588a9b4f 100644 --- a/plugins/providers/cortex/mocks/cortex_caller.go +++ b/plugins/providers/cortex/mocks/cortex_caller.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -43,9 +43,9 @@ type CortexCaller_CreateAlertmanagerConfig_Call struct { } // CreateAlertmanagerConfig is a helper method to define mock.On call -// - ctx context.Context -// - cfg string -// - templates map[string]string +// - ctx context.Context +// - cfg string +// - templates map[string]string func (_e *CortexCaller_Expecter) CreateAlertmanagerConfig(ctx interface{}, cfg interface{}, templates interface{}) *CortexCaller_CreateAlertmanagerConfig_Call { return &CortexCaller_CreateAlertmanagerConfig_Call{Call: _e.mock.On("CreateAlertmanagerConfig", ctx, cfg, templates)} } @@ -82,9 +82,9 @@ type CortexCaller_CreateRuleGroup_Call struct { } // CreateRuleGroup is a helper method to define mock.On call -// - ctx context.Context -// - namespace string -// - rg rwrulefmt.RuleGroup +// - ctx context.Context +// - namespace string +// - rg rwrulefmt.RuleGroup func (_e *CortexCaller_Expecter) CreateRuleGroup(ctx interface{}, namespace interface{}, rg interface{}) *CortexCaller_CreateRuleGroup_Call { return &CortexCaller_CreateRuleGroup_Call{Call: _e.mock.On("CreateRuleGroup", ctx, namespace, rg)} } @@ -121,9 +121,9 @@ type CortexCaller_DeleteRuleGroup_Call struct { } // DeleteRuleGroup is a helper method to define mock.On call -// - ctx context.Context -// - namespace string -// - groupName string +// - ctx context.Context +// - namespace string +// - groupName string func (_e *CortexCaller_Expecter) DeleteRuleGroup(ctx interface{}, namespace interface{}, groupName interface{}) *CortexCaller_DeleteRuleGroup_Call { return &CortexCaller_DeleteRuleGroup_Call{Call: _e.mock.On("DeleteRuleGroup", ctx, namespace, groupName)} } @@ -169,9 +169,9 @@ type CortexCaller_GetRuleGroup_Call struct { } // GetRuleGroup is a helper method to define mock.On call -// - ctx context.Context -// - namespace string -// - groupName string +// - ctx context.Context +// - namespace string +// - groupName string func (_e *CortexCaller_Expecter) GetRuleGroup(ctx interface{}, namespace interface{}, groupName interface{}) *CortexCaller_GetRuleGroup_Call { return &CortexCaller_GetRuleGroup_Call{Call: _e.mock.On("GetRuleGroup", ctx, namespace, groupName)} } @@ -217,8 +217,8 @@ type CortexCaller_ListRules_Call struct { } // ListRules is a helper method to define mock.On call -// - ctx context.Context -// - namespace string +// - ctx context.Context +// - namespace string func (_e *CortexCaller_Expecter) ListRules(ctx interface{}, namespace interface{}) *CortexCaller_ListRules_Call { return &CortexCaller_ListRules_Call{Call: _e.mock.On("ListRules", ctx, namespace)} } diff --git a/plugins/providers/cortex/service.go b/plugins/providers/cortex/service.go index 2a2fa32a..259fe2e7 100644 --- a/plugins/providers/cortex/service.go +++ b/plugins/providers/cortex/service.go @@ -76,6 +76,12 @@ func (s *PluginService) TransformToAlerts(ctx context.Context, providerID uint64 for _, item := range groupAlert.Alerts { + if err := item.Validate(); err != nil { + s.logger.Error(fmt.Sprintf("invalid alerts: %s", err.Error()), "group key", groupAlert.GroupKey, "alert detail", item) + badAlertCount++ + continue + } + if item.Status == "firing" { firingLen++ } @@ -108,11 +114,7 @@ func (s *PluginService) TransformToAlerts(ctx context.Context, providerID uint64 GeneratorURL: item.GeneratorURL, Fingerprint: item.Fingerprint, } - if !isValidCortexAlert(alrt) { - s.logger.Error("invalid alerts", "group key", groupAlert.GroupKey, "alert detail", alrt) - badAlertCount++ - continue - } + alerts = append(alerts, alrt) } diff --git a/plugins/queues/postgresq/queue_test.go b/plugins/queues/postgresq/queue_test.go index c0e01dd6..96dfcf25 100644 --- a/plugins/queues/postgresq/queue_test.go +++ b/plugins/queues/postgresq/queue_test.go @@ -43,6 +43,7 @@ func (s *QueueTestSuite) SetupSuite() { dockertestx.PostgresWithDetail( pgUser, pgPass, pgDBName, ), + dockertestx.PostgresWithVersionTag("13"), ) if err != nil { s.T().Fatal(err) @@ -97,41 +98,43 @@ func (s *QueueTestSuite) TestSimpleEnqueueDequeue() { timeNow := time.Now() messagesGenerator := func() []notification.Message { - ns := []notification.Notification{ + return []notification.Message{ { - ID: uuid.NewString(), - Data: map[string]interface{}{}, - Labels: map[string]string{}, - CreatedAt: timeNow, + ID: uuid.NewString(), + ReceiverType: receiver.TypeSlack, + Status: notification.MessageStatusEnqueued, + CreatedAt: timeNow, + UpdatedAt: timeNow, }, { - ID: uuid.NewString(), - Data: map[string]interface{}{}, - Labels: map[string]string{}, - CreatedAt: timeNow, + ID: uuid.NewString(), + ReceiverType: receiver.TypeSlack, + Status: notification.MessageStatusEnqueued, + CreatedAt: timeNow, + UpdatedAt: timeNow, }, { - ID: uuid.NewString(), - Data: map[string]interface{}{}, - Labels: map[string]string{}, - CreatedAt: timeNow, + ID: uuid.NewString(), + ReceiverType: receiver.TypeSlack, + Status: notification.MessageStatusEnqueued, + CreatedAt: timeNow, + UpdatedAt: timeNow, }, { - ID: uuid.NewString(), - Data: map[string]interface{}{}, - Labels: map[string]string{}, - CreatedAt: timeNow, + ID: uuid.NewString(), + ReceiverType: receiver.TypeSlack, + Status: notification.MessageStatusEnqueued, + CreatedAt: timeNow, + UpdatedAt: timeNow, + }, + { + ID: uuid.NewString(), + ReceiverType: receiver.TypeSlack, + Status: notification.MessageStatusEnqueued, + CreatedAt: timeNow, + UpdatedAt: timeNow, }, } - - messages := []notification.Message{} - for _, n := range ns { - msg, err := n.ToMessage(receiver.TypeSlack, map[string]interface{}{}) - s.Require().NoError(err) - messages = append(messages, *msg) - } - - return messages } s.Run("should return no error if all messages are successfully processed", func() { @@ -193,7 +196,10 @@ func (s *QueueTestSuite) TestEnqueueDequeueWithCallback() { messages := make([]notification.Message, 5) for i := 0; i < len(messages); i++ { - messages[i].Initialize(notification.Notification{}, receiver.TypeSlack, map[string]interface{}{}, notification.InitWithID(fmt.Sprintf("%d", i+1))) + messages[i].ID = fmt.Sprintf("%d", i+1) + messages[i].ReceiverType = receiver.TypeSlack + messages[i].Status = notification.MessageStatusEnqueued + messages[i].MaxTries = 3 } s.Run("should update row with error for id \"5\"", func() { @@ -248,40 +254,41 @@ func (s *QueueTestSuite) TestEnqueueDequeueDLQ() { messages := make([]notification.Message, 5) for i := 0; i < len(messages); i++ { - messages[i].Initialize(notification.Notification{}, receiver.TypeSlack, map[string]interface{}{}, notification.InitWithID(fmt.Sprintf("%d", i+1))) + messages[i].ID = fmt.Sprintf("%d", i+1) + messages[i].ReceiverType = receiver.TypeSlack + messages[i].Status = notification.MessageStatusEnqueued + messages[i].MaxTries = 3 } - s.Run("failed messages should be re-processed by dlq an ignored by main queue", func() { + s.Run("failed messages should be re-processed by dlq and ignored by main queue", func() { var anError = errors.New("some error") - err := s.q.Enqueue(s.ctx, messages...) - s.Require().NoError(err) + s.Require().NoError(s.q.Enqueue(s.ctx, messages...)) // mark failed all for _, m := range messages { m.MarkFailed(time.Now(), true, anError) - err = s.q.ErrorCallback(s.ctx, m) - s.Assert().NoError(err) + s.Assert().NoError(s.q.ErrorCallback(s.ctx, m)) } - _ = s.q.Dequeue(s.ctx, nil, 5, func(ctx context.Context, m []notification.Message) error { s.Assert().Empty(m); return nil }) - s.Assert().NoError(err) + s.Assert().EqualError( + s.q.Dequeue(s.ctx, nil, 5, func(ctx context.Context, m []notification.Message) error { s.Assert().Empty(m); return nil }), + notification.ErrNoMessage.Error(), + ) - _ = s.dlq.Dequeue(s.ctx, nil, 5, func(ctx context.Context, m []notification.Message) error { + s.Assert().NoError(s.dlq.Dequeue(s.ctx, nil, 5, func(ctx context.Context, m []notification.Message) error { s.Assert().Len(m, 5) return nil - }) + })) tempMessage := &postgresq.NotificationMessage{} - err = s.dbc.Get(tempMessage, fmt.Sprintf("SELECT * FROM %s LIMIT 1", postgresq.MessageQueueTableFullName)) - s.Require().NoError(err) + s.Require().NoError(s.dbc.Get(tempMessage, fmt.Sprintf("SELECT * FROM %s LIMIT 1", postgresq.MessageQueueTableFullName))) s.Assert().Equal(string(notification.MessageStatusPending), tempMessage.Status) s.Assert().Equal(anError.Error(), tempMessage.LastError.String) s.Assert().Equal(1, tempMessage.TryCount) - err = s.cleanup() - s.Require().NoError(err) + s.Require().NoError(s.cleanup()) }) } diff --git a/plugins/receivers/pagerduty/config/default_alert_template_body_v1.goyaml b/plugins/receivers/pagerduty/config/default_alert_template_body_v1.goyaml index 7a97dd8b..87a3b278 100644 --- a/plugins/receivers/pagerduty/config/default_alert_template_body_v1.goyaml +++ b/plugins/receivers/pagerduty/config/default_alert_template_body_v1.goyaml @@ -9,7 +9,7 @@ [[- end]] event_type: "[[template "pagerduty.event_type" . ]]" [[if .Data.id]]incident_key: "[[.Data.id]]"[[ end ]] -description: ([[ .Data.status | toUpper ]][[ if eq .Data.status "firing" ]]:[[ .Data.numAlertsFiring ]][[ end ]]) +description: ([[ .Data.status | toUpper ]][[ if eq .Data.status "firing" ]]:[[ .Data.num_alerts_firing ]][[ end ]]) [[- if eq .Data.status "resolved" ]] ~([[ .Labels.severity | toUpper ]])~ [[- else ]] *([[ .Labels.severity | toUpper ]])* [[- end]] [[ .Labels.alertname ]] diff --git a/plugins/receivers/pagerduty/mocks/pagerduty_caller.go b/plugins/receivers/pagerduty/mocks/pagerduty_caller.go index 8be35ba8..8301842a 100644 --- a/plugins/receivers/pagerduty/mocks/pagerduty_caller.go +++ b/plugins/receivers/pagerduty/mocks/pagerduty_caller.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -42,8 +42,8 @@ type PagerDutyCaller_NotifyV1_Call struct { } // NotifyV1 is a helper method to define mock.On call -// - ctx context.Context -// - message pagerduty.MessageV1 +// - ctx context.Context +// - message pagerduty.MessageV1 func (_e *PagerDutyCaller_Expecter) NotifyV1(ctx interface{}, message interface{}) *PagerDutyCaller_NotifyV1_Call { return &PagerDutyCaller_NotifyV1_Call{Call: _e.mock.On("NotifyV1", ctx, message)} } diff --git a/plugins/receivers/slack/config/default_alert_template_body.goyaml b/plugins/receivers/slack/config/default_alert_template_body.goyaml index 6162b0cb..0efd709f 100644 --- a/plugins/receivers/slack/config/default_alert_template_body.goyaml +++ b/plugins/receivers/slack/config/default_alert_template_body.goyaml @@ -10,7 +10,7 @@ [[- end]] [[- end]] [[- define "slack.pretext" -]] - [[- template "__alert_severity_prefix_emoji" . ]] ([[ .Data.status | toUpper ]][[ if eq .Data.status "firing" ]]:[[ .Data.numAlertsFiring ]][[ end ]]) + [[- template "__alert_severity_prefix_emoji" . ]] ([[ .Data.status | toUpper ]][[ if eq .Data.status "firing" ]]:[[ .Data.num_alerts_firing ]][[ end ]]) [[- if eq .Data.status "resolved" ]] ~([[ .Labels.severity | toUpper ]])~ [[- else ]] *([[ .Labels.severity | toUpper ]])* [[- end]] [[ .Labels.alertname ]] @@ -39,12 +39,11 @@ [[- end -]] username: "Siren" icon_emoji: ":eagle:" -link_names: false attachments: - title: "" pretext: "[[template "slack.pretext" . ]]" text: | -[[.Data.summary | indent 5]] +[[.Data.summary | indent 6]] color: "[[template "slack.color" . ]]" actions: - type: button diff --git a/plugins/receivers/slack/mocks/encryptor.go b/plugins/receivers/slack/mocks/encryptor.go index c893a084..7f70f3e3 100644 --- a/plugins/receivers/slack/mocks/encryptor.go +++ b/plugins/receivers/slack/mocks/encryptor.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -47,7 +47,7 @@ type Encryptor_Decrypt_Call struct { } // Decrypt is a helper method to define mock.On call -// - str secret.MaskableString +// - str secret.MaskableString func (_e *Encryptor_Expecter) Decrypt(str interface{}) *Encryptor_Decrypt_Call { return &Encryptor_Decrypt_Call{Call: _e.mock.On("Decrypt", str)} } @@ -91,7 +91,7 @@ type Encryptor_Encrypt_Call struct { } // Encrypt is a helper method to define mock.On call -// - str secret.MaskableString +// - str secret.MaskableString func (_e *Encryptor_Expecter) Encrypt(str interface{}) *Encryptor_Encrypt_Call { return &Encryptor_Encrypt_Call{Call: _e.mock.On("Encrypt", str)} } diff --git a/plugins/receivers/slack/mocks/goslack_caller.go b/plugins/receivers/slack/mocks/goslack_caller.go index 741daf97..80123adb 100644 --- a/plugins/receivers/slack/mocks/goslack_caller.go +++ b/plugins/receivers/slack/mocks/goslack_caller.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -59,8 +59,8 @@ type GoSlackCaller_GetConversationsForUserContext_Call struct { } // GetConversationsForUserContext is a helper method to define mock.On call -// - ctx context.Context -// - params *slack.GetConversationsForUserParameters +// - ctx context.Context +// - params *slack.GetConversationsForUserParameters func (_e *GoSlackCaller_Expecter) GetConversationsForUserContext(ctx interface{}, params interface{}) *GoSlackCaller_GetConversationsForUserContext_Call { return &GoSlackCaller_GetConversationsForUserContext_Call{Call: _e.mock.On("GetConversationsForUserContext", ctx, params)} } @@ -106,8 +106,8 @@ type GoSlackCaller_GetUserByEmailContext_Call struct { } // GetUserByEmailContext is a helper method to define mock.On call -// - ctx context.Context -// - email string +// - ctx context.Context +// - email string func (_e *GoSlackCaller_Expecter) GetUserByEmailContext(ctx interface{}, email interface{}) *GoSlackCaller_GetUserByEmailContext_Call { return &GoSlackCaller_GetUserByEmailContext_Call{Call: _e.mock.On("GetUserByEmailContext", ctx, email)} } @@ -172,9 +172,9 @@ type GoSlackCaller_SendMessageContext_Call struct { } // SendMessageContext is a helper method to define mock.On call -// - ctx context.Context -// - channel string -// - options ...slack.MsgOption +// - ctx context.Context +// - channel string +// - options ...slack.MsgOption func (_e *GoSlackCaller_Expecter) SendMessageContext(ctx interface{}, channel interface{}, options ...interface{}) *GoSlackCaller_SendMessageContext_Call { return &GoSlackCaller_SendMessageContext_Call{Call: _e.mock.On("SendMessageContext", append([]interface{}{ctx, channel}, options...)...)} diff --git a/plugins/receivers/slack/mocks/slack_caller.go b/plugins/receivers/slack/mocks/slack_caller.go index f8371aef..a9097104 100644 --- a/plugins/receivers/slack/mocks/slack_caller.go +++ b/plugins/receivers/slack/mocks/slack_caller.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.16.0. DO NOT EDIT. package mocks @@ -51,10 +51,10 @@ type SlackCaller_ExchangeAuth_Call struct { } // ExchangeAuth is a helper method to define mock.On call -// - ctx context.Context -// - authCode string -// - clientID string -// - clientSecret string +// - ctx context.Context +// - authCode string +// - clientID string +// - clientSecret string func (_e *SlackCaller_Expecter) ExchangeAuth(ctx interface{}, authCode interface{}, clientID interface{}, clientSecret interface{}) *SlackCaller_ExchangeAuth_Call { return &SlackCaller_ExchangeAuth_Call{Call: _e.mock.On("ExchangeAuth", ctx, authCode, clientID, clientSecret)} } @@ -100,8 +100,8 @@ type SlackCaller_GetWorkspaceChannels_Call struct { } // GetWorkspaceChannels is a helper method to define mock.On call -// - ctx context.Context -// - token secret.MaskableString +// - ctx context.Context +// - token secret.MaskableString func (_e *SlackCaller_Expecter) GetWorkspaceChannels(ctx interface{}, token interface{}) *SlackCaller_GetWorkspaceChannels_Call { return &SlackCaller_GetWorkspaceChannels_Call{Call: _e.mock.On("GetWorkspaceChannels", ctx, token)} } @@ -138,9 +138,9 @@ type SlackCaller_Notify_Call struct { } // Notify is a helper method to define mock.On call -// - ctx context.Context -// - conf slack.NotificationConfig -// - message slack.Message +// - ctx context.Context +// - conf slack.NotificationConfig +// - message slack.Message func (_e *SlackCaller_Expecter) Notify(ctx interface{}, conf interface{}, message interface{}) *SlackCaller_Notify_Call { return &SlackCaller_Notify_Call{Call: _e.mock.On("Notify", ctx, conf, message)} } diff --git a/test/e2e_test/cortex_alerting_test.go b/test/e2e_test/cortex_alerting_test.go index 64be75c1..d99b90b0 100644 --- a/test/e2e_test/cortex_alerting_test.go +++ b/test/e2e_test/cortex_alerting_test.go @@ -3,16 +3,25 @@ package e2e_test import ( "bytes" "context" + "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "testing" + "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/mcuadros/go-defaults" + "github.com/odpf/salt/db" "github.com/odpf/siren/config" + "github.com/odpf/siren/core/alert" + "github.com/odpf/siren/core/log" "github.com/odpf/siren/core/notification" + "github.com/odpf/siren/core/silence" "github.com/odpf/siren/internal/server" + "github.com/odpf/siren/internal/store/model" sirenv1beta1 "github.com/odpf/siren/proto/odpf/siren/v1beta1" "github.com/stretchr/testify/suite" "google.golang.org/protobuf/types/known/structpb" @@ -21,6 +30,7 @@ import ( type CortexAlertingTestSuite struct { suite.Suite client sirenv1beta1.SirenServiceClient + dbClient *db.Client cancelClient func() appConfig *config.Config testBench *CortexTest @@ -58,6 +68,8 @@ func (s *CortexAlertingTestSuite) SetupTest() { // TODO host.docker.internal only works for docker-desktop to call a service in host (siren) s.appConfig.Providers.Cortex.WebhookBaseAPI = fmt.Sprintf("http://test:%d/v1beta1/alerts/cortex", apiPort) s.appConfig.Providers.Cortex.GroupWaitDuration = "1s" + s.appConfig.Providers.Cortex.GroupIntervalDuration = "1s" + s.appConfig.Providers.Cortex.RepeatIntervalDuration = "1s" // enable worker s.appConfig.Notification.MessageHandler.Enabled = true @@ -76,6 +88,11 @@ func (s *CortexAlertingTestSuite) SetupTest() { Type: "cortex", }) s.Require().NoError(err) + + s.dbClient, err = db.New(s.testBench.PGConfig) + if err != nil { + s.T().Fatal(err) + } } func (s *CortexAlertingTestSuite) TearDownTest() { @@ -88,6 +105,25 @@ func (s *CortexAlertingTestSuite) TearDownTest() { func (s *CortexAlertingTestSuite) TestAlerting() { ctx := context.Background() + triggerAlertBody := ` + [ + { + "state": "firing", + "value": 1, + "labels": { + "severity": "WARNING", + "team": "odpf", + "service": "some-service", + "environment": "integration" + }, + "annotations": { + "resource": "test_alert", + "metric_name": "test_alert", + "metric_value": "1", + "template": "alert_test" + } + } + ]` _, err := s.client.CreateNamespace(ctx, &sirenv1beta1.CreateNamespaceRequest{ Name: "new-odpf-1", @@ -132,26 +168,6 @@ func (s *CortexAlertingTestSuite) TestAlerting() { }) s.Require().NoError(err) - triggerAlertBody := ` - [ - { - "state": "firing", - "value": 1, - "labels": { - "severity": "WARNING", - "team": "odpf", - "service": "some-service", - "environment": "integration" - }, - "annotations": { - "resource": "test_alert", - "metric_name": "test_alert", - "metric_value": "1", - "template": "alert_test" - } - } - ]` - for { bodyBytes, err := triggerCortexAlert(s.testBench.NginxHost, "new-odpf-1", triggerAlertBody) s.Assert().NoError(err) @@ -165,10 +181,59 @@ func (s *CortexAlertingTestSuite) TestAlerting() { } }) + } func (s *CortexAlertingTestSuite) TestIncomingHookAPI() { - ctx := context.Background() + var ( + ctx = context.Background() + triggerAlertBody = ` + { + "receiver": "http_subscribe-http-receiver-notification_receiverId_2_idx_0", + "status": "firing", + "alerts": [ + { + "status": "firing", + "labels": { + "key1": "value1", + "key2": "value2", + "severity": "WARNING", + "alertname": "some alert name", + "summary": "this is test alert", + "service": "some-service", + "environment": "integration", + "team": "odpf" + }, + "annotations": { + "metric_name": "test_alert", + "metric_value": "1", + "resource": "test_alert", + "template": "alert_test", + "summary": "this is test alert" + }, + "startsAt": "2022-10-06T03:39:19.2964655Z", + "endsAt": "0001-01-01T00:00:00Z", + "generatorURL": "", + "fingerprint": "684c979dcb5ffb96" + } + ], + "groupLabels": {}, + "commonLabels": { + "environment": "integration", + "team": "odpf" + }, + "commonAnnotations": { + "metric_name": "test_alert", + "metric_value": "1", + "resource": "test_alert", + "template": "alert_test" + }, + "externalURL": "/api/prom/alertmanager", + "version": "4", + "groupKey": "{}/{environment=\"integration\",team=\"odpf\"}:{}", + "truncatedAlerts": 0 + }` + ) _, err := s.client.CreateNamespace(ctx, &sirenv1beta1.CreateNamespaceRequest{ Name: "new-odpf-1", @@ -181,7 +246,7 @@ func (s *CortexAlertingTestSuite) TestIncomingHookAPI() { }) s.Require().NoError(err) - s.Run("Incoming alert in alerts hook API with matching subscription labels should trigger notification", func() { + s.Run("incoming alert in alerts hook API with matching subscription labels should trigger notification", func() { waitChan := make(chan struct{}, 1) // add receiver odpf-http @@ -190,8 +255,40 @@ func (s *CortexAlertingTestSuite) TestIncomingHookAPI() { defer r.Body.Close() s.Assert().NoError(err) - expectedBody := `{"alertname":"some alert name","environment":"integration","generatorUrl":"","id":"cortex-684c979dcb5ffb96","key1":"value1","key2":"value2","metric_name":"test_alert","metric_value":"1","numAlertsFiring":1,"resource":"test_alert","routing_method":"subscribers","service":"some-service","severity":"WARNING","status":"firing","summary":"this is test alert","team":"odpf","template":"alert_test"}` - s.Assert().Equal(expectedBody, string(body)) + type sampleStruct struct { + ID string `json:"id"` + Alertname string `json:"alertname"` + Environment string `json:"environment"` + GeneratorURL string `json:"generator_url"` + Key1 string `json:"key1"` + Key2 string `json:"key2"` + MetricName string `json:"metric_name"` + MetricValue string `json:"metric_value"` + NotificationType string `json:"notification_type"` + NumAlertsFiring int `json:"num_alerts_firing"` + Resource string `json:"resource"` + Service string `json:"service"` + Severity string `json:"severity"` + Status string `json:"status"` + Firing string `json:"firing"` + Summary string `json:"summary"` + Team string `json:"team"` + Template string `json:"template"` + } + + expectedBody := `{"alertname":"some alert name","environment":"integration","generator_url":"","id":"0998ab88-3f5d-4d4a-a66f-40960b105f37","key1":"value1","key2":"value2","metric_name":"test_alert","metric_value":"1","notification_type":"subscriber","num_alerts_firing":1,"resource":"test_alert","service":"some-service","severity":"WARNING","status":"firing","summary":"this is test alert","team":"odpf","template":"alert_test"}` + + var ( + expectedStruct sampleStruct + resultStruct sampleStruct + ) + + s.Require().NoError(json.Unmarshal([]byte(expectedBody), &expectedStruct)) + s.Require().NoError(json.Unmarshal([]byte(body), &resultStruct)) + + if diff := cmp.Diff(expectedStruct, resultStruct, cmpopts.IgnoreFields(sampleStruct{}, "ID")); diff != "" { + s.T().Errorf("got diff: %v", diff) + } close(waitChan) })) s.Require().Nil(err) @@ -228,53 +325,6 @@ func (s *CortexAlertingTestSuite) TestIncomingHookAPI() { }) s.Require().NoError(err) - triggerAlertBody := ` - { - "receiver": "http_subscribe-http-receiver-notification_receiverId_2_idx_0", - "status": "firing", - "alerts": [ - { - "status": "firing", - "labels": { - "key1": "value1", - "key2": "value2", - "severity": "WARNING", - "alertname": "some alert name", - "summary": "this is test alert", - "service": "some-service", - "environment": "integration", - "team": "odpf" - }, - "annotations": { - "metric_name": "test_alert", - "metric_value": "1", - "resource": "test_alert", - "template": "alert_test", - "summary": "this is test alert" - }, - "startsAt": "2022-10-06T03:39:19.2964655Z", - "endsAt": "0001-01-01T00:00:00Z", - "generatorURL": "", - "fingerprint": "684c979dcb5ffb96" - } - ], - "groupLabels": {}, - "commonLabels": { - "environment": "integration", - "team": "odpf" - }, - "commonAnnotations": { - "metric_name": "test_alert", - "metric_value": "1", - "resource": "test_alert", - "template": "alert_test" - }, - "externalURL": "/api/prom/alertmanager", - "version": "4", - "groupKey": "{}/{environment=\"integration\",team=\"odpf\"}:{}", - "truncatedAlerts": 0 - }` - res, err := http.DefaultClient.Post(fmt.Sprintf("http://localhost:%d/v1beta1/alerts/cortex/1/1", s.appConfig.Service.Port), "application/json", bytes.NewBufferString(triggerAlertBody)) s.Require().NoError(err) @@ -287,6 +337,110 @@ func (s *CortexAlertingTestSuite) TestIncomingHookAPI() { <-waitChan }) + + s.Run("triggering cortex alert with matching subscription labels and silence by labels should not trigger notification", func() { + targetExpression, err := structpb.NewStruct(map[string]interface{}{ + "team": "odpf", + "service": "some-service", + "environment": "integration", + }) + s.Require().NoError(err) + + _, err = s.client.CreateSilence(ctx, &sirenv1beta1.CreateSilenceRequest{ + NamespaceId: 1, + Type: silence.TypeMatchers, + TargetExpression: targetExpression, + }) + s.Require().NoError(err) + + res, err := http.DefaultClient.Post(fmt.Sprintf("http://localhost:%d/v1beta1/alerts/cortex/1/1", s.appConfig.Service.Port), "application/json", bytes.NewBufferString(triggerAlertBody)) + s.Require().NoError(err) + + bodyJSon, _ := io.ReadAll(res.Body) + fmt.Println(string(bodyJSon)) + + _, err = io.Copy(io.Discard, res.Body) + s.Require().NoError(err) + defer res.Body.Close() + + time.Sleep(5 * time.Second) + + rows, err := s.dbClient.QueryxContext(context.Background(), `select * from notification_log`) + s.Require().NoError(err) + + var notificationLogs []log.Notification + for rows.Next() { + var nlModel model.NotificationLog + s.Require().NoError(rows.StructScan(&nlModel)) + notificationLogs = append(notificationLogs, nlModel.ToDomain()) + } + + // check alert ids of notification logs + if diff := cmp.Diff(notificationLogs, + []log.Notification{ + { + NamespaceID: 1, + ReceiverID: 1, + AlertIDs: []int64{1}, + SubscriptionID: 1, + }, + { + NamespaceID: 1, + SubscriptionID: 1, + AlertIDs: []int64{2}, + }, + }, + cmpopts.IgnoreFields(log.Notification{}, "ID", "NotificationID", "SilenceIDs", "CreatedAt")); diff != "" { + s.T().Fatalf("found diff %v", diff) + } + + var silenceExist bool + for _, nl := range notificationLogs { + if len(nl.SilenceIDs) != 0 { + silenceExist = true + } + } + s.Assert().True(silenceExist) + + rows, err = s.dbClient.QueryxContext(context.Background(), `select * from alerts`) + s.Require().NoError(err) + + var alerts []alert.Alert + for rows.Next() { + var alrtModel model.Alert + s.Require().NoError(rows.StructScan(&alrtModel)) + alerts = append(alerts, *alrtModel.ToDomain()) + } + + if diff := cmp.Diff(alerts, + []alert.Alert{ + { + ID: 1, + ProviderID: 1, + NamespaceID: 1, + ResourceName: "test_alert", + MetricName: "test_alert", + MetricValue: "1", + Severity: "WARNING", + Rule: "alert_test", + }, + { + ID: 2, + ProviderID: 1, + NamespaceID: 1, + ResourceName: "test_alert", + MetricName: "test_alert", + MetricValue: "1", + Severity: "WARNING", + Rule: "alert_test", + SilenceStatus: alert.SilenceStatusTotal, + }, + }, + cmpopts.IgnoreFields(alert.Alert{}, "ID", "TriggeredAt", "CreatedAt", "UpdatedAt")); diff != "" { + s.T().Fatalf("found diff %v", diff) + } + + }) } func (s *CortexAlertingTestSuite) TestSendNotification() { @@ -303,15 +457,35 @@ func (s *CortexAlertingTestSuite) TestSendNotification() { }) s.Require().NoError(err) - s.Run("3. Triggering alert with matching subscription labels should trigger notification", func() { + s.Run("triggering alert with matching subscription labels should trigger notification", func() { waitChan := make(chan struct{}, 1) testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) s.Assert().NoError(err) - expectedBody := `{"key1":"value1","key2":"value2","key3":"value3","routing_method":"receiver"}` - s.Assert().Equal(expectedBody, string(body)) + type sampleStruct struct { + ID string `json:"id"` + Key1 string `json:"key1"` + Key2 string `json:"key2"` + Key3 string `json:"key3"` + NotificationType string `json:"notification_type"` + ReceiverID string `json:"receiver_id"` + } + + expectedBody := `{"key1":"value1","key2":"value2","key3":"value3","notification_type":"receiver","receiver_id":"1"}` + var ( + expectedStruct sampleStruct + resultStruct sampleStruct + ) + + s.Require().NoError(json.Unmarshal([]byte(expectedBody), &expectedStruct)) + s.Require().NoError(json.Unmarshal([]byte(body), &resultStruct)) + + if diff := cmp.Diff(expectedStruct, resultStruct, cmpopts.IgnoreFields(sampleStruct{}, "ID")); diff != "" { + s.T().Errorf("got diff: %v", diff) + } + close(waitChan) })) s.Require().Nil(err) diff --git a/test/e2e_test/cortex_helper_test.go b/test/e2e_test/cortex_helper_test.go index 9f20f99a..fdc52cd4 100644 --- a/test/e2e_test/cortex_helper_test.go +++ b/test/e2e_test/cortex_helper_test.go @@ -188,6 +188,7 @@ func InitCortexEnvironment(appConfig *config.Config) (*CortexTest, error) { dockertestx.PostgresWithLogger(logger), dockertestx.PostgresWithDockertestNetwork(ct.network), dockertestx.PostgresWithDockerPool(ct.pool), + dockertestx.PostgresWithVersionTag("13"), ) if err != nil { return nil, err diff --git a/test/e2e_test/cortex_namespace_test.go b/test/e2e_test/cortex_namespace_test.go index be896809..894915dc 100644 --- a/test/e2e_test/cortex_namespace_test.go +++ b/test/e2e_test/cortex_namespace_test.go @@ -78,7 +78,7 @@ func (s *CortexNamespaceTestSuite) TearDownTest() { func (s *CortexNamespaceTestSuite) TestNamespace() { ctx := context.Background() - s.Run("Initial state alert config not set, add a namespace will set config for the provider tenant", func() { + s.Run("initial state alert config not set, add a namespace will set config for the provider tenant", func() { _, err := s.client.CreateNamespace(ctx, &sirenv1beta1.CreateNamespaceRequest{ Name: "new-odpf-1", Urn: "new-odpf-1", diff --git a/test/e2e_test/cortex_rule_test.go b/test/e2e_test/cortex_rule_test.go index e85d2e56..ef83fc0f 100644 --- a/test/e2e_test/cortex_rule_test.go +++ b/test/e2e_test/cortex_rule_test.go @@ -75,7 +75,7 @@ func (s *CortexRuleTestSuite) TearDownTest() { func (s *CortexRuleTestSuite) TestRules() { ctx := context.Background() - s.Run("1. initial state has no rule groups, upload rules and templates should return `testdata/cortex/expected-cortexrule-scenario-1.yaml`", func() { + s.Run("initial state has no rule groups, upload rules and templates should return `testdata/cortex/expected-cortexrule-scenario-1.yaml`", func() { err := uploadTemplate(ctx, s.client, "testdata/cortex/template-rule-sample-1.yaml") s.Require().NoError(err) err = uploadTemplate(ctx, s.client, "testdata/cortex/template-rule-sample-2.yaml") diff --git a/test/e2e_test/helper_test.go b/test/e2e_test/helper_test.go index 9b8f7b4f..ea33c1e8 100644 --- a/test/e2e_test/helper_test.go +++ b/test/e2e_test/helper_test.go @@ -17,7 +17,7 @@ import ( sirenv1beta1 "github.com/odpf/siren/proto/odpf/siren/v1beta1" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) func uploadTemplate(ctx context.Context, cl sirenv1beta1.SirenServiceClient, filePath string) error { diff --git a/test/e2e_test/notification_test.go b/test/e2e_test/notification_test.go index e0839223..ddf55a25 100644 --- a/test/e2e_test/notification_test.go +++ b/test/e2e_test/notification_test.go @@ -2,6 +2,7 @@ package e2e_test import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -9,6 +10,8 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/mcuadros/go-defaults" "github.com/odpf/siren/config" "github.com/odpf/siren/core/notification" @@ -85,8 +88,6 @@ func (s *NotificationTestSuite) TearDownTest() { } func (s *NotificationTestSuite) TestSendNotification() { - expectedNotification := `{"icon_emoji":":smile:","routing_method":"receiver","text":"test send notification"}` - ctx := context.Background() controlChan := make(chan struct{}, 1) @@ -94,8 +95,27 @@ func (s *NotificationTestSuite) TestSendNotification() { testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { bodyBytes, err := io.ReadAll(r.Body) s.Assert().NoError(err) - s.Assert().Equal(expectedNotification, string(bodyBytes)) + type sampleStruct struct { + ID string `json:"id"` + IconEmoji string `json:"icon_emoji"` + NotificationType string `json:"notification_type"` + ReceiverID string `json:"receiver_id"` + Text string `json:"text"` + } + + expectedNotification := `{"icon_emoji":":smile:","notification_type":"receiver","receiver_id":"2","text":"test send notification"}` + + var ( + resultStruct sampleStruct + expectedStruct sampleStruct + ) + s.Assert().NoError(json.Unmarshal(bodyBytes, &resultStruct)) + s.Assert().NoError(json.Unmarshal([]byte(expectedNotification), &expectedStruct)) + + if diff := cmp.Diff(expectedStruct, resultStruct, cmpopts.IgnoreFields(sampleStruct{}, "ID")); diff != "" { + s.T().Errorf("got diff: %v", diff) + } controlChan <- struct{}{} }))