From 26e72d0bff92a0c1f69ade268a2cbc5f93b93427 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Fri, 13 Sep 2024 09:44:27 +0545 Subject: [PATCH 1/6] feat: notification silence --- api/v1/notification_silence.go | 44 +++++++++ api/v1/zz_generated.deepcopy.go | 96 +++++++++++++++++++ cmd/server.go | 8 ++ ...control.flanksource.com_incidentrules.yaml | 10 +- ....flanksource.com_notificationsilences.yaml | 63 ++++++++++++ db/notifications.go | 27 ++++++ fixtures/notifications/silence.yaml | 11 +++ go.mod | 2 +- go.sum | 2 - notification/events.go | 44 +++++++++ notification/events_test.go | 49 ++++++++++ 11 files changed, 347 insertions(+), 9 deletions(-) create mode 100644 api/v1/notification_silence.go create mode 100644 config/crds/mission-control.flanksource.com_notificationsilences.yaml create mode 100644 fixtures/notifications/silence.yaml create mode 100644 notification/events_test.go diff --git a/api/v1/notification_silence.go b/api/v1/notification_silence.go new file mode 100644 index 000000000..b6a7e36b8 --- /dev/null +++ b/api/v1/notification_silence.go @@ -0,0 +1,44 @@ +package v1 + +import ( + "github.com/flanksource/duty/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +kubebuilder:object:generate=true +type NotificationSilenceSpec struct { + Matcher *types.CelExpression `json:"matcher,omitempty"` + From metav1.Time `json:"from"` + Until metav1.Time `json:"until"` + Recursive bool `json:"recursive,omitempty"` +} + +// NotificationSilence defines the observed state of Notification Silence +type NotificationSilenceStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// Notification is the Schema for the Notification API +type NotificationSilence struct { + metav1.TypeMeta `json:",inline" yaml:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty" yaml:"metadata,omitempty"` + + Spec NotificationSilenceSpec `json:"spec,omitempty" yaml:"spec,omitempty"` + Status NotificationSilenceStatus `json:"status,omitempty" yaml:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// NotificationList contains a list of Notification +type NotificationSilenceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []NotificationSilence `json:"items"` +} + +func init() { + SchemeBuilder.Register(&NotificationSilence{}, &NotificationSilenceList{}) +} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 217fb7e13..b70228d84 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -1332,6 +1332,102 @@ func (in *NotificationRecipientSpec) DeepCopy() *NotificationRecipientSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NotificationSilence) DeepCopyInto(out *NotificationSilence) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationSilence. +func (in *NotificationSilence) DeepCopy() *NotificationSilence { + if in == nil { + return nil + } + out := new(NotificationSilence) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NotificationSilence) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NotificationSilenceList) DeepCopyInto(out *NotificationSilenceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NotificationSilence, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationSilenceList. +func (in *NotificationSilenceList) DeepCopy() *NotificationSilenceList { + if in == nil { + return nil + } + out := new(NotificationSilenceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NotificationSilenceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NotificationSilenceSpec) DeepCopyInto(out *NotificationSilenceSpec) { + *out = *in + if in.Matcher != nil { + in, out := &in.Matcher, &out.Matcher + *out = new(types.CelExpression) + **out = **in + } + in.From.DeepCopyInto(&out.From) + in.Until.DeepCopyInto(&out.Until) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationSilenceSpec. +func (in *NotificationSilenceSpec) DeepCopy() *NotificationSilenceSpec { + if in == nil { + return nil + } + out := new(NotificationSilenceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NotificationSilenceStatus) DeepCopyInto(out *NotificationSilenceStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationSilenceStatus. +func (in *NotificationSilenceStatus) DeepCopy() *NotificationSilenceStatus { + if in == nil { + return nil + } + out := new(NotificationSilenceStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NotificationSpec) DeepCopyInto(out *NotificationSpec) { *out = *in diff --git a/cmd/server.go b/cmd/server.go index 42a14eaa3..dd054e8db 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -69,6 +69,14 @@ func launchKopper(ctx context.Context) { logger.Fatalf("Unable to create controller for Playbook: %v", err) } + if err = kopper.SetupReconciler(ctx, mgr, + db.PersistNotificationSilenceFromCRD, + db.DeleteNotificationSilence, + "notificationsilence.mission-control.flanksource.com", + ); err != nil { + logger.Fatalf("Unable to create controller for Notification silence: %v", err) + } + if err = kopper.SetupReconciler(ctx, mgr, db.PersistNotificationFromCRD, db.DeleteNotification, diff --git a/config/crds/mission-control.flanksource.com_incidentrules.yaml b/config/crds/mission-control.flanksource.com_incidentrules.yaml index 14d3473cb..d4cece81c 100644 --- a/config/crds/mission-control.flanksource.com_incidentrules.yaml +++ b/config/crds/mission-control.flanksource.com_incidentrules.yaml @@ -44,17 +44,15 @@ spec: properties: timeout: description: How long after the health checks have been passing - before, autoclosing the incident. - format: int64 - type: integer + before, autoclosing the incident (accepts goduration format) + type: string type: object autoResolve: properties: timeout: description: How long after the health checks have been passing - before, autoclosing the incident. - format: int64 - type: integer + before, autoclosing the incident (accepts goduration format) + type: string type: object breakOnMatch: description: stop processing other incident rules, when matched diff --git a/config/crds/mission-control.flanksource.com_notificationsilences.yaml b/config/crds/mission-control.flanksource.com_notificationsilences.yaml new file mode 100644 index 000000000..d392a3cf4 --- /dev/null +++ b/config/crds/mission-control.flanksource.com_notificationsilences.yaml @@ -0,0 +1,63 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: notificationsilences.mission-control.flanksource.com +spec: + group: mission-control.flanksource.com + names: + kind: NotificationSilence + listKind: NotificationSilenceList + plural: notificationsilences + singular: notificationsilence + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: Notification is the Schema for the Notification API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + from: + format: date-time + type: string + matcher: + type: string + recursive: + type: boolean + until: + format: date-time + type: string + required: + - from + - until + type: object + status: + description: NotificationSilence defines the observed state of Notification + Silence + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/db/notifications.go b/db/notifications.go index d7d14edaa..d078d3c30 100644 --- a/db/notifications.go +++ b/db/notifications.go @@ -12,8 +12,35 @@ import ( "github.com/flanksource/incident-commander/api" v1 "github.com/flanksource/incident-commander/api/v1" "github.com/google/uuid" + "github.com/samber/lo" + "gorm.io/gorm" ) +func PersistNotificationSilenceFromCRD(ctx context.Context, obj *v1.NotificationSilence) error { + uid, err := uuid.Parse(string(obj.GetUID())) + if err != nil { + return err + } + + dbObj := models.NotificationSilence{ + ID: uid, + Namespace: obj.Namespace, + From: obj.Spec.From.Time, + Until: obj.Spec.Until.Time, + Source: models.SourceCRD, + } + + if obj.Spec.Matcher != nil { + dbObj.Matcher = lo.ToPtr(string(*obj.Spec.Matcher)) + } + + return ctx.DB().Save(&dbObj).Error +} + +func DeleteNotificationSilence(ctx context.Context, id string) error { + return ctx.DB().Model(&models.NotificationSilence{}).Where("id = ?", id).UpdateColumn("deleted_at", gorm.Expr("NOW()")).Error +} + func PersistNotificationFromCRD(ctx context.Context, obj *v1.Notification) error { uid, err := uuid.Parse(string(obj.GetUID())) if err != nil { diff --git a/fixtures/notifications/silence.yaml b/fixtures/notifications/silence.yaml new file mode 100644 index 000000000..b846eceb0 --- /dev/null +++ b/fixtures/notifications/silence.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: mission-control.flanksource.com/v1 +kind: NotificationSilence +metadata: + name: silences + namespace: mc +spec: + matcher: .config.type == "Kubernetes::HelmRelease" && .config.name == "mission-control" + from: '2024-09-01T00:00:00Z' + until: '2024-09-30T00:00:00Z' + recursive: true diff --git a/go.mod b/go.mod index b86a63633..bc08d5343 100644 --- a/go.mod +++ b/go.mod @@ -359,6 +359,6 @@ require ( // replace github.com/flanksource/commons => /Users/moshe/go/src/github.com/flanksource/commons -// replace github.com/flanksource/duty => ../duty +replace github.com/flanksource/duty => ../duty // replace github.com/flanksource/gomplate/v3 => /Users/moshe/go/src/github.com/flanksource/gomplate diff --git a/go.sum b/go.sum index 676247279..f5534ca58 100644 --- a/go.sum +++ b/go.sum @@ -877,8 +877,6 @@ github.com/flanksource/artifacts v1.0.14 h1:Vv70bccsae0MwGaf/uSPp34J5V1/PyKfct9z github.com/flanksource/artifacts v1.0.14/go.mod h1:qHVCnQu5k50aWNJ5UhpcAKEl7pAzqUrFFKGSm147G70= github.com/flanksource/commons v1.29.10 h1:T/S95Pl8kASEFvQjQ7fJjTUqeVdhxQXg1vfkULTYFJQ= github.com/flanksource/commons v1.29.10/go.mod h1:iTbrXOSp3Spv570Nly97D/U9cQjLZoVlmWCXqWzsvRU= -github.com/flanksource/duty v1.0.645 h1:D31T6RnOt5q8U2eHy2Ow+WjkkM4XXtzIyoJmbalG+CU= -github.com/flanksource/duty v1.0.645/go.mod h1:Oj9zIX94CR2U+nmnt97gNLMrsBWILyIhIBeJynIIgqE= github.com/flanksource/gomplate/v3 v3.20.4/go.mod h1:27BNWhzzSjDed1z8YShO6W+z6G9oZXuxfNFGd/iGSdc= github.com/flanksource/gomplate/v3 v3.24.30 h1:6Y25KnAMX4iCuEXe1C8d1kB2PdV+zD1ZulZrv6DV14Q= github.com/flanksource/gomplate/v3 v3.24.30/go.mod h1:/lAM7+fkcCCfghCAjzdCqwgWxN5Ow8Sk6nkdFPjejCE= diff --git a/notification/events.go b/notification/events.go index 3af92a124..ce243c7a9 100644 --- a/notification/events.go +++ b/notification/events.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "slices" + "strconv" "strings" "sync" "time" @@ -14,6 +15,7 @@ import ( "github.com/flanksource/duty/models" "github.com/flanksource/duty/query" "github.com/flanksource/duty/types" + "github.com/flanksource/gomplate/v3" "github.com/flanksource/incident-commander/api" "github.com/flanksource/incident-commander/db" "github.com/flanksource/incident-commander/events" @@ -108,6 +110,36 @@ func checkRepeatInterval(ctx context.Context, n NotificationWithSpec, event mode return !exists, nil } +func shouldSilence(silences []models.NotificationSilence, celEnv map[string]any) (bool, error) { + now := time.Now() + for _, silence := range silences { + withinSilencePeriod := now.After(silence.From) && now.Before(silence.Until) + if !withinSilencePeriod { + continue + } + + matcherMatched := true + if silence.Matcher != nil && *silence.Matcher != "" { + res, err := gomplate.RunTemplate(celEnv, gomplate.Template{Expression: *silence.Matcher}) + if err != nil { + return false, err + } + + if parsed, err := strconv.ParseBool(res); err != nil { + return false, fmt.Errorf("expected matcher %s to return a boolean value but got %s", *silence.Matcher, res) + } else if parsed { + matcherMatched = false + } + } + + if matcherMatched { + return true, nil + } + } + + return false, nil +} + // addNotificationEvent responds to a event that can possibly generate a notification. // If a notification is found for the given event and passes all the filters, then // a new `notification.send` event is created. @@ -164,6 +196,18 @@ func (t *notificationHandler) addNotificationEvent(ctx context.Context, event mo continue } + silences, err := query.GetAllNotificationSilences(ctx) + if err != nil { + return err + } + + if s, err := shouldSilence(silences, celEnv); err != nil { + return err + } else if s { + // TODO: Save in notification send history as silenced + continue + } + payloads, err := CreateNotificationSendPayloads(ctx, event, n, celEnv) if err != nil { return err diff --git a/notification/events_test.go b/notification/events_test.go new file mode 100644 index 000000000..feef8a418 --- /dev/null +++ b/notification/events_test.go @@ -0,0 +1,49 @@ +package notification + +import ( + "testing" + "time" + + "github.com/flanksource/duty/models" + "github.com/google/uuid" +) + +func Test_shouldSilence(t *testing.T) { + type args struct { + silences []models.NotificationSilence + celEnv map[string]any + } + tests := []struct { + name string + args args + want bool + wantErr bool + }{ + { + name: "simple", + args: args{ + silences: []models.NotificationSilence{ + { + ID: uuid.New(), + From: time.Now().Add(-time.Hour), + Until: time.Now().Add(time.Hour), + }, + }, + celEnv: map[string]any{}, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := shouldSilence(tt.args.silences, tt.args.celEnv) + if (err != nil) != tt.wantErr { + t.Errorf("shouldSilence() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("shouldSilence() = %v, want %v", got, tt.want) + } + }) + } +} From 3fb53000d35cb19c48025372590abdc611cd15b2 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 9 Sep 2024 20:03:08 +0545 Subject: [PATCH 2/6] feat: notification silences per resource ID [skip ci] --- api/v1/notification_silence.go | 17 +++- api/v1/zz_generated.deepcopy.go | 39 +++++++- ....flanksource.com_notificationsilences.yaml | 10 +- db/notifications.go | 47 +++++++++- notification/events.go | 94 ++++++++++--------- notification/events_test.go | 49 ---------- 6 files changed, 147 insertions(+), 109 deletions(-) delete mode 100644 notification/events_test.go diff --git a/api/v1/notification_silence.go b/api/v1/notification_silence.go index b6a7e36b8..69038e1a0 100644 --- a/api/v1/notification_silence.go +++ b/api/v1/notification_silence.go @@ -1,16 +1,23 @@ package v1 import ( - "github.com/flanksource/duty/types" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +type NotificationSilenceResource struct { + ConfigID *string `json:"config_id,omitempty"` + CanaryID *string `json:"canary_id,omitempty"` + ComponentID *string `json:"component_id,omitempty"` + CheckID *string `json:"check_id,omitempty"` +} + // +kubebuilder:object:generate=true type NotificationSilenceSpec struct { - Matcher *types.CelExpression `json:"matcher,omitempty"` - From metav1.Time `json:"from"` - Until metav1.Time `json:"until"` - Recursive bool `json:"recursive,omitempty"` + NotificationSilenceResource `json:",inline" yaml:",inline"` + + From metav1.Time `json:"from"` + Until metav1.Time `json:"until"` + Recursive bool `json:"recursive,omitempty"` } // NotificationSilence defines the observed state of Notification Silence diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index b70228d84..83dc097f5 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -1392,13 +1392,44 @@ func (in *NotificationSilenceList) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NotificationSilenceSpec) DeepCopyInto(out *NotificationSilenceSpec) { +func (in *NotificationSilenceResource) DeepCopyInto(out *NotificationSilenceResource) { *out = *in - if in.Matcher != nil { - in, out := &in.Matcher, &out.Matcher - *out = new(types.CelExpression) + if in.ConfigID != nil { + in, out := &in.ConfigID, &out.ConfigID + *out = new(string) + **out = **in + } + if in.CanaryID != nil { + in, out := &in.CanaryID, &out.CanaryID + *out = new(string) + **out = **in + } + if in.ComponentID != nil { + in, out := &in.ComponentID, &out.ComponentID + *out = new(string) + **out = **in + } + if in.CheckID != nil { + in, out := &in.CheckID, &out.CheckID + *out = new(string) **out = **in } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationSilenceResource. +func (in *NotificationSilenceResource) DeepCopy() *NotificationSilenceResource { + if in == nil { + return nil + } + out := new(NotificationSilenceResource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NotificationSilenceSpec) DeepCopyInto(out *NotificationSilenceSpec) { + *out = *in + in.NotificationSilenceResource.DeepCopyInto(&out.NotificationSilenceResource) in.From.DeepCopyInto(&out.From) in.Until.DeepCopyInto(&out.Until) } diff --git a/config/crds/mission-control.flanksource.com_notificationsilences.yaml b/config/crds/mission-control.flanksource.com_notificationsilences.yaml index d392a3cf4..21aad0c4c 100644 --- a/config/crds/mission-control.flanksource.com_notificationsilences.yaml +++ b/config/crds/mission-control.flanksource.com_notificationsilences.yaml @@ -38,11 +38,17 @@ spec: type: object spec: properties: + canary_id: + type: string + check_id: + type: string + component_id: + type: string + config_id: + type: string from: format: date-time type: string - matcher: - type: string recursive: type: boolean until: diff --git a/db/notifications.go b/db/notifications.go index d078d3c30..c88bdfc54 100644 --- a/db/notifications.go +++ b/db/notifications.go @@ -12,7 +12,6 @@ import ( "github.com/flanksource/incident-commander/api" v1 "github.com/flanksource/incident-commander/api/v1" "github.com/google/uuid" - "github.com/samber/lo" "gorm.io/gorm" ) @@ -28,10 +27,12 @@ func PersistNotificationSilenceFromCRD(ctx context.Context, obj *v1.Notification From: obj.Spec.From.Time, Until: obj.Spec.Until.Time, Source: models.SourceCRD, - } - - if obj.Spec.Matcher != nil { - dbObj.Matcher = lo.ToPtr(string(*obj.Spec.Matcher)) + NotificationSilenceResource: models.NotificationSilenceResource{ + ConfigID: obj.Spec.ConfigID, + ComponentID: obj.Spec.ComponentID, + CheckID: obj.Spec.CheckID, + CanaryID: obj.Spec.CanaryID, + }, } return ctx.DB().Save(&dbObj).Error @@ -138,3 +139,39 @@ func NotificationSendSummary(ctx context.Context, id string, window time.Duratio err := ctx.DB().Raw(query, id, window).Row().Scan(&earliest, &count) return earliest.Time, count, err } + +func GetMatchingNotificationSilencesCount(ctx context.Context, resources models.NotificationSilenceResource) (int64, error) { + query := ctx.DB().Model(&models.NotificationSilence{}). + Where(`"from" <= NOW()`). + Where("until >= NOW()"). + Where("deleted_at IS NULL") + + // Initialize with a false condition, + // if no resources are provided, the query won't return all records + orClauses := ctx.DB().Where("1 = 0") + + if resources.ConfigID != nil { + orClauses = orClauses.Or("config_id = ?", *resources.ConfigID) + } + + if resources.ComponentID != nil { + orClauses = orClauses.Or("component_id = ?", *resources.ComponentID) + } + + if resources.CanaryID != nil { + orClauses = orClauses.Or("canary_id = ?", *resources.CanaryID) + } + + if resources.CheckID != nil { + orClauses = orClauses.Or("check_id = ?", *resources.CheckID) + } + query = query.Where(orClauses) + + var count int64 + err := query.Count(&count).Error + if err != nil { + return 0, err + } + + return count, nil +} diff --git a/notification/events.go b/notification/events.go index ce243c7a9..3d7f0c787 100644 --- a/notification/events.go +++ b/notification/events.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "slices" - "strconv" "strings" "sync" "time" @@ -15,7 +14,6 @@ import ( "github.com/flanksource/duty/models" "github.com/flanksource/duty/query" "github.com/flanksource/duty/types" - "github.com/flanksource/gomplate/v3" "github.com/flanksource/incident-commander/api" "github.com/flanksource/incident-commander/db" "github.com/flanksource/incident-commander/events" @@ -110,36 +108,6 @@ func checkRepeatInterval(ctx context.Context, n NotificationWithSpec, event mode return !exists, nil } -func shouldSilence(silences []models.NotificationSilence, celEnv map[string]any) (bool, error) { - now := time.Now() - for _, silence := range silences { - withinSilencePeriod := now.After(silence.From) && now.Before(silence.Until) - if !withinSilencePeriod { - continue - } - - matcherMatched := true - if silence.Matcher != nil && *silence.Matcher != "" { - res, err := gomplate.RunTemplate(celEnv, gomplate.Template{Expression: *silence.Matcher}) - if err != nil { - return false, err - } - - if parsed, err := strconv.ParseBool(res); err != nil { - return false, fmt.Errorf("expected matcher %s to return a boolean value but got %s", *silence.Matcher, res) - } else if parsed { - matcherMatched = false - } - } - - if matcherMatched { - return true, nil - } - } - - return false, nil -} - // addNotificationEvent responds to a event that can possibly generate a notification. // If a notification is found for the given event and passes all the filters, then // a new `notification.send` event is created. @@ -160,6 +128,12 @@ func (t *notificationHandler) addNotificationEvent(ctx context.Context, event mo t.Ring.Add(event, celEnv) + silencedResource := getSilencedResourceFromCelEnv(celEnv) + matchingSilences, err := db.GetMatchingNotificationSilencesCount(ctx, silencedResource) + if err != nil { + return err + } + for _, id := range notificationIDs { n, err := GetNotification(ctx, id) if err != nil { @@ -196,18 +170,6 @@ func (t *notificationHandler) addNotificationEvent(ctx context.Context, event mo continue } - silences, err := query.GetAllNotificationSilences(ctx) - if err != nil { - return err - } - - if s, err := shouldSilence(silences, celEnv); err != nil { - return err - } else if s { - // TODO: Save in notification send history as silenced - continue - } - payloads, err := CreateNotificationSendPayloads(ctx, event, n, celEnv) if err != nil { return err @@ -226,6 +188,21 @@ func (t *notificationHandler) addNotificationEvent(ctx context.Context, event mo continue } + if matchingSilences > 0 { + ctx.Logger.V(6).Infof("silencing notification for event %s due to %d matching silences", event.ID, matchingSilences) + + if err := ctx.DB().Create(&models.NotificationSendHistory{ + NotificationID: n.ID, + ResourceID: payload.ID, + SourceEvent: event.Name, + Status: "silenced", + }).Error; err != nil { + return fmt.Errorf("failed to save silenced notification history: %w", err) + } + + return nil + } + newEvent := api.Event{ Name: api.EventNotificationSend, Properties: payload.AsMap(), @@ -485,3 +462,32 @@ func getEnvForEvent(ctx context.Context, event models.Event, properties map[stri return env, nil } + +func getSilencedResourceFromCelEnv(celEnv map[string]any) models.NotificationSilenceResource { + var silencedResource models.NotificationSilenceResource + if v, ok := celEnv["config"]; ok { + if vv, ok := v.(map[string]any); ok { + silencedResource.ConfigID = lo.ToPtr(vv["id"].(string)) + } + } + + if v, ok := celEnv["check"]; ok { + if vv, ok := v.(map[string]any); ok { + silencedResource.CheckID = lo.ToPtr(vv["id"].(string)) + } + } + + if v, ok := celEnv["canary"]; ok { + if vv, ok := v.(map[string]any); ok { + silencedResource.CanaryID = lo.ToPtr(vv["id"].(string)) + } + } + + if v, ok := celEnv["component"]; ok { + if vv, ok := v.(map[string]any); ok { + silencedResource.ComponentID = lo.ToPtr(vv["id"].(string)) + } + } + + return silencedResource +} diff --git a/notification/events_test.go b/notification/events_test.go deleted file mode 100644 index feef8a418..000000000 --- a/notification/events_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package notification - -import ( - "testing" - "time" - - "github.com/flanksource/duty/models" - "github.com/google/uuid" -) - -func Test_shouldSilence(t *testing.T) { - type args struct { - silences []models.NotificationSilence - celEnv map[string]any - } - tests := []struct { - name string - args args - want bool - wantErr bool - }{ - { - name: "simple", - args: args{ - silences: []models.NotificationSilence{ - { - ID: uuid.New(), - From: time.Now().Add(-time.Hour), - Until: time.Now().Add(time.Hour), - }, - }, - celEnv: map[string]any{}, - }, - want: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := shouldSilence(tt.args.silences, tt.args.celEnv) - if (err != nil) != tt.wantErr { - t.Errorf("shouldSilence() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.want { - t.Errorf("shouldSilence() = %v, want %v", got, tt.want) - } - }) - } -} From 0f9565cd96d8ec9d9e8d3f818d3ae25f7352944f Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 9 Sep 2024 23:22:22 +0545 Subject: [PATCH 3/6] feat: support recursive option & add tests [skip ci] --- db/notifications.go | 29 +++++++++-- db/notifications_test.go | 106 +++++++++++++++++++++++++++++++++++++++ db/suite_test.go | 12 +++++ 3 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 db/notifications_test.go diff --git a/db/notifications.go b/db/notifications.go index c88bdfc54..d8ab822cc 100644 --- a/db/notifications.go +++ b/db/notifications.go @@ -6,6 +6,8 @@ import ( "fmt" "time" + extraClausePlugin "github.com/WinterYukky/gorm-extra-clause-plugin" + "github.com/WinterYukky/gorm-extra-clause-plugin/exclause" "github.com/flanksource/duty/context" "github.com/flanksource/duty/models" "github.com/flanksource/duty/query" @@ -27,6 +29,7 @@ func PersistNotificationSilenceFromCRD(ctx context.Context, obj *v1.Notification From: obj.Spec.From.Time, Until: obj.Spec.Until.Time, Source: models.SourceCRD, + Recursive: obj.Spec.Recursive, NotificationSilenceResource: models.NotificationSilenceResource{ ConfigID: obj.Spec.ConfigID, ComponentID: obj.Spec.ComponentID, @@ -141,10 +144,9 @@ func NotificationSendSummary(ctx context.Context, id string, window time.Duratio } func GetMatchingNotificationSilencesCount(ctx context.Context, resources models.NotificationSilenceResource) (int64, error) { - query := ctx.DB().Model(&models.NotificationSilence{}). - Where(`"from" <= NOW()`). - Where("until >= NOW()"). - Where("deleted_at IS NULL") + _ = ctx.DB().Use(extraClausePlugin.New()) + + query := ctx.DB().Debug().Model(&models.NotificationSilence{}) // Initialize with a false condition, // if no resources are provided, the query won't return all records @@ -152,10 +154,26 @@ func GetMatchingNotificationSilencesCount(ctx context.Context, resources models. if resources.ConfigID != nil { orClauses = orClauses.Or("config_id = ?", *resources.ConfigID) + + // recursive stuff + orClauses = orClauses.Or("(recursive = true AND path_cte.path LIKE '%' || config_id::TEXT || '%')") + query = query.Clauses(exclause.NewWith( + "path_cte", + ctx.DB().Select("path").Model(&models.ConfigItem{}).Where("id = ?", *resources.ConfigID), + )) + query = query.Joins("CROSS JOIN path_cte") } if resources.ComponentID != nil { orClauses = orClauses.Or("component_id = ?", *resources.ComponentID) + + // recursive stuff + orClauses = orClauses.Or("(recursive = true AND path_cte.path LIKE '%' || component_id::TEXT || '%')") + query = query.Clauses(exclause.NewWith( + "path_cte", + ctx.DB().Select("path").Model(&models.Component{}).Where("id = ?", *resources.ComponentID), + )) + query = query.Joins("CROSS JOIN path_cte") } if resources.CanaryID != nil { @@ -165,10 +183,11 @@ func GetMatchingNotificationSilencesCount(ctx context.Context, resources models. if resources.CheckID != nil { orClauses = orClauses.Or("check_id = ?", *resources.CheckID) } + query = query.Where(orClauses) var count int64 - err := query.Count(&count).Error + err := query.Count(&count).Where(`"from" <= NOW()`).Where("until >= NOW()").Where("deleted_at IS NULL").Error if err != nil { return 0, err } diff --git a/db/notifications_test.go b/db/notifications_test.go new file mode 100644 index 000000000..b28628840 --- /dev/null +++ b/db/notifications_test.go @@ -0,0 +1,106 @@ +package db + +import ( + "time" + + "github.com/flanksource/duty/models" + "github.com/flanksource/duty/tests/fixtures/dummy" + "github.com/google/uuid" + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/samber/lo" +) + +var _ = ginkgo.Describe("Notification Silence", ginkgo.Ordered, func() { + var silences []models.NotificationSilence + ginkgo.BeforeAll(func() { + silences = []models.NotificationSilence{ + { + ID: uuid.New(), + From: time.Now().Add(-time.Hour), + Until: time.Now().Add(time.Hour), + Source: models.SourceCRD, + NotificationSilenceResource: models.NotificationSilenceResource{ + ConfigID: lo.ToPtr(dummy.EKSCluster.ID.String()), + }, + }, + { + ID: uuid.New(), + From: time.Now().Add(-time.Hour), + Until: time.Now().Add(time.Hour), + Source: models.SourceCRD, + Recursive: true, + NotificationSilenceResource: models.NotificationSilenceResource{ + ConfigID: lo.ToPtr(dummy.LogisticsAPIDeployment.ID.String()), + }, + }, + { + ID: uuid.New(), + From: time.Now().Add(-time.Hour), + Until: time.Now().Add(time.Hour), + Source: models.SourceCRD, + Recursive: true, + NotificationSilenceResource: models.NotificationSilenceResource{ + ComponentID: lo.ToPtr(dummy.Logistics.ID.String()), + }, + }, + } + + err := DefaultContext.DB().Create(&silences).Error + Expect(err).To(BeNil()) + }) + + ginkgo.Context("non recursive match", func() { + ginkgo.It("should match", func() { + matched, err := GetMatchingNotificationSilencesCount(DefaultContext, models.NotificationSilenceResource{ConfigID: lo.ToPtr(dummy.EKSCluster.ID.String())}) + Expect(err).To(BeNil()) + Expect(matched).To(Equal(int64(1))) + }) + + ginkgo.It("should not match", func() { + matched, err := GetMatchingNotificationSilencesCount(DefaultContext, models.NotificationSilenceResource{ConfigID: lo.ToPtr(dummy.KubernetesCluster.ID.String())}) + Expect(err).To(BeNil()) + Expect(matched).To(Equal(int64(0))) + }) + }) + + ginkgo.Context("config recursive match", func() { + ginkgo.It("should match a child", func() { + matched, err := GetMatchingNotificationSilencesCount(DefaultContext, models.NotificationSilenceResource{ConfigID: lo.ToPtr(dummy.LogisticsAPIReplicaSet.ID.String())}) + Expect(err).To(BeNil()) + Expect(matched).To(Equal(int64(1))) + }) + + ginkgo.It("should match a grand child", func() { + matched, err := GetMatchingNotificationSilencesCount(DefaultContext, models.NotificationSilenceResource{ConfigID: lo.ToPtr(dummy.LogisticsAPIPodConfig.ID.String())}) + Expect(err).To(BeNil()) + Expect(matched).To(Equal(int64(1))) + }) + + ginkgo.It("should not match", func() { + matched, err := GetMatchingNotificationSilencesCount(DefaultContext, models.NotificationSilenceResource{ConfigID: lo.ToPtr(dummy.LogisticsUIDeployment.ID.String())}) + Expect(err).To(BeNil()) + Expect(matched).To(Equal(int64(0))) + }) + }) + + ginkgo.Context("component recursive match", func() { + ginkgo.It("should match a child", func() { + matched, err := GetMatchingNotificationSilencesCount(DefaultContext, models.NotificationSilenceResource{ComponentID: lo.ToPtr(dummy.LogisticsAPI.ID.String())}) + Expect(err).To(BeNil()) + Expect(matched).To(Equal(int64(1))) + }) + + ginkgo.It("should match a grand child", func() { + matched, err := GetMatchingNotificationSilencesCount(DefaultContext, models.NotificationSilenceResource{ComponentID: lo.ToPtr(dummy.LogisticsWorker.ID.String())}) + Expect(err).To(BeNil()) + Expect(matched).To(Equal(int64(1))) + }) + + ginkgo.It("should not match", func() { + matched, err := GetMatchingNotificationSilencesCount(DefaultContext, models.NotificationSilenceResource{ComponentID: lo.ToPtr(dummy.ClusterComponent.ID.String())}) + Expect(err).To(BeNil()) + Expect(matched).To(Equal(int64(0))) + }) + }) +}) diff --git a/db/suite_test.go b/db/suite_test.go index b2e03b77a..a3c022c74 100644 --- a/db/suite_test.go +++ b/db/suite_test.go @@ -3,6 +3,8 @@ package db import ( "testing" + "github.com/flanksource/duty/context" + "github.com/flanksource/duty/tests/setup" ginkgo "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -11,3 +13,13 @@ func TestDB(t *testing.T) { RegisterFailHandler(ginkgo.Fail) ginkgo.RunSpecs(t, "DB") } + +var ( + DefaultContext context.Context +) + +var _ = ginkgo.BeforeSuite(func() { + DefaultContext = setup.BeforeSuiteFn() +}) + +var _ = ginkgo.AfterSuite(setup.AfterSuiteFn) From 363c0759d45bca7ba2a76187f283666ec1c0cc80 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 11 Sep 2024 17:14:16 +0545 Subject: [PATCH 4/6] chore: remove CRD --- api/v1/notification_silence.go | 51 ------- api/v1/zz_generated.deepcopy.go | 127 ------------------ cmd/server.go | 8 -- ....flanksource.com_notificationsilences.yaml | 69 ---------- db/notifications.go | 29 ---- go.mod | 4 +- go.sum | 2 + 7 files changed, 4 insertions(+), 286 deletions(-) delete mode 100644 api/v1/notification_silence.go delete mode 100644 config/crds/mission-control.flanksource.com_notificationsilences.yaml diff --git a/api/v1/notification_silence.go b/api/v1/notification_silence.go deleted file mode 100644 index 69038e1a0..000000000 --- a/api/v1/notification_silence.go +++ /dev/null @@ -1,51 +0,0 @@ -package v1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type NotificationSilenceResource struct { - ConfigID *string `json:"config_id,omitempty"` - CanaryID *string `json:"canary_id,omitempty"` - ComponentID *string `json:"component_id,omitempty"` - CheckID *string `json:"check_id,omitempty"` -} - -// +kubebuilder:object:generate=true -type NotificationSilenceSpec struct { - NotificationSilenceResource `json:",inline" yaml:",inline"` - - From metav1.Time `json:"from"` - Until metav1.Time `json:"until"` - Recursive bool `json:"recursive,omitempty"` -} - -// NotificationSilence defines the observed state of Notification Silence -type NotificationSilenceStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster -} - -//+kubebuilder:object:root=true -//+kubebuilder:subresource:status - -// Notification is the Schema for the Notification API -type NotificationSilence struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty" yaml:"metadata,omitempty"` - - Spec NotificationSilenceSpec `json:"spec,omitempty" yaml:"spec,omitempty"` - Status NotificationSilenceStatus `json:"status,omitempty" yaml:"status,omitempty"` -} - -//+kubebuilder:object:root=true - -// NotificationList contains a list of Notification -type NotificationSilenceList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []NotificationSilence `json:"items"` -} - -func init() { - SchemeBuilder.Register(&NotificationSilence{}, &NotificationSilenceList{}) -} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 83dc097f5..217fb7e13 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -1332,133 +1332,6 @@ func (in *NotificationRecipientSpec) DeepCopy() *NotificationRecipientSpec { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NotificationSilence) DeepCopyInto(out *NotificationSilence) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationSilence. -func (in *NotificationSilence) DeepCopy() *NotificationSilence { - if in == nil { - return nil - } - out := new(NotificationSilence) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *NotificationSilence) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NotificationSilenceList) DeepCopyInto(out *NotificationSilenceList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]NotificationSilence, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationSilenceList. -func (in *NotificationSilenceList) DeepCopy() *NotificationSilenceList { - if in == nil { - return nil - } - out := new(NotificationSilenceList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *NotificationSilenceList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NotificationSilenceResource) DeepCopyInto(out *NotificationSilenceResource) { - *out = *in - if in.ConfigID != nil { - in, out := &in.ConfigID, &out.ConfigID - *out = new(string) - **out = **in - } - if in.CanaryID != nil { - in, out := &in.CanaryID, &out.CanaryID - *out = new(string) - **out = **in - } - if in.ComponentID != nil { - in, out := &in.ComponentID, &out.ComponentID - *out = new(string) - **out = **in - } - if in.CheckID != nil { - in, out := &in.CheckID, &out.CheckID - *out = new(string) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationSilenceResource. -func (in *NotificationSilenceResource) DeepCopy() *NotificationSilenceResource { - if in == nil { - return nil - } - out := new(NotificationSilenceResource) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NotificationSilenceSpec) DeepCopyInto(out *NotificationSilenceSpec) { - *out = *in - in.NotificationSilenceResource.DeepCopyInto(&out.NotificationSilenceResource) - in.From.DeepCopyInto(&out.From) - in.Until.DeepCopyInto(&out.Until) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationSilenceSpec. -func (in *NotificationSilenceSpec) DeepCopy() *NotificationSilenceSpec { - if in == nil { - return nil - } - out := new(NotificationSilenceSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NotificationSilenceStatus) DeepCopyInto(out *NotificationSilenceStatus) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationSilenceStatus. -func (in *NotificationSilenceStatus) DeepCopy() *NotificationSilenceStatus { - if in == nil { - return nil - } - out := new(NotificationSilenceStatus) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NotificationSpec) DeepCopyInto(out *NotificationSpec) { *out = *in diff --git a/cmd/server.go b/cmd/server.go index dd054e8db..42a14eaa3 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -69,14 +69,6 @@ func launchKopper(ctx context.Context) { logger.Fatalf("Unable to create controller for Playbook: %v", err) } - if err = kopper.SetupReconciler(ctx, mgr, - db.PersistNotificationSilenceFromCRD, - db.DeleteNotificationSilence, - "notificationsilence.mission-control.flanksource.com", - ); err != nil { - logger.Fatalf("Unable to create controller for Notification silence: %v", err) - } - if err = kopper.SetupReconciler(ctx, mgr, db.PersistNotificationFromCRD, db.DeleteNotification, diff --git a/config/crds/mission-control.flanksource.com_notificationsilences.yaml b/config/crds/mission-control.flanksource.com_notificationsilences.yaml deleted file mode 100644 index 21aad0c4c..000000000 --- a/config/crds/mission-control.flanksource.com_notificationsilences.yaml +++ /dev/null @@ -1,69 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.14.0 - name: notificationsilences.mission-control.flanksource.com -spec: - group: mission-control.flanksource.com - names: - kind: NotificationSilence - listKind: NotificationSilenceList - plural: notificationsilences - singular: notificationsilence - scope: Namespaced - versions: - - name: v1 - schema: - openAPIV3Schema: - description: Notification is the Schema for the Notification API - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - properties: - canary_id: - type: string - check_id: - type: string - component_id: - type: string - config_id: - type: string - from: - format: date-time - type: string - recursive: - type: boolean - until: - format: date-time - type: string - required: - - from - - until - type: object - status: - description: NotificationSilence defines the observed state of Notification - Silence - type: object - type: object - served: true - storage: true - subresources: - status: {} diff --git a/db/notifications.go b/db/notifications.go index d8ab822cc..6b0846d44 100644 --- a/db/notifications.go +++ b/db/notifications.go @@ -14,37 +14,8 @@ import ( "github.com/flanksource/incident-commander/api" v1 "github.com/flanksource/incident-commander/api/v1" "github.com/google/uuid" - "gorm.io/gorm" ) -func PersistNotificationSilenceFromCRD(ctx context.Context, obj *v1.NotificationSilence) error { - uid, err := uuid.Parse(string(obj.GetUID())) - if err != nil { - return err - } - - dbObj := models.NotificationSilence{ - ID: uid, - Namespace: obj.Namespace, - From: obj.Spec.From.Time, - Until: obj.Spec.Until.Time, - Source: models.SourceCRD, - Recursive: obj.Spec.Recursive, - NotificationSilenceResource: models.NotificationSilenceResource{ - ConfigID: obj.Spec.ConfigID, - ComponentID: obj.Spec.ComponentID, - CheckID: obj.Spec.CheckID, - CanaryID: obj.Spec.CanaryID, - }, - } - - return ctx.DB().Save(&dbObj).Error -} - -func DeleteNotificationSilence(ctx context.Context, id string) error { - return ctx.DB().Model(&models.NotificationSilence{}).Where("id = ?", id).UpdateColumn("deleted_at", gorm.Expr("NOW()")).Error -} - func PersistNotificationFromCRD(ctx context.Context, obj *v1.Notification) error { uid, err := uuid.Parse(string(obj.GetUID())) if err != nil { diff --git a/go.mod b/go.mod index bc08d5343..79fcdba92 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( require ( github.com/MicahParks/keyfunc/v2 v2.1.0 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b + github.com/WinterYukky/gorm-extra-clause-plugin v0.2.0 github.com/aws/aws-sdk-go-v2 v1.30.4 github.com/aws/aws-sdk-go-v2/config v1.27.29 github.com/aws/aws-sdk-go-v2/credentials v1.17.29 @@ -88,7 +89,6 @@ require ( github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/RaveNoX/go-jsonmerge v1.0.0 // indirect github.com/Snawoot/go-http-digest-auth-client v1.1.3 // indirect - github.com/WinterYukky/gorm-extra-clause-plugin v0.2.0 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect @@ -359,6 +359,6 @@ require ( // replace github.com/flanksource/commons => /Users/moshe/go/src/github.com/flanksource/commons -replace github.com/flanksource/duty => ../duty +// replace github.com/flanksource/duty => ../duty // replace github.com/flanksource/gomplate/v3 => /Users/moshe/go/src/github.com/flanksource/gomplate diff --git a/go.sum b/go.sum index f5534ca58..52ff1bfed 100644 --- a/go.sum +++ b/go.sum @@ -877,6 +877,8 @@ github.com/flanksource/artifacts v1.0.14 h1:Vv70bccsae0MwGaf/uSPp34J5V1/PyKfct9z github.com/flanksource/artifacts v1.0.14/go.mod h1:qHVCnQu5k50aWNJ5UhpcAKEl7pAzqUrFFKGSm147G70= github.com/flanksource/commons v1.29.10 h1:T/S95Pl8kASEFvQjQ7fJjTUqeVdhxQXg1vfkULTYFJQ= github.com/flanksource/commons v1.29.10/go.mod h1:iTbrXOSp3Spv570Nly97D/U9cQjLZoVlmWCXqWzsvRU= +github.com/flanksource/duty v1.0.634 h1:plZxB4f9nSmR/57G06ML9lwYbAJPmdpQnFmbQuUvaVs= +github.com/flanksource/duty v1.0.634/go.mod h1:Oj9zIX94CR2U+nmnt97gNLMrsBWILyIhIBeJynIIgqE= github.com/flanksource/gomplate/v3 v3.20.4/go.mod h1:27BNWhzzSjDed1z8YShO6W+z6G9oZXuxfNFGd/iGSdc= github.com/flanksource/gomplate/v3 v3.24.30 h1:6Y25KnAMX4iCuEXe1C8d1kB2PdV+zD1ZulZrv6DV14Q= github.com/flanksource/gomplate/v3 v3.24.30/go.mod h1:/lAM7+fkcCCfghCAjzdCqwgWxN5Ow8Sk6nkdFPjejCE= From 2f716db8660340d4d7eb818417d87438ded76d6c Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 11 Sep 2024 17:15:15 +0545 Subject: [PATCH 5/6] feat: add endpoint to save notification silences --- fixtures/notifications/silence.yaml | 11 ---- notification/controllers.go | 21 ++++++- notification/events.go | 29 --------- notification/silence.go | 94 +++++++++++++++++++++++++++++ rbac/policy.go | 18 +++--- 5 files changed, 123 insertions(+), 50 deletions(-) delete mode 100644 fixtures/notifications/silence.yaml create mode 100644 notification/silence.go diff --git a/fixtures/notifications/silence.yaml b/fixtures/notifications/silence.yaml deleted file mode 100644 index b846eceb0..000000000 --- a/fixtures/notifications/silence.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -apiVersion: mission-control.flanksource.com/v1 -kind: NotificationSilence -metadata: - name: silences - namespace: mc -spec: - matcher: .config.type == "Kubernetes::HelmRelease" && .config.name == "mission-control" - from: '2024-09-01T00:00:00Z' - until: '2024-09-30T00:00:00Z' - recursive: true diff --git a/notification/controllers.go b/notification/controllers.go index 0805f8588..b29d183aa 100644 --- a/notification/controllers.go +++ b/notification/controllers.go @@ -1,8 +1,10 @@ package notification import ( + "encoding/json" "net/http" + "github.com/flanksource/duty/context" echoSrv "github.com/flanksource/incident-commander/echo" "github.com/flanksource/incident-commander/rbac" "github.com/labstack/echo/v4" @@ -13,7 +15,24 @@ func init() { } func RegisterRoutes(e *echo.Echo) { - e.GET("/notification/events", func(c echo.Context) error { + g := e.Group("/notification") + + g.GET("/events", func(c echo.Context) error { return c.JSON(http.StatusOK, EventRing.Get()) }, rbac.Authorization(rbac.ObjectMonitor, rbac.ActionRead)) + + g.POST("/silence", func(c echo.Context) error { + ctx := c.Request().Context().(context.Context) + + var req SilenceSaveRequest + if err := json.NewDecoder(c.Request().Body).Decode(&req); err != nil { + return err + } + + if err := SaveNotificationSilence(ctx, req); err != nil { + return err + } + + return nil + }, rbac.Authorization(rbac.ObjectNotificationSilence, rbac.ActionCreate)) } diff --git a/notification/events.go b/notification/events.go index 3d7f0c787..6da3a5bb4 100644 --- a/notification/events.go +++ b/notification/events.go @@ -462,32 +462,3 @@ func getEnvForEvent(ctx context.Context, event models.Event, properties map[stri return env, nil } - -func getSilencedResourceFromCelEnv(celEnv map[string]any) models.NotificationSilenceResource { - var silencedResource models.NotificationSilenceResource - if v, ok := celEnv["config"]; ok { - if vv, ok := v.(map[string]any); ok { - silencedResource.ConfigID = lo.ToPtr(vv["id"].(string)) - } - } - - if v, ok := celEnv["check"]; ok { - if vv, ok := v.(map[string]any); ok { - silencedResource.CheckID = lo.ToPtr(vv["id"].(string)) - } - } - - if v, ok := celEnv["canary"]; ok { - if vv, ok := v.(map[string]any); ok { - silencedResource.CanaryID = lo.ToPtr(vv["id"].(string)) - } - } - - if v, ok := celEnv["component"]; ok { - if vv, ok := v.(map[string]any); ok { - silencedResource.ComponentID = lo.ToPtr(vv["id"].(string)) - } - } - - return silencedResource -} diff --git a/notification/silence.go b/notification/silence.go new file mode 100644 index 000000000..975da177e --- /dev/null +++ b/notification/silence.go @@ -0,0 +1,94 @@ +package notification + +import ( + "errors" + "time" + + "github.com/flanksource/commons/duration" + "github.com/flanksource/duty/context" + "github.com/flanksource/duty/models" + "github.com/samber/lo" +) + +type SilenceSaveRequest struct { + models.NotificationSilenceResource + From time.Time `json:"from"` + Until time.Time `json:"until"` + Duration string `json:"duration"` + Description string `json:"description"` +} + +func (t *SilenceSaveRequest) Validate() error { + if t.From.IsZero() { + return errors.New("`from` time is required") + } + + if t.Until.IsZero() { + if t.Duration == "" { + return errors.New("`until` or `duration` is required") + } + + if parsed, err := duration.ParseDuration(t.Duration); err != nil { + return err + } else { + t.Until = t.From.Add(time.Duration(parsed)) + } + } + + if t.From.After(t.Until) { + return errors.New("`from` time must be before `until` time") + } + + if t.NotificationSilenceResource.CanaryID == nil && t.NotificationSilenceResource.CheckID == nil && t.NotificationSilenceResource.ConfigID == nil && + t.NotificationSilenceResource.ComponentID == nil { + return errors.New("at least one of `config_id`, `canary_id`, `check_id` or `component_id` is required") + } + + return nil +} + +func SaveNotificationSilence(ctx context.Context, req SilenceSaveRequest) error { + if err := req.Validate(); err != nil { + return err + } + + silence := models.NotificationSilence{ + NotificationSilenceResource: req.NotificationSilenceResource, + From: req.From, + Until: req.Until, + Description: req.Description, + Source: models.SourceUI, + CreatedBy: lo.ToPtr(ctx.User().ID), + } + + return ctx.DB().Create(&silence).Error +} + +func getSilencedResourceFromCelEnv(celEnv map[string]any) models.NotificationSilenceResource { + var silencedResource models.NotificationSilenceResource + if v, ok := celEnv["config"]; ok { + if vv, ok := v.(map[string]any); ok { + silencedResource.ConfigID = lo.ToPtr(vv["id"].(string)) + } + } + + if v, ok := celEnv["check"]; ok { + if vv, ok := v.(map[string]any); ok { + silencedResource.CheckID = lo.ToPtr(vv["id"].(string)) + } + } + + if v, ok := celEnv["canary"]; ok { + if vv, ok := v.(map[string]any); ok { + silencedResource.CanaryID = lo.ToPtr(vv["id"].(string)) + } + } + + if v, ok := celEnv["component"]; ok { + if vv, ok := v.(map[string]any); ok { + silencedResource.ComponentID = lo.ToPtr(vv["id"].(string)) + } + } + + return silencedResource +} diff --git a/rbac/policy.go b/rbac/policy.go index 6113c4734..3a9900b38 100644 --- a/rbac/policy.go +++ b/rbac/policy.go @@ -147,15 +147,15 @@ const ( RoleAgent = "agent" // Actions - ActionRead = "read" - ActionUpdate = "update" - ActionCreate = "create" - ActionDelete = "delete" - ActionRun = "run" - ActionApprove = "approve" - ActionAll = "*" - ActionCRUD = "create,read,update,delete" - ObjectKubernetesProxy = "kubernetes-proxy" + ActionRead = "read" + ActionUpdate = "update" + ActionCreate = "create" + ActionDelete = "delete" + ActionRun = "run" + ActionApprove = "approve" + ActionAll = "*" + ActionCRUD = "create,read,update,delete" + // Objects ObjectLogs = "logs" ObjectAgent = "agent" From 06f706bb59f9ce97163938a9a9c816dc06f47959 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 11 Sep 2024 20:51:30 +0545 Subject: [PATCH 6/6] chore: use datemath for notification silence --- db/notifications.go | 2 +- go.sum | 4 +- notification/controllers.go | 5 +- notification/silence.go | 55 +++++++++++++--------- notification/silence_test.go | 91 ++++++++++++++++++++++++++++++++++++ rbac/policy.go | 18 +++---- 6 files changed, 138 insertions(+), 37 deletions(-) create mode 100644 notification/silence_test.go diff --git a/db/notifications.go b/db/notifications.go index 6b0846d44..6ca642a96 100644 --- a/db/notifications.go +++ b/db/notifications.go @@ -117,7 +117,7 @@ func NotificationSendSummary(ctx context.Context, id string, window time.Duratio func GetMatchingNotificationSilencesCount(ctx context.Context, resources models.NotificationSilenceResource) (int64, error) { _ = ctx.DB().Use(extraClausePlugin.New()) - query := ctx.DB().Debug().Model(&models.NotificationSilence{}) + query := ctx.DB().Model(&models.NotificationSilence{}) // Initialize with a false condition, // if no resources are provided, the query won't return all records diff --git a/go.sum b/go.sum index 52ff1bfed..676247279 100644 --- a/go.sum +++ b/go.sum @@ -877,8 +877,8 @@ github.com/flanksource/artifacts v1.0.14 h1:Vv70bccsae0MwGaf/uSPp34J5V1/PyKfct9z github.com/flanksource/artifacts v1.0.14/go.mod h1:qHVCnQu5k50aWNJ5UhpcAKEl7pAzqUrFFKGSm147G70= github.com/flanksource/commons v1.29.10 h1:T/S95Pl8kASEFvQjQ7fJjTUqeVdhxQXg1vfkULTYFJQ= github.com/flanksource/commons v1.29.10/go.mod h1:iTbrXOSp3Spv570Nly97D/U9cQjLZoVlmWCXqWzsvRU= -github.com/flanksource/duty v1.0.634 h1:plZxB4f9nSmR/57G06ML9lwYbAJPmdpQnFmbQuUvaVs= -github.com/flanksource/duty v1.0.634/go.mod h1:Oj9zIX94CR2U+nmnt97gNLMrsBWILyIhIBeJynIIgqE= +github.com/flanksource/duty v1.0.645 h1:D31T6RnOt5q8U2eHy2Ow+WjkkM4XXtzIyoJmbalG+CU= +github.com/flanksource/duty v1.0.645/go.mod h1:Oj9zIX94CR2U+nmnt97gNLMrsBWILyIhIBeJynIIgqE= github.com/flanksource/gomplate/v3 v3.20.4/go.mod h1:27BNWhzzSjDed1z8YShO6W+z6G9oZXuxfNFGd/iGSdc= github.com/flanksource/gomplate/v3 v3.24.30 h1:6Y25KnAMX4iCuEXe1C8d1kB2PdV+zD1ZulZrv6DV14Q= github.com/flanksource/gomplate/v3 v3.24.30/go.mod h1:/lAM7+fkcCCfghCAjzdCqwgWxN5Ow8Sk6nkdFPjejCE= diff --git a/notification/controllers.go b/notification/controllers.go index b29d183aa..7c30e1cf3 100644 --- a/notification/controllers.go +++ b/notification/controllers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" + "github.com/flanksource/duty/api" "github.com/flanksource/duty/context" echoSrv "github.com/flanksource/incident-commander/echo" "github.com/flanksource/incident-commander/rbac" @@ -30,9 +31,9 @@ func RegisterRoutes(e *echo.Echo) { } if err := SaveNotificationSilence(ctx, req); err != nil { - return err + return api.WriteError(c, err) } return nil - }, rbac.Authorization(rbac.ObjectNotificationSilence, rbac.ActionCreate)) + }, rbac.Authorization(rbac.ObjectNotification, rbac.ActionCreate)) } diff --git a/notification/silence.go b/notification/silence.go index 975da177e..66b0d9e54 100644 --- a/notification/silence.go +++ b/notification/silence.go @@ -4,43 +4,51 @@ import ( "errors" "time" - "github.com/flanksource/commons/duration" + "github.com/flanksource/duty/api" "github.com/flanksource/duty/context" + "github.com/flanksource/duty/db" "github.com/flanksource/duty/models" "github.com/samber/lo" + "github.com/timberio/go-datemath" ) type SilenceSaveRequest struct { models.NotificationSilenceResource - From time.Time `json:"from"` - Until time.Time `json:"until"` - Duration string `json:"duration"` - Description string `json:"description"` + From string `json:"from"` + Until string `json:"until"` + Description string `json:"description"` + Recursive bool `json:"recursive"` + + from time.Time + until time.Time } func (t *SilenceSaveRequest) Validate() error { - if t.From.IsZero() { + if t.From == "" { return errors.New("`from` time is required") } - if t.Until.IsZero() { - if t.Duration == "" { - return errors.New("`until` or `duration` is required") - } + if t.Until == "" { + return errors.New("`until` is required") + } - if parsed, err := duration.ParseDuration(t.Duration); err != nil { - return err - } else { - t.Until = t.From.Add(time.Duration(parsed)) - } + if parsedTime, err := datemath.ParseAndEvaluate(t.From); err != nil { + return err + } else { + t.from = parsedTime } - if t.From.After(t.Until) { - return errors.New("`from` time must be before `until` time") + if parsedTime, err := datemath.ParseAndEvaluate(t.Until); err != nil { + return err + } else { + t.until = parsedTime + } + + if t.from.After(t.until) { + return errors.New("`from` time must be before `until") } - if t.NotificationSilenceResource.CanaryID == nil && t.NotificationSilenceResource.CheckID == nil && t.NotificationSilenceResource.ConfigID == nil && - t.NotificationSilenceResource.ComponentID == nil { + if t.NotificationSilenceResource.Empty() { return errors.New("at least one of `config_id`, `canary_id`, `check_id` or `component_id` is required") } @@ -49,19 +57,20 @@ func (t *SilenceSaveRequest) Validate() error { func SaveNotificationSilence(ctx context.Context, req SilenceSaveRequest) error { if err := req.Validate(); err != nil { - return err + return api.Errorf(api.EINVALID, err.Error()) } silence := models.NotificationSilence{ NotificationSilenceResource: req.NotificationSilenceResource, - From: req.From, - Until: req.Until, + From: req.from, + Until: req.until, Description: req.Description, + Recursive: req.Recursive, Source: models.SourceUI, CreatedBy: lo.ToPtr(ctx.User().ID), } - return ctx.DB().Create(&silence).Error + return db.ErrorDetails(ctx.DB().Create(&silence).Error) } func getSilencedResourceFromCelEnv(celEnv map[string]any) models.NotificationSilenceResource { diff --git a/notification/silence_test.go b/notification/silence_test.go new file mode 100644 index 000000000..3768d04a9 --- /dev/null +++ b/notification/silence_test.go @@ -0,0 +1,91 @@ +package notification + +import ( + "testing" + "time" + + "github.com/flanksource/duty/models" + "github.com/google/uuid" + "github.com/samber/lo" +) + +func TestSilenceSaveRequest_Validate(t *testing.T) { + type fields struct { + NotificationSilenceResource models.NotificationSilenceResource + From string + Until string + Description string + from time.Time + until time.Time + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + { + name: "empty from", + fields: fields{ + NotificationSilenceResource: models.NotificationSilenceResource{}, + From: "", + Until: "now+2d", + }, + wantErr: true, + }, + { + name: "empty until", + fields: fields{ + NotificationSilenceResource: models.NotificationSilenceResource{}, + From: "now", + Until: "", + }, + wantErr: true, + }, + { + name: "empty resource", + fields: fields{ + NotificationSilenceResource: models.NotificationSilenceResource{}, + From: "now", + Until: "now+2d", + }, + wantErr: true, + }, + { + name: "valid", + fields: fields{ + NotificationSilenceResource: models.NotificationSilenceResource{ + ConfigID: lo.ToPtr(uuid.NewString()), + }, + From: "now", + Until: "now+2d", + }, + }, + { + name: "complete but invalid", + fields: fields{ + NotificationSilenceResource: models.NotificationSilenceResource{ + ConfigID: lo.ToPtr(uuid.NewString()), + }, + From: "now", + Until: "now-1m", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tr := &SilenceSaveRequest{ + NotificationSilenceResource: tt.fields.NotificationSilenceResource, + From: tt.fields.From, + Until: tt.fields.Until, + Description: tt.fields.Description, + from: tt.fields.from, + until: tt.fields.until, + } + if err := tr.Validate(); (err != nil) != tt.wantErr { + t.Fatalf("SilenceSaveRequest.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/rbac/policy.go b/rbac/policy.go index 3a9900b38..6113c4734 100644 --- a/rbac/policy.go +++ b/rbac/policy.go @@ -147,15 +147,15 @@ const ( RoleAgent = "agent" // Actions - ActionRead = "read" - ActionUpdate = "update" - ActionCreate = "create" - ActionDelete = "delete" - ActionRun = "run" - ActionApprove = "approve" - ActionAll = "*" - ActionCRUD = "create,read,update,delete" - + ActionRead = "read" + ActionUpdate = "update" + ActionCreate = "create" + ActionDelete = "delete" + ActionRun = "run" + ActionApprove = "approve" + ActionAll = "*" + ActionCRUD = "create,read,update,delete" + ObjectKubernetesProxy = "kubernetes-proxy" // Objects ObjectLogs = "logs" ObjectAgent = "agent"