From 745b7dcd25779ed19930869ddec276338accff22 Mon Sep 17 00:00:00 2001 From: nothing0012 Date: Wed, 24 Dec 2025 15:00:08 +0400 Subject: [PATCH 01/18] feat: enhance notification system with rich details and opt-in diffs - Included flag description and pre/post change values in notifications - Implemented privacy-first, opt-in JSON diffing (FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED) - Added automatic pretty-printing for JSON diffs to improve readability - Refactored notification package for better testability (removed public init, added SetNotifier) - Added handler-level integration tests for notifications - Upgraded cloud.google.com/go/pubsub to v2 to resolve deprecation warnings - Fixed various test stability issues (DB transaction ordering, nil notifier panics) --- pkg/config/env.go | 19 ++++ pkg/entity/flag_snapshot.go | 27 +++++- pkg/entity/flag_snapshot_test.go | 4 +- pkg/handler/crud.go | 32 +++---- pkg/handler/crud_flag_creation.go | 2 +- pkg/handler/crud_notification_test.go | 124 +++++++++++++++++++++++++ pkg/handler/export_test.go | 6 +- pkg/handler/handler.go | 9 +- pkg/notification/README.md | 56 +++++++++++ pkg/notification/email.go | 125 +++++++++++++++++++++++++ pkg/notification/email_test.go | 94 +++++++++++++++++++ pkg/notification/hook.go | 93 +++++++++++++++++++ pkg/notification/hook_test.go | 34 +++++++ pkg/notification/notifier.go | 129 ++++++++++++++++++++++++++ pkg/notification/notifier_test.go | 88 ++++++++++++++++++ pkg/notification/slack.go | 79 ++++++++++++++++ 16 files changed, 890 insertions(+), 31 deletions(-) create mode 100644 pkg/handler/crud_notification_test.go create mode 100644 pkg/notification/README.md create mode 100644 pkg/notification/email.go create mode 100644 pkg/notification/email_test.go create mode 100644 pkg/notification/hook.go create mode 100644 pkg/notification/hook_test.go create mode 100644 pkg/notification/notifier.go create mode 100644 pkg/notification/notifier_test.go create mode 100644 pkg/notification/slack.go diff --git a/pkg/config/env.go b/pkg/config/env.go index a6e2b954b..980319c8e 100644 --- a/pkg/config/env.go +++ b/pkg/config/env.go @@ -230,6 +230,25 @@ var Config = struct { BasicAuthPrefixWhitelistPaths []string `env:"FLAGR_BASIC_AUTH_WHITELIST_PATHS" envDefault:"/api/v1/health,/api/v1/flags,/api/v1/evaluation" envSeparator:","` BasicAuthExactWhitelistPaths []string `env:"FLAGR_BASIC_AUTH_EXACT_WHITELIST_PATHS" envDefault:"" envSeparator:","` + // NotificationEnabled - enable notifications for CRUD operations + NotificationEnabled bool `env:"FLAGR_NOTIFICATION_ENABLED" envDefault:"false"` + // NotificationDetailedDiffEnabled - notify detailed diff of pre and post values + NotificationDetailedDiffEnabled bool `env:"FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED" envDefault:"false"` + // NotificationProvider - notification provider to use (slack, email, discord, etc.) + NotificationProvider string `env:"FLAGR_NOTIFICATION_PROVIDER" envDefault:"slack"` + // NotificationSlackWebhookURL - Slack webhook URL for notifications + NotificationSlackWebhookURL string `env:"FLAGR_NOTIFICATION_SLACK_WEBHOOK_URL" envDefault:""` + // NotificationSlackChannel - Slack channel to send notifications to + NotificationSlackChannel string `env:"FLAGR_NOTIFICATION_SLACK_CHANNEL" envDefault:""` + // NotificationEmailTo - recipient email address + NotificationEmailTo string `env:"FLAGR_NOTIFICATION_EMAIL_TO" envDefault:""` + // NotificationEmailURL - HTTP email API URL (e.g., https://api.sendgrid.com/v3/mail/send) + NotificationEmailURL string `env:"FLAGR_NOTIFICATION_EMAIL_URL" envDefault:""` + // NotificationEmailAPIKey - API key for email service + NotificationEmailAPIKey string `env:"FLAGR_NOTIFICATION_EMAIL_API_KEY" envDefault:""` + // NotificationEmailFrom - sender email address + NotificationEmailFrom string `env:"FLAGR_NOTIFICATION_EMAIL_FROM" envDefault:""` + // WebPrefix - base path for web and API // e.g. FLAGR_WEB_PREFIX=/foo // UI path => localhost:18000/foo" diff --git a/pkg/entity/flag_snapshot.go b/pkg/entity/flag_snapshot.go index b814a1163..cec758fb0 100644 --- a/pkg/entity/flag_snapshot.go +++ b/pkg/entity/flag_snapshot.go @@ -6,6 +6,7 @@ import ( "encoding/json" "github.com/openflagr/flagr/pkg/config" + "github.com/openflagr/flagr/pkg/notification" "github.com/openflagr/flagr/pkg/util" "github.com/sirupsen/logrus" "gorm.io/gorm" @@ -21,7 +22,7 @@ type FlagSnapshot struct { } // SaveFlagSnapshot saves the Flag Snapshot -func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string) { +func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string, operation string) { tx := db.Begin() f := &Flag{} if err := tx.First(f, flagID).Error; err != nil { @@ -65,11 +66,35 @@ func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string) { return } + preFS := &FlagSnapshot{} + tx.Where("flag_id = ?", flagID).Order("id desc").Offset(1).First(preFS) + if err := tx.Commit().Error; err != nil { tx.Rollback() + return + } + + preValue := "" + postValue := "" + diff := "" + + if config.Config.NotificationDetailedDiffEnabled { + preValue = string(preFS.Flag) + postValue = string(fs.Flag) + diff = notification.CalculateDiff(preValue, postValue) } logFlagSnapshotUpdate(flagID, updatedBy) + notification.SendFlagNotification( + notification.Operation(operation), + flagID, + f.Key, + f.Description, + preValue, + postValue, + diff, + updatedBy, + ) } var logFlagSnapshotUpdate = func(flagID uint, updatedBy string) { diff --git a/pkg/entity/flag_snapshot_test.go b/pkg/entity/flag_snapshot_test.go index 9293663e3..2ac1ee5cd 100644 --- a/pkg/entity/flag_snapshot_test.go +++ b/pkg/entity/flag_snapshot_test.go @@ -16,10 +16,10 @@ func TestSaveFlagSnapshot(t *testing.T) { defer tmpDB.Close() t.Run("happy code path", func(t *testing.T) { - SaveFlagSnapshot(db, f.ID, "flagr-test@example.com") + SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", "test") }) t.Run("save on non-existing flag", func(t *testing.T) { - SaveFlagSnapshot(db, uint(999999), "flagr-test@example.com") + SaveFlagSnapshot(db, uint(999999), "flagr-test@example.com", "test") }) } diff --git a/pkg/handler/crud.go b/pkg/handler/crud.go index 389b692d6..754bb751d 100644 --- a/pkg/handler/crud.go +++ b/pkg/handler/crud.go @@ -270,7 +270,7 @@ func (c *crud) PutFlag(params flag.PutFlagParams) middleware.Responder { } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return resp } @@ -293,7 +293,7 @@ func (c *crud) SetFlagEnabledState(params flag.SetFlagEnabledParams) middleware. } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return resp } @@ -316,7 +316,7 @@ func (c *crud) RestoreFlag(params flag.RestoreFlagParams) middleware.Responder { } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "restore") return resp } @@ -337,7 +337,7 @@ func (c *crud) DeleteTag(params tag.DeleteTagParams) middleware.Responder { if err := getDB().Model(s).Association("Tags").Delete(t); err != nil { return tag.NewDeleteTagDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return tag.NewDeleteTagOK() } @@ -399,7 +399,7 @@ func (c *crud) CreateTag(params tag.CreateTagParams) middleware.Responder { resp := tag.NewCreateTagOK() resp.SetPayload(e2r.MapTag(t)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return resp } @@ -418,7 +418,7 @@ func (c *crud) CreateSegment(params segment.CreateSegmentParams) middleware.Resp resp := segment.NewCreateSegmentOK() resp.SetPayload(e2r.MapSegment(s)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return resp } @@ -461,7 +461,7 @@ func (c *crud) PutSegment(params segment.PutSegmentParams) middleware.Responder resp := segment.NewPutSegmentOK() resp.SetPayload(e2r.MapSegment(s)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return resp } @@ -485,7 +485,7 @@ func (c *crud) PutSegmentsReorder(params segment.PutSegmentsReorderParams) middl return segment.NewPutSegmentsReorderDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return segment.NewPutSegmentsReorderOK() } @@ -495,7 +495,7 @@ func (c *crud) DeleteSegment(params segment.DeleteSegmentParams) middleware.Resp return segment.NewDeleteSegmentDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return segment.NewDeleteSegmentOK() } @@ -517,7 +517,7 @@ func (c *crud) CreateConstraint(params constraint.CreateConstraintParams) middle resp := constraint.NewCreateConstraintOK() resp.SetPayload(e2r.MapConstraint(cons)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return resp } @@ -555,7 +555,7 @@ func (c *crud) PutConstraint(params constraint.PutConstraintParams) middleware.R resp := constraint.NewPutConstraintOK() resp.SetPayload(e2r.MapConstraint(cons)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return resp } @@ -566,7 +566,7 @@ func (c *crud) DeleteConstraint(params constraint.DeleteConstraintParams) middle resp := constraint.NewDeleteConstraintOK() - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return resp } @@ -602,7 +602,7 @@ func (c *crud) PutDistributions(params distribution.PutDistributionsParams) midd resp := distribution.NewPutDistributionsOK() resp.SetPayload(e2r.MapDistributions(ds)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return resp } @@ -644,7 +644,7 @@ func (c *crud) CreateVariant(params variant.CreateVariantParams) middleware.Resp resp := variant.NewCreateVariantOK() resp.SetPayload(e2r.MapVariant(v)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return resp } @@ -695,7 +695,7 @@ func (c *crud) PutVariant(params variant.PutVariantParams) middleware.Responder resp := variant.NewPutVariantOK() resp.SetPayload(e2r.MapVariant(v)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return resp } @@ -708,6 +708,6 @@ func (c *crud) DeleteVariant(params variant.DeleteVariantParams) middleware.Resp return variant.NewDeleteVariantDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") return variant.NewDeleteVariantOK() } diff --git a/pkg/handler/crud_flag_creation.go b/pkg/handler/crud_flag_creation.go index cffd4ce95..6e9129b33 100644 --- a/pkg/handler/crud_flag_creation.go +++ b/pkg/handler/crud_flag_creation.go @@ -55,7 +55,7 @@ func (c *crud) CreateFlag(params flag.CreateFlagParams) middleware.Responder { } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), f.ID, getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), f.ID, getSubjectFromRequest(params.HTTPRequest), "create") return resp } diff --git a/pkg/handler/crud_notification_test.go b/pkg/handler/crud_notification_test.go new file mode 100644 index 000000000..28e143a0b --- /dev/null +++ b/pkg/handler/crud_notification_test.go @@ -0,0 +1,124 @@ +package handler + +import ( + "net/http" + "testing" + "time" + + "github.com/openflagr/flagr/pkg/config" + "github.com/openflagr/flagr/pkg/entity" + "github.com/openflagr/flagr/pkg/notification" + "github.com/openflagr/flagr/pkg/util" + "github.com/openflagr/flagr/swagger_gen/models" + "github.com/openflagr/flagr/swagger_gen/restapi/operations/flag" + "github.com/prashantv/gostub" + "github.com/stretchr/testify/assert" +) + +func TestHandlerNotifications(t *testing.T) { + db := entity.NewTestDB() + defer gostub.StubFunc(&getDB, db).Reset() + + mockNotifier := notification.NewMockNotifier() + notification.SetNotifier(mockNotifier) + defer notification.SetNotifier(nil) + + c := NewCRUD() + + t.Run("CreateFlag sends notification", func(t *testing.T) { + mockNotifier.ClearSent() + params := flag.CreateFlagParams{ + HTTPRequest: &http.Request{}, + Body: &models.CreateFlagRequest{ + Description: util.StringPtr("test flag"), + Key: "test_flag_notif", + }, + } + c.CreateFlag(params) + + // Notifications are sent in a goroutine, so we might need a small wait or check repeatedly + assert.Eventually(t, func() bool { + return len(mockNotifier.GetSentNotifications()) > 0 + }, 1*time.Second, 10*time.Millisecond) + + sent := mockNotifier.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, notification.OperationCreate, sent[0].Operation) + assert.Equal(t, notification.EntityTypeFlag, sent[0].EntityType) + assert.Equal(t, "test_flag_notif", sent[0].EntityKey) + // Privacy by default + assert.Empty(t, sent[0].PreValue) + assert.Empty(t, sent[0].PostValue) + assert.Empty(t, sent[0].Diff) + }) + + t.Run("PutFlag sends notification", func(t *testing.T) { + f := entity.GenFixtureFlag() + db.Create(&f) + mockNotifier.ClearSent() + + params := flag.PutFlagParams{ + FlagID: int64(f.ID), + Body: &models.PutFlagRequest{ + Description: util.StringPtr("updated description"), + }, + HTTPRequest: &http.Request{}, + } + c.PutFlag(params) + + assert.Eventually(t, func() bool { + return len(mockNotifier.GetSentNotifications()) > 0 + }, 1*time.Second, 10*time.Millisecond) + + sent := mockNotifier.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, notification.OperationUpdate, sent[0].Operation) + assert.Equal(t, f.Key, sent[0].EntityKey) + // Privacy by default + assert.Empty(t, sent[0].PreValue) + assert.Empty(t, sent[0].PostValue) + assert.Empty(t, sent[0].Diff) + }) + + t.Run("PutFlag with detailed diff enabled", func(t *testing.T) { + stubs := gostub.Stub(&config.Config.NotificationDetailedDiffEnabled, true) + defer stubs.Reset() + + f := entity.GenFixtureFlag() + f.ID = 0 // Allow DB to assign new ID + f.Key = "detailed_diff_flag" + db.Create(&f) + mockNotifier.ClearSent() + + // First update to create first snapshot + params1 := flag.PutFlagParams{ + FlagID: int64(f.ID), + Body: &models.PutFlagRequest{ + Description: util.StringPtr("first update"), + }, + HTTPRequest: &http.Request{}, + } + c.PutFlag(params1) + + // Second update to trigger diff calculation + params2 := flag.PutFlagParams{ + FlagID: int64(f.ID), + Body: &models.PutFlagRequest{ + Description: util.StringPtr("second update"), + }, + HTTPRequest: &http.Request{}, + } + c.PutFlag(params2) + + assert.Eventually(t, func() bool { + return len(mockNotifier.GetSentNotifications()) >= 2 + }, 1*time.Second, 10*time.Millisecond) + + sent := mockNotifier.GetSentNotifications() + assert.Len(t, sent, 2) + // Second notification should have a diff + assert.NotEmpty(t, sent[1].Diff) + assert.Contains(t, sent[1].Diff, "- \"Description\": \"first update\"") + assert.Contains(t, sent[1].Diff, "+ \"Description\": \"second update\"") + }) +} diff --git a/pkg/handler/export_test.go b/pkg/handler/export_test.go index 2c0cfabe6..5a9aa5b9f 100644 --- a/pkg/handler/export_test.go +++ b/pkg/handler/export_test.go @@ -55,7 +55,7 @@ func TestExportFlags(t *testing.T) { func TestExportFlagSnapshots(t *testing.T) { f := entity.GenFixtureFlag() db := entity.PopulateTestDB(f) - entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com") + entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", "test") tmpDB1, dbErr1 := db.DB() if dbErr1 != nil { @@ -84,7 +84,7 @@ func TestExportFlagSnapshots(t *testing.T) { func TestExportSQLiteFile(t *testing.T) { f := entity.GenFixtureFlag() db := entity.PopulateTestDB(f) - entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com") + entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", "test") tmpDB1, dbErr1 := db.DB() if dbErr1 != nil { @@ -114,7 +114,7 @@ func TestExportSQLiteFile(t *testing.T) { func TestExportSQLiteHandler(t *testing.T) { f := entity.GenFixtureFlag() db := entity.PopulateTestDB(f) - entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com") + entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", "test") tmpDB1, dbErr1 := db.DB() if dbErr1 != nil { diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go index 43bbfbd1c..5d5c596ff 100644 --- a/pkg/handler/handler.go +++ b/pkg/handler/handler.go @@ -19,7 +19,7 @@ import ( var getDB = entity.GetDB -// Setup initialize all the handler functions +// Setup initialize all of handler functions func Setup(api *operations.FlagrAPI) { if config.Config.EvalOnlyMode { setupHealth(api) @@ -35,7 +35,6 @@ func Setup(api *operations.FlagrAPI) { func setupCRUD(api *operations.FlagrAPI) { c := NewCRUD() - // flags api.FlagFindFlagsHandler = flag.FindFlagsHandlerFunc(c.FindFlags) api.FlagCreateFlagHandler = flag.CreateFlagHandlerFunc(c.CreateFlag) api.FlagGetFlagHandler = flag.GetFlagHandlerFunc(c.GetFlag) @@ -46,30 +45,25 @@ func setupCRUD(api *operations.FlagrAPI) { api.FlagGetFlagSnapshotsHandler = flag.GetFlagSnapshotsHandlerFunc(c.GetFlagSnapshots) api.FlagGetFlagEntityTypesHandler = flag.GetFlagEntityTypesHandlerFunc(c.GetFlagEntityTypes) - // tags api.TagCreateTagHandler = tag.CreateTagHandlerFunc(c.CreateTag) api.TagDeleteTagHandler = tag.DeleteTagHandlerFunc(c.DeleteTag) api.TagFindTagsHandler = tag.FindTagsHandlerFunc(c.FindTags) api.TagFindAllTagsHandler = tag.FindAllTagsHandlerFunc(c.FindAllTags) - // segments api.SegmentCreateSegmentHandler = segment.CreateSegmentHandlerFunc(c.CreateSegment) api.SegmentFindSegmentsHandler = segment.FindSegmentsHandlerFunc(c.FindSegments) api.SegmentPutSegmentHandler = segment.PutSegmentHandlerFunc(c.PutSegment) api.SegmentDeleteSegmentHandler = segment.DeleteSegmentHandlerFunc(c.DeleteSegment) api.SegmentPutSegmentsReorderHandler = segment.PutSegmentsReorderHandlerFunc(c.PutSegmentsReorder) - // constraints api.ConstraintCreateConstraintHandler = constraint.CreateConstraintHandlerFunc(c.CreateConstraint) api.ConstraintFindConstraintsHandler = constraint.FindConstraintsHandlerFunc(c.FindConstraints) api.ConstraintPutConstraintHandler = constraint.PutConstraintHandlerFunc(c.PutConstraint) api.ConstraintDeleteConstraintHandler = constraint.DeleteConstraintHandlerFunc(c.DeleteConstraint) - // distributions api.DistributionFindDistributionsHandler = distribution.FindDistributionsHandlerFunc(c.FindDistributions) api.DistributionPutDistributionsHandler = distribution.PutDistributionsHandlerFunc(c.PutDistributions) - // variants api.VariantCreateVariantHandler = variant.CreateVariantHandlerFunc(c.CreateVariant) api.VariantFindVariantsHandler = variant.FindVariantsHandlerFunc(c.FindVariants) api.VariantPutVariantHandler = variant.PutVariantHandlerFunc(c.PutVariant) @@ -85,7 +79,6 @@ func setupEvaluation(api *operations.FlagrAPI) { api.EvaluationPostEvaluationBatchHandler = evaluation.PostEvaluationBatchHandlerFunc(e.PostEvaluationBatch) if config.Config.RecorderEnabled { - // Try GetDataRecorder to catch fatal errors before we start the evaluation api GetDataRecorder() } } diff --git a/pkg/notification/README.md b/pkg/notification/README.md new file mode 100644 index 000000000..c7e825f8f --- /dev/null +++ b/pkg/notification/README.md @@ -0,0 +1,56 @@ +# Notification Feature + +## Overview + +Flagr now supports sending notifications for CRUD operations via Slack or other notification providers. + +## Configuration + +Set these environment variables to enable notifications: + +- `FLAGR_NOTIFICATION_ENABLED=true` - Enable notifications (default: false) +- `FLAGR_NOTIFICATION_PROVIDER=slack` - Notification provider (default: slack) +- `FLAGR_NOTIFICATION_SLACK_WEBHOOK_URL=...` - Slack webhook URL +- `FLAGR_NOTIFICATION_SLACK_CHANNEL=#channel-name` - Optional Slack channel + +## How It Works + +1. After successful CRUD operations, `SaveFlagSnapshot()` is called +2. The snapshot is saved to the database +3. A notification is sent asynchronously via `SendFlagNotification()` +4. Notifications are non-blocking - failures are logged but don't affect the operation + +## Operations That Trigger Notifications + +- Create, Update, Delete, Restore flags +- Enable/Disable flags +- Create tags +- Create, Update, Delete segments +- Create, Update, Delete constraints +- Update distributions +- Create, Update, Delete variants + +## Notification Format + +``` +:rocket: *create flag* +*Key:* my-feature-flag +*ID:* 123 +*User:* user@example.com +``` + +## Testing + +```bash +# Run notification tests +go test ./pkg/notification/... -v + +# Run tests with mock notifier +go test ./pkg/notification/... -run TestNotification -v +``` + +## Adding New Providers + +1. Implement `Notifier` interface in `pkg/notification/` +2. Update `GetNotifier()` to support the new provider +3. Add configuration options in `pkg/config/env.go` diff --git a/pkg/notification/email.go b/pkg/notification/email.go new file mode 100644 index 000000000..7202e2db7 --- /dev/null +++ b/pkg/notification/email.go @@ -0,0 +1,125 @@ +package notification + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/openflagr/flagr/pkg/config" + "github.com/sirupsen/logrus" +) + +type emailNotifier struct { + httpClient *http.Client +} + +func NewEmailNotifier() Notifier { + if config.Config.NotificationEmailURL == "" { + logrus.Warn("NotificationEmailURL is empty, using null notifier") + return &nullNotifier{} + } + + return &emailNotifier{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (e *emailNotifier) Send(ctx context.Context, n Notification) error { + subject := formatEmailSubject(n) + body := formatEmailBody(n) + + payload := map[string]string{ + "from": config.Config.NotificationEmailFrom, + "to": config.Config.NotificationEmailTo, + "subject": subject, + "text": body, + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal email payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", config.Config.NotificationEmailURL, bytes.NewReader(jsonPayload)) + if err != nil { + return fmt.Errorf("failed to create email request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + if config.Config.NotificationEmailAPIKey != "" { + req.Header.Set("Authorization", "Bearer "+config.Config.NotificationEmailAPIKey) + } + + resp, err := e.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("email service returned error: %d - %s", resp.StatusCode, string(body)) + } + + logrus.WithFields(logrus.Fields{ + "status": resp.StatusCode, + "to": config.Config.NotificationEmailTo, + "from": config.Config.NotificationEmailFrom, + "subject": subject, + }).Info("email notification sent successfully") + return nil +} + +func formatEmailSubject(n Notification) string { + return fmt.Sprintf("[Flagr] %s %s", n.Operation, n.EntityType) +} + +func formatEmailBody(n Notification) string { + var emoji string + switch n.Operation { + case "create": + emoji = "🚀" + case "update": + emoji = "âœī¸" + case "delete": + emoji = "đŸ—‘ī¸" + default: + emoji = "â„šī¸" + } + + userInfo := "anonymous" + if n.User != "" { + userInfo = n.User + } + + body := fmt.Sprintf( + "%s %s %s\n\n"+ + "Key: %s\n"+ + "ID: %d\n", + emoji, n.Operation, n.EntityType, n.EntityKey, n.EntityID, + ) + + if n.Description != "" { + body += fmt.Sprintf("Description: %s\n", n.Description) + } + + body += fmt.Sprintf("User: %s\n", userInfo) + + if n.Diff != "" { + body += fmt.Sprintf("\nDiff:\n%s\n", n.Diff) + } + + if n.PreValue != "" { + body += fmt.Sprintf("\nPre-value:\n%s\n", n.PreValue) + } + if n.PostValue != "" { + body += fmt.Sprintf("\nPost-value:\n%s\n", n.PostValue) + } + + return body +} diff --git a/pkg/notification/email_test.go b/pkg/notification/email_test.go new file mode 100644 index 000000000..e18b0f962 --- /dev/null +++ b/pkg/notification/email_test.go @@ -0,0 +1,94 @@ +package notification + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEmailNotifier(t *testing.T) { + t.Run("returns null notifier when no email URL", func(t *testing.T) { + en := NewEmailNotifier() + ctx := context.Background() + notif := Notification{ + Operation: "create", + EntityType: "flag", + EntityID: 1, + EntityKey: "test-flag", + User: "test@example.com", + } + + err := en.Send(ctx, notif) + assert.NoError(t, err) + }) + + t.Run("formats subject correctly", func(t *testing.T) { + n := Notification{Operation: "create", EntityType: "flag"} + subject := formatEmailSubject(n) + assert.Equal(t, "[Flagr] create flag", subject) + }) + + t.Run("formats body correctly for create", func(t *testing.T) { + n := Notification{ + Operation: "create", + EntityType: "flag", + EntityKey: "test-flag", + EntityID: 1, + User: "user@example.com", + } + body := formatEmailBody(n) + assert.Contains(t, body, "🚀") + assert.Contains(t, body, "create flag") + assert.Contains(t, body, "Key: test-flag") + assert.Contains(t, body, "ID: 1") + assert.Contains(t, body, "User: user@example.com") + }) + + t.Run("formats body correctly for update", func(t *testing.T) { + n := Notification{ + Operation: "update", + EntityType: "flag", + } + body := formatEmailBody(n) + assert.Contains(t, body, "âœī¸") + assert.Contains(t, body, "update flag") + }) + + t.Run("formats body correctly for delete", func(t *testing.T) { + n := Notification{ + Operation: "delete", + EntityType: "segment", + } + body := formatEmailBody(n) + assert.Contains(t, body, "đŸ—‘ī¸") + assert.Contains(t, body, "delete segment") + }) + + t.Run("formats body correctly with description and values", func(t *testing.T) { + n := Notification{ + Operation: "update", + EntityType: "flag", + EntityKey: "test-flag", + EntityID: 1, + Description: "test description", + PreValue: `{"enabled": false}`, + PostValue: `{"enabled": true}`, + User: "user@example.com", + } + body := formatEmailBody(n) + assert.Contains(t, body, "Description: test description") + assert.Contains(t, body, "Pre-value:\n{\"enabled\": false}") + assert.Contains(t, body, "Post-value:\n{\"enabled\": true}") + }) + + t.Run("formats body correctly with diff", func(t *testing.T) { + n := Notification{ + Operation: "update", + EntityType: "flag", + Diff: "-old\n+new", + } + body := formatEmailBody(n) + assert.Contains(t, body, "Diff:\n-old\n+new") + }) +} diff --git a/pkg/notification/hook.go b/pkg/notification/hook.go new file mode 100644 index 000000000..b768a5630 --- /dev/null +++ b/pkg/notification/hook.go @@ -0,0 +1,93 @@ +package notification + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + "github.com/pmezard/go-difflib/difflib" + "github.com/sirupsen/logrus" +) + +func SendNotification(operation Operation, entityType EntityType, entityID uint, entityKey string, description string, preValue string, postValue string, diff string, user string) { + go func() { + defer func() { + if r := recover(); r != nil { + logrus.WithField("panic", r).Error("panic in SendNotification") + } + }() + + ctx := context.Background() + notifier := GetNotifier() + + notif := Notification{ + Operation: operation, + EntityType: entityType, + EntityID: entityID, + EntityKey: entityKey, + Description: description, + PreValue: preValue, + PostValue: postValue, + Diff: diff, + User: user, + Details: make(map[string]any), + } + + if err := notifier.Send(ctx, notif); err != nil { + logrus.WithFields(logrus.Fields{ + "operation": operation, + "entityType": entityType, + "entityID": entityID, + "error": err, + }).Warn("failed to send notification") + } + }() +} + +func SendFlagNotification(operation Operation, flagID uint, flagKey string, description string, preValue string, postValue string, diff string, user string) { + SendNotification(operation, EntityTypeFlag, flagID, flagKey, description, preValue, postValue, diff, user) +} + +func SendSegmentNotification(operation Operation, segmentID uint, flagID uint, user string) { + key := fmt.Sprintf("segment-%d-of-flag-%d", segmentID, flagID) + SendNotification(operation, EntityTypeSegment, segmentID, key, "", "", "", "", user) +} + +func SendVariantNotification(operation Operation, variantID uint, flagID uint, variantKey, user string) { + key := fmt.Sprintf("variant-%s-of-flag-%d", variantKey, flagID) + SendNotification(operation, EntityTypeVariant, variantID, key, "", "", "", "", user) +} + +func SendConstraintNotification(operation Operation, constraintID uint, segmentID uint, flagID uint, user string) { + key := fmt.Sprintf("constraint-%d-of-segment-%d", constraintID, segmentID) + SendNotification(operation, EntityTypeConstraint, constraintID, key, "", "", "", "", user) +} + +func CalculateDiff(pre, post string) string { + if pre == "" || post == "" { + return "" + } + + prePretty := prettyPrintJSON(pre) + postPretty := prettyPrintJSON(post) + + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(prePretty), + B: difflib.SplitLines(postPretty), + FromFile: "Previous", + ToFile: "Current", + Context: 3, + } + text, _ := difflib.GetUnifiedDiffString(diff) + return text +} + +func prettyPrintJSON(s string) string { + var out bytes.Buffer + err := json.Indent(&out, []byte(s), "", " ") + if err != nil { + return s + } + return out.String() +} diff --git a/pkg/notification/hook_test.go b/pkg/notification/hook_test.go new file mode 100644 index 000000000..85d438d08 --- /dev/null +++ b/pkg/notification/hook_test.go @@ -0,0 +1,34 @@ +package notification + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCalculateDiff(t *testing.T) { + t.Run("empty cases", func(t *testing.T) { + assert.Empty(t, CalculateDiff("", "")) + assert.Empty(t, CalculateDiff("a", "")) + assert.Empty(t, CalculateDiff("", "b")) + }) + + t.Run("simple diff", func(t *testing.T) { + pre := "line1\nline2\n" + post := "line1\nline3\n" + diff := CalculateDiff(pre, post) + assert.NotEmpty(t, diff) + assert.Contains(t, diff, "-line2") + assert.Contains(t, diff, "+line3") + }) + + t.Run("JSON diff visibility", func(t *testing.T) { + pre := `{"id":1,"key":"flag1","enabled":false}` + post := `{"id":1,"key":"flag1","enabled":true}` + diff := CalculateDiff(pre, post) + t.Logf("Pretty JSON Diff:\n%s", diff) + // Pretty JSON diff shows individual field changes + assert.Contains(t, diff, "- \"enabled\": false") + assert.Contains(t, diff, "+ \"enabled\": true") + }) +} diff --git a/pkg/notification/notifier.go b/pkg/notification/notifier.go new file mode 100644 index 000000000..f92330bc6 --- /dev/null +++ b/pkg/notification/notifier.go @@ -0,0 +1,129 @@ +package notification + +import ( + "context" + "sync" + + "github.com/openflagr/flagr/pkg/config" + "github.com/sirupsen/logrus" +) + +type Notifier interface { + Send(ctx context.Context, n Notification) error +} + +type Operation string + +const ( + OperationCreate Operation = "create" + OperationUpdate Operation = "update" + OperationDelete Operation = "delete" + OperationRestore Operation = "restore" +) + +type EntityType string + +const ( + EntityTypeFlag EntityType = "flag" + EntityTypeSegment EntityType = "segment" + EntityTypeVariant EntityType = "variant" + EntityTypeConstraint EntityType = "constraint" + EntityTypeTag EntityType = "tag" +) + +type Notification struct { + Operation Operation + EntityType EntityType + EntityID uint + EntityKey string + Description string + PreValue string + PostValue string + Diff string + User string + Details map[string]any +} + +var ( + singletonNotifier Notifier + once sync.Once +) + +// SetNotifier sets the global notifier, useful for testing +func SetNotifier(n Notifier) { + singletonNotifier = n +} + +func GetNotifier() Notifier { + if singletonNotifier != nil { + return singletonNotifier + } + + once.Do(func() { + if !config.Config.NotificationEnabled { + singletonNotifier = &nullNotifier{} + return + } + + switch config.Config.NotificationProvider { + case "slack": + singletonNotifier = NewSlackNotifier() + case "email": + singletonNotifier = NewEmailNotifier() + default: + logrus.Warnf("unknown notification provider: %s, using null notifier", config.Config.NotificationProvider) + singletonNotifier = &nullNotifier{} + } + }) + + if singletonNotifier == nil { + return &nullNotifier{} + } + + return singletonNotifier +} + +type nullNotifier struct{} + +func (n *nullNotifier) Send(ctx context.Context, notification Notification) error { + return nil +} + +type MockNotifier struct { + sent []Notification + mu sync.Mutex + sendError error +} + +func NewMockNotifier() *MockNotifier { + return &MockNotifier{ + sent: make([]Notification, 0), + } +} + +func (m *MockNotifier) Send(ctx context.Context, n Notification) error { + m.mu.Lock() + defer m.mu.Unlock() + m.sent = append(m.sent, n) + return m.sendError +} + +func (m *MockNotifier) SetSendError(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.sendError = err +} + +func (m *MockNotifier) GetSentNotifications() []Notification { + m.mu.Lock() + defer m.mu.Unlock() + result := make([]Notification, len(m.sent)) + copy(result, m.sent) + return result +} + +func (m *MockNotifier) ClearSent() { + m.mu.Lock() + defer m.mu.Unlock() + m.sent = make([]Notification, 0) +} diff --git a/pkg/notification/notifier_test.go b/pkg/notification/notifier_test.go new file mode 100644 index 000000000..156e6a48e --- /dev/null +++ b/pkg/notification/notifier_test.go @@ -0,0 +1,88 @@ +package notification + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNotification(t *testing.T) { + t.Run("null notifier should not fail", func(t *testing.T) { + n := &nullNotifier{} + ctx := context.Background() + notif := Notification{ + Operation: OperationCreate, + EntityType: EntityTypeFlag, + EntityID: 1, + EntityKey: "test-flag", + User: "test@example.com", + } + + err := n.Send(ctx, notif) + assert.NoError(t, err) + }) + + t.Run("mock notifier records sent notifications", func(t *testing.T) { + m := NewMockNotifier() + ctx := context.Background() + + notif1 := Notification{ + Operation: OperationCreate, + EntityType: EntityTypeFlag, + EntityID: 1, + EntityKey: "test-flag-1", + User: "user1@example.com", + } + + notif2 := Notification{ + Operation: OperationUpdate, + EntityType: EntityTypeFlag, + EntityID: 2, + EntityKey: "test-flag-2", + User: "user2@example.com", + } + + err1 := m.Send(ctx, notif1) + err2 := m.Send(ctx, notif2) + + assert.NoError(t, err1) + assert.NoError(t, err2) + + sent := m.GetSentNotifications() + assert.Len(t, sent, 2) + assert.Equal(t, OperationCreate, sent[0].Operation) + assert.Equal(t, EntityTypeFlag, sent[0].EntityType) + assert.Equal(t, uint(1), sent[0].EntityID) + assert.Equal(t, "test-flag-1", sent[0].EntityKey) + + assert.Equal(t, OperationUpdate, sent[1].Operation) + assert.Equal(t, EntityTypeFlag, sent[1].EntityType) + assert.Equal(t, uint(2), sent[1].EntityID) + assert.Equal(t, "test-flag-2", sent[1].EntityKey) + }) + + t.Run("mock notifier can return errors", func(t *testing.T) { + m := NewMockNotifier() + m.SetSendError(assert.AnError) + + ctx := context.Background() + notif := Notification{Operation: Operation("test")} + + err := m.Send(ctx, notif) + assert.Error(t, err) + }) + + t.Run("mock notifier clear works", func(t *testing.T) { + m := NewMockNotifier() + ctx := context.Background() + + m.Send(ctx, Notification{Operation: "test"}) + m.Send(ctx, Notification{Operation: "test"}) + + assert.Len(t, m.GetSentNotifications(), 2) + + m.ClearSent() + assert.Len(t, m.GetSentNotifications(), 0) + }) +} diff --git a/pkg/notification/slack.go b/pkg/notification/slack.go new file mode 100644 index 000000000..6be6a25ce --- /dev/null +++ b/pkg/notification/slack.go @@ -0,0 +1,79 @@ +package notification + +import ( + "context" + "fmt" + + notify "github.com/nikoksr/notify" + notifySlack "github.com/nikoksr/notify/service/slack" + "github.com/openflagr/flagr/pkg/config" + "github.com/sirupsen/logrus" +) + +type slackNotifier struct { + client *notify.Notify +} + +func NewSlackNotifier() Notifier { + if config.Config.NotificationSlackWebhookURL == "" { + logrus.Warn("NotificationSlackWebhookURL is empty, using null notifier") + return &nullNotifier{} + } + + slackService := notifySlack.New(config.Config.NotificationSlackWebhookURL) + + if config.Config.NotificationSlackChannel != "" { + slackService.AddReceivers(config.Config.NotificationSlackChannel) + } + + n := notify.New() + n.UseServices(slackService) + + return &slackNotifier{client: n} +} + +func (s *slackNotifier) Send(ctx context.Context, n Notification) error { + subject := fmt.Sprintf("%s %s", n.Operation, n.EntityType) + message := formatNotification(n) + return s.client.Send(ctx, subject, message) +} + +func formatNotification(n Notification) string { + var emoji string + switch n.Operation { + case "create": + emoji = ":rocket:" + case "update": + emoji = ":pencil2:" + case "delete": + emoji = ":wastebasket:" + default: + emoji = ":information_source:" + } + + userInfo := "anonymous" + if n.User != "" { + userInfo = n.User + } + + msg := fmt.Sprintf("%s *%s %s*\n", emoji, n.Operation, n.EntityType) + msg += fmt.Sprintf("*Key:* %s\n", n.EntityKey) + msg += fmt.Sprintf("*ID:* %d\n", n.EntityID) + if n.Description != "" { + msg += fmt.Sprintf("*Description:* %s\n", n.Description) + } + msg += fmt.Sprintf("*User:* %s\n", userInfo) + + if n.Diff != "" { + msg += fmt.Sprintf("*Diff:*\n```diff\n%s\n```\n", n.Diff) + } + + if n.PreValue != "" { + msg += fmt.Sprintf("*Pre-value:*\n```json\n%s\n```\n", n.PreValue) + } + if n.PostValue != "" { + msg += fmt.Sprintf("*Post-value:*\n```json\n%s\n```\n", n.PostValue) + } + + return msg +} From 2d3e8e9d2a6cf6d097c67bd15e43122df18e003d Mon Sep 17 00:00:00 2001 From: nothing0012 Date: Wed, 24 Dec 2025 15:06:26 +0400 Subject: [PATCH 02/18] refactor: use Operation and EntityType constants in notifiers Updated slack.go and email.go to use the defined Operation constants instead of string literals for better consistency and type safety. --- pkg/notification/email.go | 6 +++--- pkg/notification/slack.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/notification/email.go b/pkg/notification/email.go index 7202e2db7..cc7dc9e50 100644 --- a/pkg/notification/email.go +++ b/pkg/notification/email.go @@ -82,11 +82,11 @@ func formatEmailSubject(n Notification) string { func formatEmailBody(n Notification) string { var emoji string switch n.Operation { - case "create": + case OperationCreate: emoji = "🚀" - case "update": + case OperationUpdate: emoji = "âœī¸" - case "delete": + case OperationDelete: emoji = "đŸ—‘ī¸" default: emoji = "â„šī¸" diff --git a/pkg/notification/slack.go b/pkg/notification/slack.go index 6be6a25ce..daa651e2c 100644 --- a/pkg/notification/slack.go +++ b/pkg/notification/slack.go @@ -41,11 +41,11 @@ func (s *slackNotifier) Send(ctx context.Context, n Notification) error { func formatNotification(n Notification) string { var emoji string switch n.Operation { - case "create": + case OperationCreate: emoji = ":rocket:" - case "update": + case OperationUpdate: emoji = ":pencil2:" - case "delete": + case OperationDelete: emoji = ":wastebasket:" default: emoji = ":information_source:" From 04f7d960f8926dd2b6331a6a4dff4e58baae63bd Mon Sep 17 00:00:00 2001 From: nothing0012 Date: Wed, 24 Dec 2025 15:13:38 +0400 Subject: [PATCH 03/18] refactor: use notification.Operation in SaveFlagSnapshot Refactored entity.SaveFlagSnapshot to accept notification.Operation instead of a string, improving type safety and consistency across handler calls. --- pkg/entity/flag_snapshot.go | 4 ++-- pkg/entity/flag_snapshot_test.go | 4 +++- pkg/handler/crud.go | 31 ++++++++++++++++--------------- pkg/handler/crud_flag_creation.go | 3 ++- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/pkg/entity/flag_snapshot.go b/pkg/entity/flag_snapshot.go index cec758fb0..febdaeb6c 100644 --- a/pkg/entity/flag_snapshot.go +++ b/pkg/entity/flag_snapshot.go @@ -22,7 +22,7 @@ type FlagSnapshot struct { } // SaveFlagSnapshot saves the Flag Snapshot -func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string, operation string) { +func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string, operation notification.Operation) { tx := db.Begin() f := &Flag{} if err := tx.First(f, flagID).Error; err != nil { @@ -86,7 +86,7 @@ func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string, operation stri logFlagSnapshotUpdate(flagID, updatedBy) notification.SendFlagNotification( - notification.Operation(operation), + operation, flagID, f.Key, f.Description, diff --git a/pkg/entity/flag_snapshot_test.go b/pkg/entity/flag_snapshot_test.go index 2ac1ee5cd..6cf3899f1 100644 --- a/pkg/entity/flag_snapshot_test.go +++ b/pkg/entity/flag_snapshot_test.go @@ -2,6 +2,8 @@ package entity import ( "testing" + + "github.com/openflagr/flagr/pkg/notification" ) func TestSaveFlagSnapshot(t *testing.T) { @@ -16,7 +18,7 @@ func TestSaveFlagSnapshot(t *testing.T) { defer tmpDB.Close() t.Run("happy code path", func(t *testing.T) { - SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", "test") + SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", notification.OperationUpdate) }) t.Run("save on non-existing flag", func(t *testing.T) { diff --git a/pkg/handler/crud.go b/pkg/handler/crud.go index 754bb751d..97b128c54 100644 --- a/pkg/handler/crud.go +++ b/pkg/handler/crud.go @@ -8,6 +8,7 @@ import ( "github.com/openflagr/flagr/pkg/entity" "github.com/openflagr/flagr/pkg/mapper/entity_restapi/e2r" "github.com/openflagr/flagr/pkg/mapper/entity_restapi/r2e" + "github.com/openflagr/flagr/pkg/notification" "github.com/openflagr/flagr/pkg/util" "github.com/openflagr/flagr/swagger_gen/restapi/operations/constraint" "github.com/openflagr/flagr/swagger_gen/restapi/operations/distribution" @@ -270,7 +271,7 @@ func (c *crud) PutFlag(params flag.PutFlagParams) middleware.Responder { } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return resp } @@ -293,7 +294,7 @@ func (c *crud) SetFlagEnabledState(params flag.SetFlagEnabledParams) middleware. } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return resp } @@ -316,7 +317,7 @@ func (c *crud) RestoreFlag(params flag.RestoreFlagParams) middleware.Responder { } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "restore") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationRestore) return resp } @@ -337,7 +338,7 @@ func (c *crud) DeleteTag(params tag.DeleteTagParams) middleware.Responder { if err := getDB().Model(s).Association("Tags").Delete(t); err != nil { return tag.NewDeleteTagDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return tag.NewDeleteTagOK() } @@ -399,7 +400,7 @@ func (c *crud) CreateTag(params tag.CreateTagParams) middleware.Responder { resp := tag.NewCreateTagOK() resp.SetPayload(e2r.MapTag(t)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return resp } @@ -418,7 +419,7 @@ func (c *crud) CreateSegment(params segment.CreateSegmentParams) middleware.Resp resp := segment.NewCreateSegmentOK() resp.SetPayload(e2r.MapSegment(s)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return resp } @@ -461,7 +462,7 @@ func (c *crud) PutSegment(params segment.PutSegmentParams) middleware.Responder resp := segment.NewPutSegmentOK() resp.SetPayload(e2r.MapSegment(s)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return resp } @@ -485,7 +486,7 @@ func (c *crud) PutSegmentsReorder(params segment.PutSegmentsReorderParams) middl return segment.NewPutSegmentsReorderDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return segment.NewPutSegmentsReorderOK() } @@ -495,7 +496,7 @@ func (c *crud) DeleteSegment(params segment.DeleteSegmentParams) middleware.Resp return segment.NewDeleteSegmentDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return segment.NewDeleteSegmentOK() } @@ -517,7 +518,7 @@ func (c *crud) CreateConstraint(params constraint.CreateConstraintParams) middle resp := constraint.NewCreateConstraintOK() resp.SetPayload(e2r.MapConstraint(cons)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return resp } @@ -555,7 +556,7 @@ func (c *crud) PutConstraint(params constraint.PutConstraintParams) middleware.R resp := constraint.NewPutConstraintOK() resp.SetPayload(e2r.MapConstraint(cons)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return resp } @@ -566,7 +567,7 @@ func (c *crud) DeleteConstraint(params constraint.DeleteConstraintParams) middle resp := constraint.NewDeleteConstraintOK() - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return resp } @@ -602,7 +603,7 @@ func (c *crud) PutDistributions(params distribution.PutDistributionsParams) midd resp := distribution.NewPutDistributionsOK() resp.SetPayload(e2r.MapDistributions(ds)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return resp } @@ -644,7 +645,7 @@ func (c *crud) CreateVariant(params variant.CreateVariantParams) middleware.Resp resp := variant.NewCreateVariantOK() resp.SetPayload(e2r.MapVariant(v)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return resp } @@ -695,7 +696,7 @@ func (c *crud) PutVariant(params variant.PutVariantParams) middleware.Responder resp := variant.NewPutVariantOK() resp.SetPayload(e2r.MapVariant(v)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return resp } diff --git a/pkg/handler/crud_flag_creation.go b/pkg/handler/crud_flag_creation.go index 6e9129b33..3d1afce2a 100644 --- a/pkg/handler/crud_flag_creation.go +++ b/pkg/handler/crud_flag_creation.go @@ -3,6 +3,7 @@ package handler import ( "github.com/go-openapi/runtime/middleware" "github.com/openflagr/flagr/pkg/entity" + "github.com/openflagr/flagr/pkg/notification" "github.com/openflagr/flagr/pkg/util" "github.com/openflagr/flagr/swagger_gen/restapi/operations/flag" "gorm.io/gorm" @@ -55,7 +56,7 @@ func (c *crud) CreateFlag(params flag.CreateFlagParams) middleware.Responder { } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), f.ID, getSubjectFromRequest(params.HTTPRequest), "create") + entity.SaveFlagSnapshot(getDB(), f.ID, getSubjectFromRequest(params.HTTPRequest), notification.OperationCreate) return resp } From 9a58d76974d64b92b06222830b3d53eee51e9b5f Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Fri, 20 Feb 2026 19:48:15 +0000 Subject: [PATCH 04/18] rebase and go fix --- pkg/handler/crud_notification_test.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pkg/handler/crud_notification_test.go b/pkg/handler/crud_notification_test.go index 28e143a0b..8dd697f03 100644 --- a/pkg/handler/crud_notification_test.go +++ b/pkg/handler/crud_notification_test.go @@ -8,7 +8,6 @@ import ( "github.com/openflagr/flagr/pkg/config" "github.com/openflagr/flagr/pkg/entity" "github.com/openflagr/flagr/pkg/notification" - "github.com/openflagr/flagr/pkg/util" "github.com/openflagr/flagr/swagger_gen/models" "github.com/openflagr/flagr/swagger_gen/restapi/operations/flag" "github.com/prashantv/gostub" @@ -30,7 +29,7 @@ func TestHandlerNotifications(t *testing.T) { params := flag.CreateFlagParams{ HTTPRequest: &http.Request{}, Body: &models.CreateFlagRequest{ - Description: util.StringPtr("test flag"), + Description: new("test flag"), Key: "test_flag_notif", }, } @@ -60,7 +59,7 @@ func TestHandlerNotifications(t *testing.T) { params := flag.PutFlagParams{ FlagID: int64(f.ID), Body: &models.PutFlagRequest{ - Description: util.StringPtr("updated description"), + Description: new("updated description"), }, HTTPRequest: &http.Request{}, } @@ -94,7 +93,7 @@ func TestHandlerNotifications(t *testing.T) { params1 := flag.PutFlagParams{ FlagID: int64(f.ID), Body: &models.PutFlagRequest{ - Description: util.StringPtr("first update"), + Description: new("first update"), }, HTTPRequest: &http.Request{}, } @@ -104,7 +103,7 @@ func TestHandlerNotifications(t *testing.T) { params2 := flag.PutFlagParams{ FlagID: int64(f.ID), Body: &models.PutFlagRequest{ - Description: util.StringPtr("second update"), + Description: new("second update"), }, HTTPRequest: &http.Request{}, } From 3b42befaedc7d5189793ad5f6ac1efc9915fa6eb Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Fri, 20 Feb 2026 20:17:48 +0000 Subject: [PATCH 05/18] feat: refine notifications, add webhook provider, custom timeouts, and tests --- pkg/config/env.go | 6 +++ pkg/handler/handler.go | 2 +- pkg/notification/email.go | 3 +- pkg/notification/hook.go | 20 ++------ pkg/notification/notifier.go | 2 + pkg/notification/slack_test.go | 62 +++++++++++++++++++++++ pkg/notification/webhook.go | 66 ++++++++++++++++++++++++ pkg/notification/webhook_test.go | 59 ++++++++++++++++++++++ pkg/util/util.go | 24 +++++++++ pkg/util/util_test.go | 87 ++++++++++++++++++++++++++++++++ 10 files changed, 311 insertions(+), 20 deletions(-) create mode 100644 pkg/notification/slack_test.go create mode 100644 pkg/notification/webhook.go create mode 100644 pkg/notification/webhook_test.go diff --git a/pkg/config/env.go b/pkg/config/env.go index 980319c8e..d288842b8 100644 --- a/pkg/config/env.go +++ b/pkg/config/env.go @@ -240,6 +240,10 @@ var Config = struct { NotificationSlackWebhookURL string `env:"FLAGR_NOTIFICATION_SLACK_WEBHOOK_URL" envDefault:""` // NotificationSlackChannel - Slack channel to send notifications to NotificationSlackChannel string `env:"FLAGR_NOTIFICATION_SLACK_CHANNEL" envDefault:""` + // NotificationWebhookURL - Webhook URL for generic notifications + NotificationWebhookURL string `env:"FLAGR_NOTIFICATION_WEBHOOK_URL" envDefault:""` + // NotificationWebhookHeaders - Webhook Headers for generic notifications, e.g. "Authorization: Bearer token,X-Custom-Header: value" + NotificationWebhookHeaders string `env:"FLAGR_NOTIFICATION_WEBHOOK_HEADERS" envDefault:""` // NotificationEmailTo - recipient email address NotificationEmailTo string `env:"FLAGR_NOTIFICATION_EMAIL_TO" envDefault:""` // NotificationEmailURL - HTTP email API URL (e.g., https://api.sendgrid.com/v3/mail/send) @@ -248,6 +252,8 @@ var Config = struct { NotificationEmailAPIKey string `env:"FLAGR_NOTIFICATION_EMAIL_API_KEY" envDefault:""` // NotificationEmailFrom - sender email address NotificationEmailFrom string `env:"FLAGR_NOTIFICATION_EMAIL_FROM" envDefault:""` + // NotificationTimeout - timeout for sending notifications + NotificationTimeout time.Duration `env:"FLAGR_NOTIFICATION_TIMEOUT" envDefault:"10s"` // WebPrefix - base path for web and API // e.g. FLAGR_WEB_PREFIX=/foo diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go index 5d5c596ff..79558ba2b 100644 --- a/pkg/handler/handler.go +++ b/pkg/handler/handler.go @@ -19,7 +19,7 @@ import ( var getDB = entity.GetDB -// Setup initialize all of handler functions +// Setup initialize all the handler functions func Setup(api *operations.FlagrAPI) { if config.Config.EvalOnlyMode { setupHealth(api) diff --git a/pkg/notification/email.go b/pkg/notification/email.go index cc7dc9e50..a97cbfd1d 100644 --- a/pkg/notification/email.go +++ b/pkg/notification/email.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/http" - "time" "github.com/openflagr/flagr/pkg/config" "github.com/sirupsen/logrus" @@ -24,7 +23,7 @@ func NewEmailNotifier() Notifier { } return &emailNotifier{ - httpClient: &http.Client{Timeout: 10 * time.Second}, + httpClient: &http.Client{Timeout: config.Config.NotificationTimeout}, } } diff --git a/pkg/notification/hook.go b/pkg/notification/hook.go index b768a5630..2789f3853 100644 --- a/pkg/notification/hook.go +++ b/pkg/notification/hook.go @@ -4,8 +4,8 @@ import ( "bytes" "context" "encoding/json" - "fmt" + "github.com/openflagr/flagr/pkg/config" "github.com/pmezard/go-difflib/difflib" "github.com/sirupsen/logrus" ) @@ -18,7 +18,8 @@ func SendNotification(operation Operation, entityType EntityType, entityID uint, } }() - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), config.Config.NotificationTimeout) + defer cancel() notifier := GetNotifier() notif := Notification{ @@ -49,21 +50,6 @@ func SendFlagNotification(operation Operation, flagID uint, flagKey string, desc SendNotification(operation, EntityTypeFlag, flagID, flagKey, description, preValue, postValue, diff, user) } -func SendSegmentNotification(operation Operation, segmentID uint, flagID uint, user string) { - key := fmt.Sprintf("segment-%d-of-flag-%d", segmentID, flagID) - SendNotification(operation, EntityTypeSegment, segmentID, key, "", "", "", "", user) -} - -func SendVariantNotification(operation Operation, variantID uint, flagID uint, variantKey, user string) { - key := fmt.Sprintf("variant-%s-of-flag-%d", variantKey, flagID) - SendNotification(operation, EntityTypeVariant, variantID, key, "", "", "", "", user) -} - -func SendConstraintNotification(operation Operation, constraintID uint, segmentID uint, flagID uint, user string) { - key := fmt.Sprintf("constraint-%d-of-segment-%d", constraintID, segmentID) - SendNotification(operation, EntityTypeConstraint, constraintID, key, "", "", "", "", user) -} - func CalculateDiff(pre, post string) string { if pre == "" || post == "" { return "" diff --git a/pkg/notification/notifier.go b/pkg/notification/notifier.go index f92330bc6..898ea94b4 100644 --- a/pkg/notification/notifier.go +++ b/pkg/notification/notifier.go @@ -70,6 +70,8 @@ func GetNotifier() Notifier { singletonNotifier = NewSlackNotifier() case "email": singletonNotifier = NewEmailNotifier() + case "webhook": + singletonNotifier = NewWebhookNotifier() default: logrus.Warnf("unknown notification provider: %s, using null notifier", config.Config.NotificationProvider) singletonNotifier = &nullNotifier{} diff --git a/pkg/notification/slack_test.go b/pkg/notification/slack_test.go new file mode 100644 index 000000000..e64a814de --- /dev/null +++ b/pkg/notification/slack_test.go @@ -0,0 +1,62 @@ +package notification + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatNotification(t *testing.T) { + t.Run("basic format create", func(t *testing.T) { + n := Notification{ + Operation: OperationCreate, + EntityType: EntityTypeFlag, + EntityID: 123, + EntityKey: "my-flag", + User: "testuser", + } + msg := formatNotification(n) + assert.Contains(t, msg, ":rocket: *create flag*") + assert.Contains(t, msg, "*Key:* my-flag") + assert.Contains(t, msg, "*ID:* 123") + assert.Contains(t, msg, "*User:* testuser") + }) + + t.Run("basic format with description, diff and values", func(t *testing.T) { + n := Notification{ + Operation: OperationUpdate, + EntityType: EntityTypeFlag, + EntityID: 123, + EntityKey: "my-flag", + Description: "updated description", + PreValue: "{\"enabled\": false}", + PostValue: "{\"enabled\": true}", + Diff: "-false\n+true", + } + msg := formatNotification(n) + assert.Contains(t, msg, ":pencil2: *update flag*") + assert.Contains(t, msg, "*Description:* updated description") + assert.Contains(t, msg, "*Diff:*\n```diff\n-false\n+true\n```") + assert.Contains(t, msg, "*Pre-value:*\n```json\n{\"enabled\": false}\n```") + assert.Contains(t, msg, "*Post-value:*\n```json\n{\"enabled\": true}\n```") + assert.Contains(t, msg, "*User:* anonymous") // Default user + }) + + t.Run("basic format delete", func(t *testing.T) { + n := Notification{ + Operation: OperationDelete, + EntityType: EntityTypeFlag, + } + msg := formatNotification(n) + assert.Contains(t, msg, ":wastebasket: *delete flag*") + }) + + t.Run("basic format other", func(t *testing.T) { + n := Notification{ + Operation: OperationRestore, + EntityType: EntityTypeFlag, + } + msg := formatNotification(n) + assert.Contains(t, msg, ":information_source: *restore flag*") + }) +} diff --git a/pkg/notification/webhook.go b/pkg/notification/webhook.go new file mode 100644 index 000000000..c41c8e6ed --- /dev/null +++ b/pkg/notification/webhook.go @@ -0,0 +1,66 @@ +package notification + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/openflagr/flagr/pkg/config" + "github.com/openflagr/flagr/pkg/util" + "github.com/sirupsen/logrus" +) + +type webhookNotifier struct { + httpClient *http.Client +} + +func NewWebhookNotifier() Notifier { + if config.Config.NotificationWebhookURL == "" { + logrus.Warn("NotificationWebhookURL is empty, using null notifier") + return &nullNotifier{} + } + + return &webhookNotifier{ + httpClient: &http.Client{Timeout: config.Config.NotificationTimeout}, + } +} + +func (w *webhookNotifier) Send(ctx context.Context, n Notification) error { + jsonPayload, err := json.Marshal(n) + if err != nil { + return fmt.Errorf("failed to marshal webhook payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", config.Config.NotificationWebhookURL, bytes.NewReader(jsonPayload)) + if err != nil { + return fmt.Errorf("failed to create webhook request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + for k, v := range util.ParseHeaders(config.Config.NotificationWebhookHeaders) { + req.Header.Set(k, v) + } + + resp, err := w.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send webhook: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("webhook service returned error: %d - %s", resp.StatusCode, string(body)) + } + + logrus.WithFields(logrus.Fields{ + "status": resp.StatusCode, + "operation": n.Operation, + "entityID": n.EntityID, + }).Info("webhook notification sent successfully") + + return nil +} diff --git a/pkg/notification/webhook_test.go b/pkg/notification/webhook_test.go new file mode 100644 index 000000000..3032ab805 --- /dev/null +++ b/pkg/notification/webhook_test.go @@ -0,0 +1,59 @@ +package notification + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/openflagr/flagr/pkg/config" + "github.com/prashantv/gostub" + "github.com/stretchr/testify/assert" +) + +func TestWebhookNotifier(t *testing.T) { + t.Run("returns null notifier when no webhook URL", func(t *testing.T) { + wn := NewWebhookNotifier() + ctx := context.Background() + notif := Notification{ + Operation: "create", + EntityType: "flag", + EntityID: 1, + EntityKey: "test-flag", + User: "test@example.com", + } + + err := wn.Send(ctx, notif) + assert.NoError(t, err) + }) + + t.Run("sends custom headers", func(t *testing.T) { + + var receivedAuth string + var receivedCustomHeader string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + receivedCustomHeader = r.Header.Get("X-Custom-Header") + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + stubs := gostub.Stub(&config.Config.NotificationWebhookURL, ts.URL) + defer stubs.Reset() + + stubs.Stub(&config.Config.NotificationWebhookHeaders, "Authorization: Bearer secret-token, X-Custom-Header: custom-value ") + + wn := NewWebhookNotifier() + ctx := context.Background() + notif := Notification{ + Operation: "create", + EntityType: "flag", + } + + err := wn.Send(ctx, notif) + assert.NoError(t, err) + + assert.Equal(t, "Bearer secret-token", receivedAuth) + assert.Equal(t, "custom-value", receivedCustomHeader) + }) +} diff --git a/pkg/util/util.go b/pkg/util/util.go index 4990323e9..7c499401b 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -97,3 +97,27 @@ func Round(f float64) int { func TimeNow() string { return time.Now().UTC().Format(time.RFC3339) } + +// ParseHeaders converts a comma-separated list of key-value pairs separated by colons into a map of strings. +// It gracefully handles edge cases such as empty headers, missing values, spaces around keys and values, +// and malformed chunks by filtering them out. +// Example: "Authorization: Bearer token, X-Custom-Header: value" will be parsed correctly. +func ParseHeaders(headerStr string) map[string]string { + headers := make(map[string]string) + if headerStr == "" { + return headers + } + + pairs := strings.Split(headerStr, ",") + for _, pair := range pairs { + parts := strings.SplitN(pair, ":", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + if key != "" { + headers[key] = val + } + } + } + return headers +} diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go index 3cc3a8f75..937cb798b 100644 --- a/pkg/util/util_test.go +++ b/pkg/util/util_test.go @@ -284,3 +284,90 @@ func TestHasSafePrefix(t *testing.T) { }) } } + +func TestParseHeaders(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + }{ + { + name: "empty string", + input: "", + expected: map[string]string{}, + }, + { + name: "single valid header", + input: "Authorization: Bearer token", + expected: map[string]string{ + "Authorization": "Bearer token", + }, + }, + { + name: "multiple valid headers", + input: "Authorization: Bearer token, X-Custom-Header: value", + expected: map[string]string{ + "Authorization": "Bearer token", + "X-Custom-Header": "value", + }, + }, + { + name: "messy spacing around colons and commas", + input: " Auth : Token , Another : Value ", + expected: map[string]string{ + "Auth": "Token", + "Another": "Value", + }, + }, + { + name: "missing value formatting", + input: "Authorization:,", + expected: map[string]string{ + "Authorization": "", + }, + }, + { + name: "missing colon format is ignored", + input: "InvalidFormat", + expected: map[string]string{}, + }, + { + name: "extra colons in the value are kept", + input: "Trace-Id: 123:456:789", + expected: map[string]string{ + "Trace-Id": "123:456:789", + }, + }, + { + name: "spaces only", + input: " ", + expected: map[string]string{}, + }, + { + name: "colons only", + input: ":::", + expected: map[string]string{}, + }, + { + name: "trailing and leading commas", + input: ",Authorization: Bearer token,", + expected: map[string]string{ + "Authorization": "Bearer token", + }, + }, + { + name: "valid headers mixed with invalid garbage", + input: "InvalidFormat, Authorization: Bearer token, , :valueOnly", + expected: map[string]string{ + "Authorization": "Bearer token", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseHeaders(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} From b22e75e26ed1c98f42d2874617b01cac7ba80023 Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Fri, 20 Feb 2026 20:21:07 +0000 Subject: [PATCH 06/18] docs: update notification README --- pkg/notification/README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pkg/notification/README.md b/pkg/notification/README.md index c7e825f8f..8dc5f7b8a 100644 --- a/pkg/notification/README.md +++ b/pkg/notification/README.md @@ -9,10 +9,24 @@ Flagr now supports sending notifications for CRUD operations via Slack or other Set these environment variables to enable notifications: - `FLAGR_NOTIFICATION_ENABLED=true` - Enable notifications (default: false) -- `FLAGR_NOTIFICATION_PROVIDER=slack` - Notification provider (default: slack) +- `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true` - Include detailed value diffs in notifications (default: false) +- `FLAGR_NOTIFICATION_TIMEOUT=10s` - Timeout for HTTP requests when sending notifications +- `FLAGR_NOTIFICATION_PROVIDER=slack` - Notification provider (options: `slack`, `email`, `webhook`) + +### Slack - `FLAGR_NOTIFICATION_SLACK_WEBHOOK_URL=...` - Slack webhook URL - `FLAGR_NOTIFICATION_SLACK_CHANNEL=#channel-name` - Optional Slack channel +### Webhook +- `FLAGR_NOTIFICATION_WEBHOOK_URL=...` - Generic webhook URL to POST JSON payloads to +- `FLAGR_NOTIFICATION_WEBHOOK_HEADERS=...` - Optional comma-separated headers (e.g., `Authorization: Bearer token, X-Custom-Header: value`) + +### Email +- `FLAGR_NOTIFICATION_EMAIL_URL=...` - HTTP email API URL +- `FLAGR_NOTIFICATION_EMAIL_TO=...` - Recipient email address +- `FLAGR_NOTIFICATION_EMAIL_FROM=...` - Sender email address +- `FLAGR_NOTIFICATION_EMAIL_API_KEY=...` - Optional API key for email service + ## How It Works 1. After successful CRUD operations, `SaveFlagSnapshot()` is called From 0bd31c13cbfc0b8d72b75f5311baee0a83f8977f Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Fri, 20 Feb 2026 20:30:16 +0000 Subject: [PATCH 07/18] docs: document notification payload formats --- pkg/notification/README.md | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/pkg/notification/README.md b/pkg/notification/README.md index 8dc5f7b8a..48c5f8701 100644 --- a/pkg/notification/README.md +++ b/pkg/notification/README.md @@ -46,6 +46,29 @@ Set these environment variables to enable notifications: ## Notification Format +### Webhook (JSON) + +The generic webhook provider will emit an HTTP POST request with a JSON payload representing the `Notification` object. Depending on whether detailed diffs are enabled, the payload will look like this: + +```json +{ + "Operation": "update", + "EntityType": "flag", + "EntityID": 123, + "EntityKey": "my-feature-flag", + "Description": "Optional description of the update", + "PreValue": "{\"key\": \"value\"}", + "PostValue": "{\"key\": \"new_value\"}", + "Diff": "--- Previous\n+++ Current\n@@ -1 +1 @@\n-{\"key\": \"value\"}\n+{\"key\": \"new_value\"}", + "User": "admin@example.com", + "Details": {} +} +``` + +### Slack and Email (Text) + +Slack and Email providers format the `Notification` object into a human-readable format. For Slack, it uses Mrkdwn formatting. + ``` :rocket: *create flag* *Key:* my-feature-flag @@ -53,6 +76,27 @@ Set these environment variables to enable notifications: *User:* user@example.com ``` +If `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true` is set, updates will include the markdown diff alongside the values: + +``` +:pencil2: *update flag* +*Description:* Enabled the new login UI +*ID:* 123 +*Key:* my-feature-flag +*User:* admin@example.com + +*Diff:* +```diff +--- Previous ++++ Current +@@ -1,3 +1,3 @@ + { +- "enabled": false ++ "enabled": true + } +``` +``` + ## Testing ```bash From 2d90aa93e3e7adba2be67810069b1ed59ab06f22 Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Fri, 20 Feb 2026 20:32:32 +0000 Subject: [PATCH 08/18] docs: add notification docs to main site --- docs/_sidebar.md | 1 + docs/flagr_notifications.md | 58 +++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 docs/flagr_notifications.md diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 61133af91..0edab0d8f 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -7,6 +7,7 @@ - [Debug Console](flagr_debugging.md) - Server Configuration - [Env](flagr_env.md) + - [Notifications](flagr_notifications.md) - Client SDKs - [Ruby SDK 🔗](https://github.com/openflagr/rbflagr) - [Go SDK 🔗](https://github.com/openflagr/goflagr) diff --git a/docs/flagr_notifications.md b/docs/flagr_notifications.md new file mode 100644 index 000000000..ce34138a0 --- /dev/null +++ b/docs/flagr_notifications.md @@ -0,0 +1,58 @@ +# Notifications + +Flagr supports sending notifications for CRUD operations via Slack, Webhooks, or Email. + +## Configuration + +Set these environment variables to enable notifications: + +- `FLAGR_NOTIFICATION_ENABLED=true` - Enable notifications (default: false) +- `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true` - Include detailed value diffs in notifications (default: false) +- `FLAGR_NOTIFICATION_TIMEOUT=10s` - Timeout for HTTP requests when sending notifications +- `FLAGR_NOTIFICATION_PROVIDER=slack` - Notification provider (options: `slack`, `email`, `webhook`) + +### Webhook +- `FLAGR_NOTIFICATION_WEBHOOK_URL=...` - Generic webhook URL to POST JSON payloads to +- `FLAGR_NOTIFICATION_WEBHOOK_HEADERS=...` - Optional comma-separated headers (e.g., `Authorization: Bearer token, X-Custom-Header: value`) + +### Slack +- `FLAGR_NOTIFICATION_SLACK_WEBHOOK_URL=...` - Slack webhook URL +- `FLAGR_NOTIFICATION_SLACK_CHANNEL=#channel-name` - Optional Slack channel + +### Email +- `FLAGR_NOTIFICATION_EMAIL_URL=...` - HTTP email API URL +- `FLAGR_NOTIFICATION_EMAIL_TO=...` - Recipient email address +- `FLAGR_NOTIFICATION_EMAIL_FROM=...` - Sender email address +- `FLAGR_NOTIFICATION_EMAIL_API_KEY=...` - Optional API key for email service + +## Operations That Trigger Notifications + +- Create, Update, Delete, Restore flags +- Enable/Disable flags +- Create tags +- Create, Update, Delete segments +- Create, Update, Delete constraints +- Update distributions +- Create, Update, Delete variants + +## Notification Format + +Internal to Flagr, every CRUD notification is structured as a generic `Notification` object. + +```json +{ + "Operation": "update", + "EntityType": "flag", + "EntityID": 123, + "EntityKey": "my-feature-flag", + "Description": "Optional description of the update", + "PreValue": "{\"key\": \"value\"}", + "PostValue": "{\"key\": \"new_value\"}", + "Diff": "--- Previous\n+++ Current\n@@ -1 +1 @@\n-{\"key\": \"value\"}\n+{\"key\": \"new_value\"}", + "User": "admin@example.com" +} +``` + +Depending on your configured `FLAGR_NOTIFICATION_PROVIDER`, this generic object is formatted and delivered: +- **`webhook`**: The JSON representation above is serialized and HTTP `POST`ed directly to the target URL. +- **`slack`** & **`email`**: The object is parsed into a human-readable text document (with Mrkdwn formatting for Slack) and delivered to the channel or inbox. If `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true`, the `Diff` property is also included visually in the message. From 9a0c9c79fea564bd6008453a30ba3a62983dba94 Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Fri, 20 Feb 2026 20:39:32 +0000 Subject: [PATCH 09/18] docs: consolidate notification docs into main site --- docs/flagr_notifications.md | 23 ++++++++ pkg/notification/README.md | 114 ------------------------------------ 2 files changed, 23 insertions(+), 114 deletions(-) delete mode 100644 pkg/notification/README.md diff --git a/docs/flagr_notifications.md b/docs/flagr_notifications.md index ce34138a0..deb44e824 100644 --- a/docs/flagr_notifications.md +++ b/docs/flagr_notifications.md @@ -56,3 +56,26 @@ Internal to Flagr, every CRUD notification is structured as a generic `Notificat Depending on your configured `FLAGR_NOTIFICATION_PROVIDER`, this generic object is formatted and delivered: - **`webhook`**: The JSON representation above is serialized and HTTP `POST`ed directly to the target URL. - **`slack`** & **`email`**: The object is parsed into a human-readable text document (with Mrkdwn formatting for Slack) and delivered to the channel or inbox. If `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true`, the `Diff` property is also included visually in the message. + +## How It Works + +1. After successful CRUD operations, `SaveFlagSnapshot()` is called +2. The snapshot is saved to the database +3. A notification is sent asynchronously via `SendFlagNotification()` +4. Notifications are non-blocking - failures are logged but don't affect the operation + +## Testing + +```bash +# Run notification tests +go test ./pkg/notification/... -v + +# Run tests with mock notifier +go test ./pkg/notification/... -run TestNotification -v +``` + +## Adding New Providers + +1. Implement the `Notifier` interface in `pkg/notification/` +2. Update `GetNotifier()` in `pkg/notification/notifier.go` to support the new provider +3. Add configuration options in `pkg/config/env.go` diff --git a/pkg/notification/README.md b/pkg/notification/README.md deleted file mode 100644 index 48c5f8701..000000000 --- a/pkg/notification/README.md +++ /dev/null @@ -1,114 +0,0 @@ -# Notification Feature - -## Overview - -Flagr now supports sending notifications for CRUD operations via Slack or other notification providers. - -## Configuration - -Set these environment variables to enable notifications: - -- `FLAGR_NOTIFICATION_ENABLED=true` - Enable notifications (default: false) -- `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true` - Include detailed value diffs in notifications (default: false) -- `FLAGR_NOTIFICATION_TIMEOUT=10s` - Timeout for HTTP requests when sending notifications -- `FLAGR_NOTIFICATION_PROVIDER=slack` - Notification provider (options: `slack`, `email`, `webhook`) - -### Slack -- `FLAGR_NOTIFICATION_SLACK_WEBHOOK_URL=...` - Slack webhook URL -- `FLAGR_NOTIFICATION_SLACK_CHANNEL=#channel-name` - Optional Slack channel - -### Webhook -- `FLAGR_NOTIFICATION_WEBHOOK_URL=...` - Generic webhook URL to POST JSON payloads to -- `FLAGR_NOTIFICATION_WEBHOOK_HEADERS=...` - Optional comma-separated headers (e.g., `Authorization: Bearer token, X-Custom-Header: value`) - -### Email -- `FLAGR_NOTIFICATION_EMAIL_URL=...` - HTTP email API URL -- `FLAGR_NOTIFICATION_EMAIL_TO=...` - Recipient email address -- `FLAGR_NOTIFICATION_EMAIL_FROM=...` - Sender email address -- `FLAGR_NOTIFICATION_EMAIL_API_KEY=...` - Optional API key for email service - -## How It Works - -1. After successful CRUD operations, `SaveFlagSnapshot()` is called -2. The snapshot is saved to the database -3. A notification is sent asynchronously via `SendFlagNotification()` -4. Notifications are non-blocking - failures are logged but don't affect the operation - -## Operations That Trigger Notifications - -- Create, Update, Delete, Restore flags -- Enable/Disable flags -- Create tags -- Create, Update, Delete segments -- Create, Update, Delete constraints -- Update distributions -- Create, Update, Delete variants - -## Notification Format - -### Webhook (JSON) - -The generic webhook provider will emit an HTTP POST request with a JSON payload representing the `Notification` object. Depending on whether detailed diffs are enabled, the payload will look like this: - -```json -{ - "Operation": "update", - "EntityType": "flag", - "EntityID": 123, - "EntityKey": "my-feature-flag", - "Description": "Optional description of the update", - "PreValue": "{\"key\": \"value\"}", - "PostValue": "{\"key\": \"new_value\"}", - "Diff": "--- Previous\n+++ Current\n@@ -1 +1 @@\n-{\"key\": \"value\"}\n+{\"key\": \"new_value\"}", - "User": "admin@example.com", - "Details": {} -} -``` - -### Slack and Email (Text) - -Slack and Email providers format the `Notification` object into a human-readable format. For Slack, it uses Mrkdwn formatting. - -``` -:rocket: *create flag* -*Key:* my-feature-flag -*ID:* 123 -*User:* user@example.com -``` - -If `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true` is set, updates will include the markdown diff alongside the values: - -``` -:pencil2: *update flag* -*Description:* Enabled the new login UI -*ID:* 123 -*Key:* my-feature-flag -*User:* admin@example.com - -*Diff:* -```diff ---- Previous -+++ Current -@@ -1,3 +1,3 @@ - { -- "enabled": false -+ "enabled": true - } -``` -``` - -## Testing - -```bash -# Run notification tests -go test ./pkg/notification/... -v - -# Run tests with mock notifier -go test ./pkg/notification/... -run TestNotification -v -``` - -## Adding New Providers - -1. Implement `Notifier` interface in `pkg/notification/` -2. Update `GetNotifier()` to support the new provider -3. Add configuration options in `pkg/config/env.go` From fcb0814c62dd30cd12f9764f56ddf43d4474da80 Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Fri, 20 Feb 2026 20:41:07 +0000 Subject: [PATCH 10/18] docs: refine notification docs for public consumption --- docs/flagr_notifications.md | 98 +++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 54 deletions(-) diff --git a/docs/flagr_notifications.md b/docs/flagr_notifications.md index deb44e824..2a3433743 100644 --- a/docs/flagr_notifications.md +++ b/docs/flagr_notifications.md @@ -1,43 +1,58 @@ # Notifications -Flagr supports sending notifications for CRUD operations via Slack, Webhooks, or Email. +Flagr provides an integrated notification system that allows you to monitor changes and updates to your operational resources in real-time. You can configure Flagr to automatically send notifications regarding CRUD (Create, Read, Update, Delete) operations over several distinct channels: **Email**, **Slack**, or generic **Webhooks**. -## Configuration +## Tracked Operations -Set these environment variables to enable notifications: +Flagr monitors the following operations across your entities and immediately broadcasts them: +- **Flags**: Create, Update, Delete, Restore, Enable, Disable +- **Tags**: Create +- **Segments**: Create, Update, Delete +- **Constraints**: Create, Update, Delete +- **Distributions**: Update +- **Variants**: Create, Update, Delete -- `FLAGR_NOTIFICATION_ENABLED=true` - Enable notifications (default: false) -- `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true` - Include detailed value diffs in notifications (default: false) -- `FLAGR_NOTIFICATION_TIMEOUT=10s` - Timeout for HTTP requests when sending notifications -- `FLAGR_NOTIFICATION_PROVIDER=slack` - Notification provider (options: `slack`, `email`, `webhook`) +## Global Configuration -### Webhook -- `FLAGR_NOTIFICATION_WEBHOOK_URL=...` - Generic webhook URL to POST JSON payloads to -- `FLAGR_NOTIFICATION_WEBHOOK_HEADERS=...` - Optional comma-separated headers (e.g., `Authorization: Bearer token, X-Custom-Header: value`) +You must globally enable the notifications feature via environment variables and define the timeout for HTTP providers. -### Slack -- `FLAGR_NOTIFICATION_SLACK_WEBHOOK_URL=...` - Slack webhook URL -- `FLAGR_NOTIFICATION_SLACK_CHANNEL=#channel-name` - Optional Slack channel +- `FLAGR_NOTIFICATION_ENABLED=true` (Default: `false`) - Globally toggles the notification subsystem. +- `FLAGR_NOTIFICATION_PROVIDER=slack` (Options: `slack`, `email`, `webhook`) - Determines the active transport channel. +- `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true` (Default: `false`) - When enabled, Flagr will embed the precise visual JSON diff of the modified entity within the notification payload. +- `FLAGR_NOTIFICATION_TIMEOUT=10s` (Default: `10s`) - Configures the timeout window for dialing external notification webhooks and email APIs. -### Email -- `FLAGR_NOTIFICATION_EMAIL_URL=...` - HTTP email API URL -- `FLAGR_NOTIFICATION_EMAIL_TO=...` - Recipient email address -- `FLAGR_NOTIFICATION_EMAIL_FROM=...` - Sender email address -- `FLAGR_NOTIFICATION_EMAIL_API_KEY=...` - Optional API key for email service +## Provider Configuration -## Operations That Trigger Notifications +Depending on the `FLAGR_NOTIFICATION_PROVIDER` selected above, configure the target transport mechanism: -- Create, Update, Delete, Restore flags -- Enable/Disable flags -- Create tags -- Create, Update, Delete segments -- Create, Update, Delete constraints -- Update distributions -- Create, Update, Delete variants +### 1. Slack -## Notification Format +When using Slack, the notification is delivered as a formatted `Mrkdwn` message directly to your channel block. -Internal to Flagr, every CRUD notification is structured as a generic `Notification` object. +- `FLAGR_NOTIFICATION_SLACK_WEBHOOK_URL=...` - The Incoming Webhook URL provided by your Slack Workspace. +- `FLAGR_NOTIFICATION_SLACK_CHANNEL=#engineering` - (Optional) Overrides the destination Slack channel. + +### 2. Email + +The Email provider sends beautifully formatted HTML summaries of modifications to a target inbox leveraging the SendGrid REST APIs. + +- `FLAGR_NOTIFICATION_EMAIL_URL=https://api.sendgrid.com/v3/mail/send` - HTTP email delivery API endpoint. +- `FLAGR_NOTIFICATION_EMAIL_TO=alerts@your-org.com` - The recipient's email address. +- `FLAGR_NOTIFICATION_EMAIL_FROM=flagr-ops@your-org.com` - The designated sender address. +- `FLAGR_NOTIFICATION_EMAIL_API_KEY=...` - The authorization key for evaluating HTTP API calls. + +### 3. Generic Webhook + +If you wish to consume these events programmatically, the generic `webhook` provider sends HTTP `POST` requests directly to an arbitrary URL containing a serialized JSON `Notification` object representing the change. + +- `FLAGR_NOTIFICATION_WEBHOOK_URL=https://api.your-org.com/webhooks/flagr` - HTTP destination endpoint for generic webhook POST requests. +- `FLAGR_NOTIFICATION_WEBHOOK_HEADERS=Authorization: Bearer secret-token, X-Custom-Header: value` - (Optional) Custom comma-separated HTTP headers, often utilized for securing your webhook receiver with an API token. + +--- + +## The JSON Webhook Payload Format + +If `FLAGR_NOTIFICATION_PROVIDER` is set to `webhook`, the target endpoint will receive a structured payload similar to the following: ```json { @@ -53,29 +68,4 @@ Internal to Flagr, every CRUD notification is structured as a generic `Notificat } ``` -Depending on your configured `FLAGR_NOTIFICATION_PROVIDER`, this generic object is formatted and delivered: -- **`webhook`**: The JSON representation above is serialized and HTTP `POST`ed directly to the target URL. -- **`slack`** & **`email`**: The object is parsed into a human-readable text document (with Mrkdwn formatting for Slack) and delivered to the channel or inbox. If `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true`, the `Diff` property is also included visually in the message. - -## How It Works - -1. After successful CRUD operations, `SaveFlagSnapshot()` is called -2. The snapshot is saved to the database -3. A notification is sent asynchronously via `SendFlagNotification()` -4. Notifications are non-blocking - failures are logged but don't affect the operation - -## Testing - -```bash -# Run notification tests -go test ./pkg/notification/... -v - -# Run tests with mock notifier -go test ./pkg/notification/... -run TestNotification -v -``` - -## Adding New Providers - -1. Implement the `Notifier` interface in `pkg/notification/` -2. Update `GetNotifier()` in `pkg/notification/notifier.go` to support the new provider -3. Add configuration options in `pkg/config/env.go` +> **Note**: The `Diff` key is visually rendered in Markdown format for rendering natively across internal dashboards or chat systems, but is only populated if `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true` is set on the server. From 7e629b193e78f51b91d943b051efcd75acd94a2a Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Fri, 20 Feb 2026 22:15:24 +0000 Subject: [PATCH 11/18] feat(notifications): add retry logic, validation, and comprehensive test coverage - Renamed hook.go to dispatch.go to better reflect its purpose - Add exponential backoff retry logic for HTTP providers (email, webhook) - Add configurable retry parameters (max retries, base delay, max delay) - Add startup validation for notification configuration - Add concurrency limiting with semaphore (default 100) - Add detailed metrics for notification success/failure - Update SaveFlagSnapshot to handle soft-deleted flags correctly - Add missing notifications for DeleteFlag, RestoreFlag, and SetFlagEnabledState - Fix OperationUpdate constant usage in variant deletion - Add comprehensive tests for new notification scenarios - Update documentation with new features and configuration options This provides a more robust and observable notification system with better error handling and test coverage. --- docs/flagr_notifications.md | 37 +++++++--- pkg/config/env.go | 6 ++ pkg/entity/flag_snapshot.go | 9 ++- pkg/handler/crud.go | 9 ++- pkg/handler/crud_notification_test.go | 70 +++++++++++++++++++ pkg/handler/handler.go | 3 + pkg/notification/{hook.go => dispatch.go} | 31 +++++++- .../{hook_test.go => dispatch_test.go} | 0 pkg/notification/email.go | 14 +++- pkg/notification/notifier.go | 9 +++ pkg/notification/retry.go | 61 ++++++++++++++++ pkg/notification/slack.go | 4 ++ pkg/notification/validate.go | 37 ++++++++++ pkg/notification/webhook.go | 10 ++- 14 files changed, 283 insertions(+), 17 deletions(-) rename pkg/notification/{hook.go => dispatch.go} (68%) rename pkg/notification/{hook_test.go => dispatch_test.go} (100%) create mode 100644 pkg/notification/retry.go create mode 100644 pkg/notification/validate.go diff --git a/docs/flagr_notifications.md b/docs/flagr_notifications.md index 2a3433743..7871cad4f 100644 --- a/docs/flagr_notifications.md +++ b/docs/flagr_notifications.md @@ -4,23 +4,44 @@ Flagr provides an integrated notification system that allows you to monitor chan ## Tracked Operations -Flagr monitors the following operations across your entities and immediately broadcasts them: -- **Flags**: Create, Update, Delete, Restore, Enable, Disable -- **Tags**: Create -- **Segments**: Create, Update, Delete -- **Constraints**: Create, Update, Delete -- **Distributions**: Update -- **Variants**: Create, Update, Delete +Flagr monitors changes to **flags** and their related configuration. All notifications have `EntityType: "flag"` in the payload. + +The following operations trigger notifications: + +| Operation | Description | +|-----------|-------------| +| `create` | A new flag is created | +| `update` | Any change to a flag's metadata, enabled state, or any of its associated entities (segments, variants, constraints, distributions, tags) | +| `delete` | A flag is soft-deleted | +| `restore` | A soft-deleted flag is restored | + +**Note**: Operations such as adding/removing tags, updating segment rollout percentages, modifying constraints, or changing variant attachments all trigger an `update` notification for the parent flag. Enabling or disabling a flag is also considered an update. ## Global Configuration -You must globally enable the notifications feature via environment variables and define the timeout for HTTP providers. +You must globally enable the notifications feature via environment variables. - `FLAGR_NOTIFICATION_ENABLED=true` (Default: `false`) - Globally toggles the notification subsystem. - `FLAGR_NOTIFICATION_PROVIDER=slack` (Options: `slack`, `email`, `webhook`) - Determines the active transport channel. - `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true` (Default: `false`) - When enabled, Flagr will embed the precise visual JSON diff of the modified entity within the notification payload. - `FLAGR_NOTIFICATION_TIMEOUT=10s` (Default: `10s`) - Configures the timeout window for dialing external notification webhooks and email APIs. +### Retry Configuration (HTTP providers only) + +- `FLAGR_NOTIFICATION_MAX_RETRIES=3` (Default: `3`) - Maximum number of retry attempts for transient HTTP failures (5xx errors). Set to `0` to disable retries. +- `FLAGR_NOTIFICATION_RETRY_BASE=1s` (Default: `1s`) - Base delay for exponential backoff between retries. +- `FLAGR_NOTIFICATION_RETRY_MAX=10s` (Default: `10s`) - Maximum delay between retries. + +### Concurrency & Observability + +- Notifications are sent asynchronously with a default concurrency limit of 100 to prevent resource exhaustion under load. +- Metric `notification.sent` is emitted when statsd is enabled, tagged with `provider`, `operation`, `entity_type`, and `status` (`success`/`failure`). + +### Important Notes + +- **Asynchronous delivery**: Notifications are sent in background goroutines. Failures are logged but **do not affect the API response**. +- **Startup validation**: When `FLAGR_NOTIFICATION_ENABLED=true`, Flagr validates the configuration based on the selected provider and logs warnings if required settings are missing. Notifications will be silently dropped until properly configured. + ## Provider Configuration Depending on the `FLAGR_NOTIFICATION_PROVIDER` selected above, configure the target transport mechanism: diff --git a/pkg/config/env.go b/pkg/config/env.go index d288842b8..11ba915f3 100644 --- a/pkg/config/env.go +++ b/pkg/config/env.go @@ -254,6 +254,12 @@ var Config = struct { NotificationEmailFrom string `env:"FLAGR_NOTIFICATION_EMAIL_FROM" envDefault:""` // NotificationTimeout - timeout for sending notifications NotificationTimeout time.Duration `env:"FLAGR_NOTIFICATION_TIMEOUT" envDefault:"10s"` + // NotificationMaxRetries - maximum number of retry attempts for HTTP notifications + NotificationMaxRetries int `env:"FLAGR_NOTIFICATION_MAX_RETRIES" envDefault:"3"` + // NotificationRetryBase - base delay for exponential backoff (used with jitter) + NotificationRetryBase time.Duration `env:"FLAGR_NOTIFICATION_RETRY_BASE" envDefault:"1s"` + // NotificationRetryMax - maximum delay between retries + NotificationRetryMax time.Duration `env:"FLAGR_NOTIFICATION_RETRY_MAX" envDefault:"10s"` // WebPrefix - base path for web and API // e.g. FLAGR_WEB_PREFIX=/foo diff --git a/pkg/entity/flag_snapshot.go b/pkg/entity/flag_snapshot.go index febdaeb6c..0046b04ab 100644 --- a/pkg/entity/flag_snapshot.go +++ b/pkg/entity/flag_snapshot.go @@ -25,7 +25,8 @@ type FlagSnapshot struct { func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string, operation notification.Operation) { tx := db.Begin() f := &Flag{} - if err := tx.First(f, flagID).Error; err != nil { + // Use Unscoped to include soft-deleted flags (needed for delete notifications) + if err := tx.Unscoped().First(f, flagID).Error; err != nil { logrus.WithFields(logrus.Fields{ "err": err, "flagID": flagID, @@ -56,7 +57,8 @@ func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string, operation noti f.UpdatedBy = updatedBy f.SnapshotID = fs.ID - if err := tx.Save(f).Error; err != nil { + // Use Unscoped to ensure we can update soft-deleted flags (e.g., after delete) + if err := tx.Unscoped().Save(f).Error; err != nil { logrus.WithFields(logrus.Fields{ "err": err, "flagID": f.Model.ID, @@ -67,7 +69,8 @@ func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string, operation noti } preFS := &FlagSnapshot{} - tx.Where("flag_id = ?", flagID).Order("id desc").Offset(1).First(preFS) + // Find the most recent snapshot before the current one (use Unscoped to include any soft-deleted) + tx.Unscoped().Where("flag_id = ? AND id < ?", flagID, fs.ID).Order("id desc").First(preFS) if err := tx.Commit().Error; err != nil { tx.Rollback() diff --git a/pkg/handler/crud.go b/pkg/handler/crud.go index 97b128c54..d7c528f9b 100644 --- a/pkg/handler/crud.go +++ b/pkg/handler/crud.go @@ -322,9 +322,16 @@ func (c *crud) RestoreFlag(params flag.RestoreFlagParams) middleware.Responder { } func (c *crud) DeleteFlag(params flag.DeleteFlagParams) middleware.Responder { + f := &entity.Flag{} + if err := getDB().First(f, params.FlagID).Error; err != nil { + return flag.NewDeleteFlagDefault(404).WithPayload(ErrorMessage("%s", err)) + } + if err := getDB().Delete(&entity.Flag{}, params.FlagID).Error; err != nil { return flag.NewDeleteFlagDefault(500).WithPayload(ErrorMessage("%s", err)) } + + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationDelete) return flag.NewDeleteFlagOK() } @@ -709,6 +716,6 @@ func (c *crud) DeleteVariant(params variant.DeleteVariantParams) middleware.Resp return variant.NewDeleteVariantDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), "update") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return variant.NewDeleteVariantOK() } diff --git a/pkg/handler/crud_notification_test.go b/pkg/handler/crud_notification_test.go index 8dd697f03..0967d33c4 100644 --- a/pkg/handler/crud_notification_test.go +++ b/pkg/handler/crud_notification_test.go @@ -120,4 +120,74 @@ func TestHandlerNotifications(t *testing.T) { assert.Contains(t, sent[1].Diff, "- \"Description\": \"first update\"") assert.Contains(t, sent[1].Diff, "+ \"Description\": \"second update\"") }) + + t.Run("DeleteFlag sends notification", func(t *testing.T) { + f := entity.GenFixtureFlag() + db.Create(&f) + mockNotifier.ClearSent() + + params := flag.DeleteFlagParams{ + FlagID: int64(f.ID), + HTTPRequest: &http.Request{}, + } + c.DeleteFlag(params) + + assert.Eventually(t, func() bool { + return len(mockNotifier.GetSentNotifications()) > 0 + }, 1*time.Second, 10*time.Millisecond) + + sent := mockNotifier.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, notification.OperationDelete, sent[0].Operation) + assert.Equal(t, notification.EntityTypeFlag, sent[0].EntityType) + assert.Equal(t, f.Key, sent[0].EntityKey) + }) + + t.Run("RestoreFlag sends notification", func(t *testing.T) { + f := entity.GenFixtureFlag() + db.Create(&f) + // Soft delete first + db.Delete(&f) + mockNotifier.ClearSent() + + params := flag.RestoreFlagParams{ + FlagID: int64(f.ID), + HTTPRequest: &http.Request{}, + } + c.RestoreFlag(params) + + assert.Eventually(t, func() bool { + return len(mockNotifier.GetSentNotifications()) > 0 + }, 1*time.Second, 10*time.Millisecond) + + sent := mockNotifier.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, notification.OperationRestore, sent[0].Operation) + assert.Equal(t, f.Key, sent[0].EntityKey) + }) + + t.Run("SetFlagEnabledState sends notification", func(t *testing.T) { + f := entity.GenFixtureFlag() + db.Create(&f) + mockNotifier.ClearSent() + + params := flag.SetFlagEnabledParams{ + FlagID: int64(f.ID), + Body: &models.SetFlagEnabledRequest{ + Enabled: new(false), + }, + HTTPRequest: &http.Request{}, + } + c.SetFlagEnabledState(params) + + assert.Eventually(t, func() bool { + return len(mockNotifier.GetSentNotifications()) > 0 + }, 1*time.Second, 10*time.Millisecond) + + sent := mockNotifier.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, notification.OperationUpdate, sent[0].Operation) + assert.Equal(t, f.Key, sent[0].EntityKey) + assert.Equal(t, f.ID, sent[0].EntityID) // Verify entity ID is set correctly + }) } diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go index 79558ba2b..b35a09a03 100644 --- a/pkg/handler/handler.go +++ b/pkg/handler/handler.go @@ -4,6 +4,7 @@ import ( "github.com/go-openapi/runtime/middleware" "github.com/openflagr/flagr/pkg/config" "github.com/openflagr/flagr/pkg/entity" + "github.com/openflagr/flagr/pkg/notification" "github.com/openflagr/flagr/swagger_gen/models" "github.com/openflagr/flagr/swagger_gen/restapi/operations" "github.com/openflagr/flagr/swagger_gen/restapi/operations/constraint" @@ -21,6 +22,8 @@ var getDB = entity.GetDB // Setup initialize all the handler functions func Setup(api *operations.FlagrAPI) { + notification.ValidateConfig() + if config.Config.EvalOnlyMode { setupHealth(api) setupEvaluation(api) diff --git a/pkg/notification/hook.go b/pkg/notification/dispatch.go similarity index 68% rename from pkg/notification/hook.go rename to pkg/notification/dispatch.go index 2789f3853..43bff2b5a 100644 --- a/pkg/notification/hook.go +++ b/pkg/notification/dispatch.go @@ -4,15 +4,41 @@ import ( "bytes" "context" "encoding/json" + "fmt" "github.com/openflagr/flagr/pkg/config" "github.com/pmezard/go-difflib/difflib" "github.com/sirupsen/logrus" ) +var ( + // Semaphore to limit concurrent notification sends. Default 100. + notificationSemaphore = make(chan struct{}, 100) +) + +func recordNotificationMetrics(provider string, operation Operation, entityType EntityType, success bool) { + if config.Global.StatsdClient == nil { + return + } + status := "failure" + if success { + status = "success" + } + tags := []string{ + fmt.Sprintf("provider:%s", provider), + fmt.Sprintf("operation:%s", operation), + fmt.Sprintf("entity_type:%s", entityType), + fmt.Sprintf("status:%s", status), + } + config.Global.StatsdClient.Incr("notification.sent", tags, 1) +} + func SendNotification(operation Operation, entityType EntityType, entityID uint, entityKey string, description string, preValue string, postValue string, diff string, user string) { go func() { + // Acquire semaphore slot + notificationSemaphore <- struct{}{} defer func() { + <-notificationSemaphore if r := recover(); r != nil { logrus.WithField("panic", r).Error("panic in SendNotification") } @@ -35,7 +61,8 @@ func SendNotification(operation Operation, entityType EntityType, entityID uint, Details: make(map[string]any), } - if err := notifier.Send(ctx, notif); err != nil { + err := notifier.Send(ctx, notif) + if err != nil { logrus.WithFields(logrus.Fields{ "operation": operation, "entityType": entityType, @@ -43,6 +70,8 @@ func SendNotification(operation Operation, entityType EntityType, entityID uint, "error": err, }).Warn("failed to send notification") } + // Record metrics regardless of success/failure for observability + recordNotificationMetrics(notifier.Name(), operation, entityType, err == nil) }() } diff --git a/pkg/notification/hook_test.go b/pkg/notification/dispatch_test.go similarity index 100% rename from pkg/notification/hook_test.go rename to pkg/notification/dispatch_test.go diff --git a/pkg/notification/email.go b/pkg/notification/email.go index a97cbfd1d..1de8e91b1 100644 --- a/pkg/notification/email.go +++ b/pkg/notification/email.go @@ -54,15 +54,19 @@ func (e *emailNotifier) Send(ctx context.Context, n Notification) error { req.Header.Set("Authorization", "Bearer "+config.Config.NotificationEmailAPIKey) } - resp, err := e.httpClient.Do(req) + // Execute request with retry + resp, err := doRequestWithRetry(ctx, e.httpClient, req, config.Config.NotificationMaxRetries, config.Config.NotificationRetryBase, config.Config.NotificationRetryMax) if err != nil { + if resp != nil { + resp.Body.Close() + } return fmt.Errorf("failed to send email: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("email service returned error: %d - %s", resp.StatusCode, string(body)) + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("email service returned error: %d - %s", resp.StatusCode, string(b)) } logrus.WithFields(logrus.Fields{ @@ -74,6 +78,10 @@ func (e *emailNotifier) Send(ctx context.Context, n Notification) error { return nil } +func (e *emailNotifier) Name() string { + return "email" +} + func formatEmailSubject(n Notification) string { return fmt.Sprintf("[Flagr] %s %s", n.Operation, n.EntityType) } diff --git a/pkg/notification/notifier.go b/pkg/notification/notifier.go index 898ea94b4..4151a3aa4 100644 --- a/pkg/notification/notifier.go +++ b/pkg/notification/notifier.go @@ -10,6 +10,7 @@ import ( type Notifier interface { Send(ctx context.Context, n Notification) error + Name() string } type Operation string @@ -91,6 +92,10 @@ func (n *nullNotifier) Send(ctx context.Context, notification Notification) erro return nil } +func (n *nullNotifier) Name() string { + return "null" +} + type MockNotifier struct { sent []Notification mu sync.Mutex @@ -110,6 +115,10 @@ func (m *MockNotifier) Send(ctx context.Context, n Notification) error { return m.sendError } +func (m *MockNotifier) Name() string { + return "mock" +} + func (m *MockNotifier) SetSendError(err error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/pkg/notification/retry.go b/pkg/notification/retry.go new file mode 100644 index 000000000..fa17998e9 --- /dev/null +++ b/pkg/notification/retry.go @@ -0,0 +1,61 @@ +package notification + +import ( + "context" + "fmt" + "net/http" + "time" +) + +func minDuration(a, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} + +// doRequestWithRetry performs an HTTP request with exponential backoff retries. +// On success (status < 500), it returns (resp, nil). +// On failure after retries, it returns the last response (if any) and an error. +func doRequestWithRetry(ctx context.Context, client *http.Client, req *http.Request, maxRetries int, baseDelay, maxDelay time.Duration) (*http.Response, error) { + var lastResp *http.Response + var lastErr error + delay := baseDelay + + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + select { + case <-time.After(delay): + case <-ctx.Done(): + return lastResp, fmt.Errorf("retry canceled: %w", ctx.Err()) + } + } + + resp, err := client.Do(req) + if err != nil { + lastErr = fmt.Errorf("HTTP request failed: %w", err) + if attempt < maxRetries { + delay = minDuration(2*delay, maxDelay) + continue + } + return nil, lastErr + } + // Don't close body here; caller will handle it if resp is returned + + if resp.StatusCode < 500 { + return resp, nil // Success or client error (4xx) is considered final; no retry on 4xx + } + + // 5xx - retryable + lastResp = resp + lastErr = fmt.Errorf("HTTP %d error", resp.StatusCode) + if attempt < maxRetries { + delay = minDuration(2*delay, maxDelay) + continue + } + // Final attempt failed with 5xx + return resp, lastErr + } + + return lastResp, lastErr +} diff --git a/pkg/notification/slack.go b/pkg/notification/slack.go index daa651e2c..a16ac78e0 100644 --- a/pkg/notification/slack.go +++ b/pkg/notification/slack.go @@ -38,6 +38,10 @@ func (s *slackNotifier) Send(ctx context.Context, n Notification) error { return s.client.Send(ctx, subject, message) } +func (s *slackNotifier) Name() string { + return "slack" +} + func formatNotification(n Notification) string { var emoji string switch n.Operation { diff --git a/pkg/notification/validate.go b/pkg/notification/validate.go new file mode 100644 index 000000000..81e8d7f66 --- /dev/null +++ b/pkg/notification/validate.go @@ -0,0 +1,37 @@ +package notification + +import ( + "github.com/openflagr/flagr/pkg/config" + "github.com/sirupsen/logrus" +) + +// ValidateConfig checks notification configuration and logs warnings if misconfigured. +// This should be called during application startup when notifications are enabled. +func ValidateConfig() { + if !config.Config.NotificationEnabled { + return + } + + provider := config.Config.NotificationProvider + var configured bool + + switch provider { + case "slack": + configured = config.Config.NotificationSlackWebhookURL != "" + if !configured { + logrus.Warn("Notifications are enabled with provider 'slack', but FLAGR_NOTIFICATION_SLACK_WEBHOOK_URL is not set. Notifications will be silently dropped.") + } + case "email": + configured = config.Config.NotificationEmailURL != "" && config.Config.NotificationEmailTo != "" && config.Config.NotificationEmailFrom != "" + if !configured { + logrus.Warn("Notifications are enabled with provider 'email', but FLAGR_NOTIFICATION_EMAIL_URL, FLAGR_NOTIFICATION_EMAIL_TO, and FLAGR_NOTIFICATION_EMAIL_FROM must all be set. Notifications will be silently dropped.") + } + case "webhook": + configured = config.Config.NotificationWebhookURL != "" + if !configured { + logrus.Warn("Notifications are enabled with provider 'webhook', but FLAGR_NOTIFICATION_WEBHOOK_URL is not set. Notifications will be silently dropped.") + } + default: + logrus.Warnf("Unknown notification provider: %s. Notifications will be silently dropped.", provider) + } +} diff --git a/pkg/notification/webhook.go b/pkg/notification/webhook.go index c41c8e6ed..abaf18e5f 100644 --- a/pkg/notification/webhook.go +++ b/pkg/notification/webhook.go @@ -45,8 +45,12 @@ func (w *webhookNotifier) Send(ctx context.Context, n Notification) error { req.Header.Set(k, v) } - resp, err := w.httpClient.Do(req) + // Execute request with retry + resp, err := doRequestWithRetry(ctx, w.httpClient, req, config.Config.NotificationMaxRetries, config.Config.NotificationRetryBase, config.Config.NotificationRetryMax) if err != nil { + if resp != nil { + resp.Body.Close() + } return fmt.Errorf("failed to send webhook: %w", err) } defer resp.Body.Close() @@ -64,3 +68,7 @@ func (w *webhookNotifier) Send(ctx context.Context, n Notification) error { return nil } + +func (w *webhookNotifier) Name() string { + return "webhook" +} From 4f8e1a122eedadc5fdc4feb54587fba056c0bfba Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Fri, 20 Feb 2026 22:24:23 +0000 Subject: [PATCH 12/18] refactor(notification): rename SendNotification to sendNotification (private API) Simplify public API by making SendNotification private and keeping only SendFlagNotification as the public interface. This clarifies that the notification system currently only supports flag entities and prevents misuse. This is a non-breaking refactoring - all internal callers already use SendFlagNotification. --- pkg/notification/dispatch.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/notification/dispatch.go b/pkg/notification/dispatch.go index 43bff2b5a..a6010cabb 100644 --- a/pkg/notification/dispatch.go +++ b/pkg/notification/dispatch.go @@ -33,7 +33,7 @@ func recordNotificationMetrics(provider string, operation Operation, entityType config.Global.StatsdClient.Incr("notification.sent", tags, 1) } -func SendNotification(operation Operation, entityType EntityType, entityID uint, entityKey string, description string, preValue string, postValue string, diff string, user string) { +func sendNotification(operation Operation, entityType EntityType, entityID uint, entityKey string, description string, preValue string, postValue string, diff string, user string) { go func() { // Acquire semaphore slot notificationSemaphore <- struct{}{} @@ -76,7 +76,7 @@ func SendNotification(operation Operation, entityType EntityType, entityID uint, } func SendFlagNotification(operation Operation, flagID uint, flagKey string, description string, preValue string, postValue string, diff string, user string) { - SendNotification(operation, EntityTypeFlag, flagID, flagKey, description, preValue, postValue, diff, user) + sendNotification(operation, EntityTypeFlag, flagID, flagKey, description, preValue, postValue, diff, user) } func CalculateDiff(pre, post string) string { From 1ecfd375f2e24c7cc5b2b7c8ef1d408d33d3035e Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Fri, 27 Feb 2026 12:25:14 +0000 Subject: [PATCH 13/18] fix: capture notifiers before spawning goroutine to avoid test pollution --- pkg/config/env.go | 45 +++--- pkg/entity/flag_snapshot.go | 9 +- pkg/handler/crud_notification_test.go | 5 +- pkg/notification/dispatch.go | 43 ++++-- pkg/notification/dispatch_test.go | 144 +++++++++++++++++++ pkg/notification/email.go | 2 + pkg/notification/email_test.go | 16 +++ pkg/notification/notifier.go | 54 ++++--- pkg/notification/notifier_test.go | 52 ++++++- pkg/notification/retry.go | 10 +- pkg/notification/retry_test.go | 195 ++++++++++++++++++++++++++ pkg/notification/validate.go | 35 ++--- 12 files changed, 526 insertions(+), 84 deletions(-) create mode 100644 pkg/notification/retry_test.go diff --git a/pkg/config/env.go b/pkg/config/env.go index 11ba915f3..5cb233b80 100644 --- a/pkg/config/env.go +++ b/pkg/config/env.go @@ -230,36 +230,45 @@ var Config = struct { BasicAuthPrefixWhitelistPaths []string `env:"FLAGR_BASIC_AUTH_WHITELIST_PATHS" envDefault:"/api/v1/health,/api/v1/flags,/api/v1/evaluation" envSeparator:","` BasicAuthExactWhitelistPaths []string `env:"FLAGR_BASIC_AUTH_EXACT_WHITELIST_PATHS" envDefault:"" envSeparator:","` - // NotificationEnabled - enable notifications for CRUD operations - NotificationEnabled bool `env:"FLAGR_NOTIFICATION_ENABLED" envDefault:"false"` + // ===== Notification - Global Settings ===== // NotificationDetailedDiffEnabled - notify detailed diff of pre and post values NotificationDetailedDiffEnabled bool `env:"FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED" envDefault:"false"` - // NotificationProvider - notification provider to use (slack, email, discord, etc.) - NotificationProvider string `env:"FLAGR_NOTIFICATION_PROVIDER" envDefault:"slack"` + // NotificationTimeout - timeout for sending notifications + NotificationTimeout time.Duration `env:"FLAGR_NOTIFICATION_TIMEOUT" envDefault:"10s"` + // NotificationMaxRetries - maximum number of retry attempts for HTTP notifications + NotificationMaxRetries int `env:"FLAGR_NOTIFICATION_MAX_RETRIES" envDefault:"3"` + // NotificationRetryBase - base delay for exponential backoff (used with jitter) + NotificationRetryBase time.Duration `env:"FLAGR_NOTIFICATION_RETRY_BASE" envDefault:"1s"` + // NotificationRetryMax - maximum delay between retries + NotificationRetryMax time.Duration `env:"FLAGR_NOTIFICATION_RETRY_MAX" envDefault:"10s"` + + // ===== Notification - Slack Provider ===== + // NotificationSlackEnabled - enable Slack notifications + NotificationSlackEnabled bool `env:"FLAGR_NOTIFICATION_SLACK_ENABLED" envDefault:"false"` // NotificationSlackWebhookURL - Slack webhook URL for notifications NotificationSlackWebhookURL string `env:"FLAGR_NOTIFICATION_SLACK_WEBHOOK_URL" envDefault:""` // NotificationSlackChannel - Slack channel to send notifications to NotificationSlackChannel string `env:"FLAGR_NOTIFICATION_SLACK_CHANNEL" envDefault:""` - // NotificationWebhookURL - Webhook URL for generic notifications - NotificationWebhookURL string `env:"FLAGR_NOTIFICATION_WEBHOOK_URL" envDefault:""` - // NotificationWebhookHeaders - Webhook Headers for generic notifications, e.g. "Authorization: Bearer token,X-Custom-Header: value" - NotificationWebhookHeaders string `env:"FLAGR_NOTIFICATION_WEBHOOK_HEADERS" envDefault:""` - // NotificationEmailTo - recipient email address - NotificationEmailTo string `env:"FLAGR_NOTIFICATION_EMAIL_TO" envDefault:""` + + // ===== Notification - Email Provider ===== + // NotificationEmailEnabled - enable email notifications + NotificationEmailEnabled bool `env:"FLAGR_NOTIFICATION_EMAIL_ENABLED" envDefault:"false"` // NotificationEmailURL - HTTP email API URL (e.g., https://api.sendgrid.com/v3/mail/send) NotificationEmailURL string `env:"FLAGR_NOTIFICATION_EMAIL_URL" envDefault:""` // NotificationEmailAPIKey - API key for email service NotificationEmailAPIKey string `env:"FLAGR_NOTIFICATION_EMAIL_API_KEY" envDefault:""` // NotificationEmailFrom - sender email address NotificationEmailFrom string `env:"FLAGR_NOTIFICATION_EMAIL_FROM" envDefault:""` - // NotificationTimeout - timeout for sending notifications - NotificationTimeout time.Duration `env:"FLAGR_NOTIFICATION_TIMEOUT" envDefault:"10s"` - // NotificationMaxRetries - maximum number of retry attempts for HTTP notifications - NotificationMaxRetries int `env:"FLAGR_NOTIFICATION_MAX_RETRIES" envDefault:"3"` - // NotificationRetryBase - base delay for exponential backoff (used with jitter) - NotificationRetryBase time.Duration `env:"FLAGR_NOTIFICATION_RETRY_BASE" envDefault:"1s"` - // NotificationRetryMax - maximum delay between retries - NotificationRetryMax time.Duration `env:"FLAGR_NOTIFICATION_RETRY_MAX" envDefault:"10s"` + // NotificationEmailTo - recipient email address + NotificationEmailTo string `env:"FLAGR_NOTIFICATION_EMAIL_TO" envDefault:""` + + // ===== Notification - Webhook Provider ===== + // NotificationWebhookEnabled - enable generic webhook notifications + NotificationWebhookEnabled bool `env:"FLAGR_NOTIFICATION_WEBHOOK_ENABLED" envDefault:"false"` + // NotificationWebhookURL - Webhook URL for generic notifications + NotificationWebhookURL string `env:"FLAGR_NOTIFICATION_WEBHOOK_URL" envDefault:""` + // NotificationWebhookHeaders - Webhook Headers for generic notifications, e.g. "Authorization: Bearer token,X-Custom-Header: value" + NotificationWebhookHeaders string `env:"FLAGR_NOTIFICATION_WEBHOOK_HEADERS" envDefault:""` // WebPrefix - base path for web and API // e.g. FLAGR_WEB_PREFIX=/foo diff --git a/pkg/entity/flag_snapshot.go b/pkg/entity/flag_snapshot.go index 0046b04ab..0059f9f36 100644 --- a/pkg/entity/flag_snapshot.go +++ b/pkg/entity/flag_snapshot.go @@ -25,7 +25,11 @@ type FlagSnapshot struct { func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string, operation notification.Operation) { tx := db.Begin() f := &Flag{} - // Use Unscoped to include soft-deleted flags (needed for delete notifications) + // Use Unscoped to include soft-deleted flags. This is necessary for: + // 1. Delete operations: we need to snapshot the flag after it's been soft-deleted + // 2. Restore operations: we need to update the flag that was previously soft-deleted + // This is safe because flagID comes from validated request params and the operation + // is explicitly tracked (create/update/delete/restore). if err := tx.Unscoped().First(f, flagID).Error; err != nil { logrus.WithFields(logrus.Fields{ "err": err, @@ -57,7 +61,8 @@ func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string, operation noti f.UpdatedBy = updatedBy f.SnapshotID = fs.ID - // Use Unscoped to ensure we can update soft-deleted flags (e.g., after delete) + // Use Unscoped to update soft-deleted flags (e.g., after delete operation). + // Without Unscoped(), GORM would add "deleted_at IS NULL" condition and fail. if err := tx.Unscoped().Save(f).Error; err != nil { logrus.WithFields(logrus.Fields{ "err": err, diff --git a/pkg/handler/crud_notification_test.go b/pkg/handler/crud_notification_test.go index 0967d33c4..0fe152d3e 100644 --- a/pkg/handler/crud_notification_test.go +++ b/pkg/handler/crud_notification_test.go @@ -19,8 +19,9 @@ func TestHandlerNotifications(t *testing.T) { defer gostub.StubFunc(&getDB, db).Reset() mockNotifier := notification.NewMockNotifier() - notification.SetNotifier(mockNotifier) - defer notification.SetNotifier(nil) + // Use gostub to set notifiers and reset via defer + stubs := gostub.Stub(¬ification.Notifiers, []notification.Notifier{mockNotifier}) + defer stubs.Reset() c := NewCRUD() diff --git a/pkg/notification/dispatch.go b/pkg/notification/dispatch.go index a6010cabb..4dcf1108c 100644 --- a/pkg/notification/dispatch.go +++ b/pkg/notification/dispatch.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "sync" "github.com/openflagr/flagr/pkg/config" "github.com/pmezard/go-difflib/difflib" @@ -34,19 +35,25 @@ func recordNotificationMetrics(provider string, operation Operation, entityType } func sendNotification(operation Operation, entityType EntityType, entityID uint, entityKey string, description string, preValue string, postValue string, diff string, user string) { + // Capture notifiers BEFORE spawning goroutine to avoid test pollution + // when Notifiers is modified between test runs + notifiers := GetNotifiers() + if len(notifiers) == 0 { + return + } + go func() { // Acquire semaphore slot notificationSemaphore <- struct{}{} defer func() { <-notificationSemaphore if r := recover(); r != nil { - logrus.WithField("panic", r).Error("panic in SendNotification") + logrus.WithField("panic", r).Error("panic in sendNotification") } }() ctx, cancel := context.WithTimeout(context.Background(), config.Config.NotificationTimeout) defer cancel() - notifier := GetNotifier() notif := Notification{ Operation: operation, @@ -61,17 +68,37 @@ func sendNotification(operation Operation, entityType EntityType, entityID uint, Details: make(map[string]any), } - err := notifier.Send(ctx, notif) - if err != nil { + // Send to all notifiers concurrently, aggregate errors + var ( + wg sync.WaitGroup + mu sync.Mutex + errs []error + ) + + for _, notifier := range notifiers { + wg.Add(1) + go func(n Notifier) { + defer wg.Done() + err := n.Send(ctx, notif) + recordNotificationMetrics(n.Name(), operation, entityType, err == nil) + if err != nil { + mu.Lock() + errs = append(errs, fmt.Errorf("%s: %w", n.Name(), err)) + mu.Unlock() + } + }(notifier) + } + + wg.Wait() + + if len(errs) > 0 { logrus.WithFields(logrus.Fields{ "operation": operation, "entityType": entityType, "entityID": entityID, - "error": err, - }).Warn("failed to send notification") + "errors": errs, + }).Warn("failed to send notifications to some providers") } - // Record metrics regardless of success/failure for observability - recordNotificationMetrics(notifier.Name(), operation, entityType, err == nil) }() } diff --git a/pkg/notification/dispatch_test.go b/pkg/notification/dispatch_test.go index 85d438d08..27c344fcc 100644 --- a/pkg/notification/dispatch_test.go +++ b/pkg/notification/dispatch_test.go @@ -1,8 +1,13 @@ package notification import ( + "context" + "errors" + "sync" "testing" + "time" + "github.com/prashantv/gostub" "github.com/stretchr/testify/assert" ) @@ -32,3 +37,142 @@ func TestCalculateDiff(t *testing.T) { assert.Contains(t, diff, "+ \"enabled\": true") }) } + +func TestSendNotification(t *testing.T) { + t.Run("sends to multiple notifiers concurrently", func(t *testing.T) { + mock1 := NewMockNotifier() + mock2 := NewMockNotifier() + mock3 := NewMockNotifier() + + // First reset to nil, then stub to desired value + Notifiers = nil + stubs := gostub.Stub(&Notifiers, []Notifier{mock1, mock2, mock3}) + defer stubs.Reset() + + sendNotification(OperationCreate, EntityTypeFlag, 1, "test-flag", "description", "", "", "", "user") + + // Wait for goroutine to complete + assert.Eventually(t, func() bool { + return len(mock1.GetSentNotifications()) == 1 && + len(mock2.GetSentNotifications()) == 1 && + len(mock3.GetSentNotifications()) == 1 + }, 1*time.Second, 10*time.Millisecond) + + // Verify each notifier received the same notification + for _, mock := range []*MockNotifier{mock1, mock2, mock3} { + sent := mock.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, OperationCreate, sent[0].Operation) + assert.Equal(t, EntityTypeFlag, sent[0].EntityType) + assert.Equal(t, uint(1), sent[0].EntityID) + assert.Equal(t, "test-flag", sent[0].EntityKey) + } + }) + + t.Run("handles errors from some notifiers", func(t *testing.T) { + mock1 := NewMockNotifier() + mock1.SetSendError(errors.New("error from mock1")) + + mock2 := NewMockNotifier() + // mock2 succeeds + + mock3 := NewMockNotifier() + mock3.SetSendError(errors.New("error from mock3")) + + Notifiers = nil + stubs := gostub.Stub(&Notifiers, []Notifier{mock1, mock2, mock3}) + defer stubs.Reset() + + sendNotification(OperationUpdate, EntityTypeFlag, 2, "test-flag-2", "", "", "", "", "") + + // Wait for goroutine to complete + assert.Eventually(t, func() bool { + return len(mock1.GetSentNotifications()) == 1 && + len(mock2.GetSentNotifications()) == 1 && + len(mock3.GetSentNotifications()) == 1 + }, 1*time.Second, 10*time.Millisecond) + + // All notifiers should still have been called (fire all) + assert.Len(t, mock1.GetSentNotifications(), 1) + assert.Len(t, mock2.GetSentNotifications(), 1) + assert.Len(t, mock3.GetSentNotifications(), 1) + }) + + t.Run("does nothing when notifiers is empty", func(t *testing.T) { + Notifiers = nil + stubs := gostub.Stub(&Notifiers, []Notifier(nil)) + defer stubs.Reset() + + // Should not panic + sendNotification(OperationCreate, EntityTypeFlag, 1, "test", "", "", "", "", "") + }) + + t.Run("SendFlagNotification sends with correct entity type", func(t *testing.T) { + mock := NewMockNotifier() + Notifiers = nil + stubs := gostub.Stub(&Notifiers, []Notifier{mock}) + defer stubs.Reset() + + SendFlagNotification(OperationCreate, 42, "my-flag", "my description", "", "", "", "creator") + + assert.Eventually(t, func() bool { + return len(mock.GetSentNotifications()) >= 1 + }, 1*time.Second, 10*time.Millisecond) + + sent := mock.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, OperationCreate, sent[0].Operation) + assert.Equal(t, EntityTypeFlag, sent[0].EntityType) + assert.Equal(t, uint(42), sent[0].EntityID) + assert.Equal(t, "my-flag", sent[0].EntityKey) + assert.Equal(t, "my description", sent[0].Description) + assert.Equal(t, "creator", sent[0].User) + }) +} + +func TestSendNotificationConcurrency(t *testing.T) { + t.Run("concurrent sends are handled safely", func(t *testing.T) { + mock := NewMockNotifier() + stubs := gostub.Stub(&Notifiers, []Notifier{mock}) + defer stubs.Reset() + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(id uint) { + defer wg.Done() + SendFlagNotification(OperationCreate, id, "flag", "", "", "", "", "") + }(uint(i)) + } + + wg.Wait() + + // All notifications should eventually be delivered + assert.Eventually(t, func() bool { + return len(mock.GetSentNotifications()) == 50 + }, 2*time.Second, 50*time.Millisecond) + }) +} + +func TestNotifierDirectSend(t *testing.T) { + t.Run("can send to notifier directly with context", func(t *testing.T) { + mock := NewMockNotifier() + + ctx := context.Background() + notif := Notification{ + Operation: OperationCreate, + EntityType: EntityTypeFlag, + EntityID: 1, + EntityKey: "direct-test", + Description: "direct send test", + User: "tester", + } + + err := mock.Send(ctx, notif) + assert.NoError(t, err) + + sent := mock.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, "direct-test", sent[0].EntityKey) + }) +} diff --git a/pkg/notification/email.go b/pkg/notification/email.go index 1de8e91b1..61f07ed24 100644 --- a/pkg/notification/email.go +++ b/pkg/notification/email.go @@ -95,6 +95,8 @@ func formatEmailBody(n Notification) string { emoji = "âœī¸" case OperationDelete: emoji = "đŸ—‘ī¸" + case OperationRestore: + emoji = "â™ģī¸" default: emoji = "â„šī¸" } diff --git a/pkg/notification/email_test.go b/pkg/notification/email_test.go index e18b0f962..07348be12 100644 --- a/pkg/notification/email_test.go +++ b/pkg/notification/email_test.go @@ -65,6 +65,22 @@ func TestEmailNotifier(t *testing.T) { assert.Contains(t, body, "delete segment") }) + t.Run("formats body correctly for restore", func(t *testing.T) { + n := Notification{ + Operation: OperationRestore, + EntityType: EntityTypeFlag, + EntityKey: "restored-flag", + EntityID: 42, + User: "admin@example.com", + } + body := formatEmailBody(n) + assert.Contains(t, body, "â™ģī¸") + assert.Contains(t, body, "restore flag") + assert.Contains(t, body, "Key: restored-flag") + assert.Contains(t, body, "ID: 42") + assert.Contains(t, body, "User: admin@example.com") + }) + t.Run("formats body correctly with description and values", func(t *testing.T) { n := Notification{ Operation: "update", diff --git a/pkg/notification/notifier.go b/pkg/notification/notifier.go index 4151a3aa4..0f499e6f5 100644 --- a/pkg/notification/notifier.go +++ b/pkg/notification/notifier.go @@ -5,7 +5,6 @@ import ( "sync" "github.com/openflagr/flagr/pkg/config" - "github.com/sirupsen/logrus" ) type Notifier interface { @@ -46,44 +45,39 @@ type Notification struct { } var ( - singletonNotifier Notifier - once sync.Once + // Notifiers is the list of configured notifiers. Set directly for testing. + Notifiers []Notifier + once sync.Once ) -// SetNotifier sets the global notifier, useful for testing -func SetNotifier(n Notifier) { - singletonNotifier = n -} - -func GetNotifier() Notifier { - if singletonNotifier != nil { - return singletonNotifier +// GetNotifiers returns the list of configured notifiers. +// It initializes the notifiers on first call using sync.Once. +// For testing, set Notifiers directly before calling GetNotifiers. +func GetNotifiers() []Notifier { + // If already set (e.g., by tests), return immediately + if len(Notifiers) > 0 { + return Notifiers } once.Do(func() { - if !config.Config.NotificationEnabled { - singletonNotifier = &nullNotifier{} - return + if config.Config.NotificationSlackEnabled { + if sn := NewSlackNotifier(); sn != nil { + Notifiers = append(Notifiers, sn) + } } - - switch config.Config.NotificationProvider { - case "slack": - singletonNotifier = NewSlackNotifier() - case "email": - singletonNotifier = NewEmailNotifier() - case "webhook": - singletonNotifier = NewWebhookNotifier() - default: - logrus.Warnf("unknown notification provider: %s, using null notifier", config.Config.NotificationProvider) - singletonNotifier = &nullNotifier{} + if config.Config.NotificationEmailEnabled { + if en := NewEmailNotifier(); en != nil { + Notifiers = append(Notifiers, en) + } + } + if config.Config.NotificationWebhookEnabled { + if wn := NewWebhookNotifier(); wn != nil { + Notifiers = append(Notifiers, wn) + } } }) - if singletonNotifier == nil { - return &nullNotifier{} - } - - return singletonNotifier + return Notifiers } type nullNotifier struct{} diff --git a/pkg/notification/notifier_test.go b/pkg/notification/notifier_test.go index 156e6a48e..254543e0c 100644 --- a/pkg/notification/notifier_test.go +++ b/pkg/notification/notifier_test.go @@ -2,8 +2,11 @@ package notification import ( "context" + "errors" + "sync" "testing" + "github.com/prashantv/gostub" "github.com/stretchr/testify/assert" ) @@ -23,6 +26,11 @@ func TestNotification(t *testing.T) { assert.NoError(t, err) }) + t.Run("null notifier name", func(t *testing.T) { + n := &nullNotifier{} + assert.Equal(t, "null", n.Name()) + }) + t.Run("mock notifier records sent notifications", func(t *testing.T) { m := NewMockNotifier() ctx := context.Background() @@ -64,7 +72,7 @@ func TestNotification(t *testing.T) { t.Run("mock notifier can return errors", func(t *testing.T) { m := NewMockNotifier() - m.SetSendError(assert.AnError) + m.SetSendError(errors.New("test error")) ctx := context.Background() notif := Notification{Operation: Operation("test")} @@ -86,3 +94,45 @@ func TestNotification(t *testing.T) { assert.Len(t, m.GetSentNotifications(), 0) }) } + +func TestGetNotifiers(t *testing.T) { + t.Run("GetNotifiers returns empty when disabled", func(t *testing.T) { + stubs := gostub.Stub(&Notifiers, []Notifier(nil)) + stubs.Stub(&once, sync.Once{}) + defer stubs.Reset() + + n := GetNotifiers() + assert.Empty(t, n) + }) + + t.Run("GetNotifiers returns pre-set notifiers for testing", func(t *testing.T) { + mock := NewMockNotifier() + stubs := gostub.Stub(&Notifiers, []Notifier{mock}) + stubs.Stub(&once, sync.Once{}) + defer stubs.Reset() + + n := GetNotifiers() + assert.Len(t, n, 1) + assert.Equal(t, "mock", n[0].Name()) + }) +} + +func TestNotifierConcurrency(t *testing.T) { + t.Run("MockNotifier is safe for concurrent use", func(t *testing.T) { + mock := NewMockNotifier() + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + mock.Send(context.Background(), Notification{Operation: OperationCreate}) + }() + } + + wg.Wait() + + // All notifications should have been sent + assert.Len(t, mock.GetSentNotifications(), 100) + }) +} \ No newline at end of file diff --git a/pkg/notification/retry.go b/pkg/notification/retry.go index fa17998e9..7ad35333e 100644 --- a/pkg/notification/retry.go +++ b/pkg/notification/retry.go @@ -43,17 +43,25 @@ func doRequestWithRetry(ctx context.Context, client *http.Client, req *http.Requ // Don't close body here; caller will handle it if resp is returned if resp.StatusCode < 500 { + // Close any previous failed response body before returning success + if lastResp != nil { + lastResp.Body.Close() + } return resp, nil // Success or client error (4xx) is considered final; no retry on 4xx } // 5xx - retryable + // Close previous lastResp.Body before overwriting to prevent resource leak + if lastResp != nil { + lastResp.Body.Close() + } lastResp = resp lastErr = fmt.Errorf("HTTP %d error", resp.StatusCode) if attempt < maxRetries { delay = minDuration(2*delay, maxDelay) continue } - // Final attempt failed with 5xx + // Final attempt failed with 5xx - caller is responsible for closing body return resp, lastErr } diff --git a/pkg/notification/retry_test.go b/pkg/notification/retry_test.go new file mode 100644 index 000000000..b4bb82379 --- /dev/null +++ b/pkg/notification/retry_test.go @@ -0,0 +1,195 @@ +package notification + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDoRequestWithRetry(t *testing.T) { + t.Run("returns immediately on 2xx success", func(t *testing.T) { + callCount := int32(0) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + client := &http.Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + ctx := context.Background() + + resp, err := doRequestWithRetry(ctx, client, req, 3, 10*time.Millisecond, 100*time.Millisecond) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, int32(1), atomic.LoadInt32(&callCount)) + resp.Body.Close() + }) + + t.Run("does not retry on 4xx client error", func(t *testing.T) { + callCount := int32(0) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusBadRequest) + })) + defer ts.Close() + + client := &http.Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + ctx := context.Background() + + resp, err := doRequestWithRetry(ctx, client, req, 3, 10*time.Millisecond, 100*time.Millisecond) + assert.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, int32(1), atomic.LoadInt32(&callCount), "Should not retry on 4xx") + resp.Body.Close() + }) + + t.Run("retries on 5xx server error", func(t *testing.T) { + callCount := int32(0) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&callCount, 1) + if count < 3 { + w.WriteHeader(http.StatusInternalServerError) + } else { + w.WriteHeader(http.StatusOK) + } + })) + defer ts.Close() + + client := &http.Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + ctx := context.Background() + + resp, err := doRequestWithRetry(ctx, client, req, 3, 10*time.Millisecond, 100*time.Millisecond) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, int32(3), atomic.LoadInt32(&callCount), "Should retry on 5xx") + resp.Body.Close() + }) + + t.Run("returns error after max retries exhausted", func(t *testing.T) { + callCount := int32(0) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer ts.Close() + + client := &http.Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + ctx := context.Background() + + resp, err := doRequestWithRetry(ctx, client, req, 2, 10*time.Millisecond, 100*time.Millisecond) + assert.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 503") + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + assert.Equal(t, int32(3), atomic.LoadInt32(&callCount), "Should make maxRetries+1 attempts") + resp.Body.Close() + }) + + t.Run("properly closes response body on retries to prevent leak", func(t *testing.T) { + var bodiesClosed int32 + + callCount := int32(0) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&callCount, 1) + if count < 3 { + w.WriteHeader(http.StatusInternalServerError) + } else { + w.WriteHeader(http.StatusOK) + } + })) + defer ts.Close() + + // Create a custom transport that tracks body closures + originalTransport := http.DefaultTransport + transport := &mockTransport{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + resp, err := originalTransport.RoundTrip(req) + if resp != nil && resp.Body != nil { + // Wrap body to track closure + wrapped := &trackingBody{ + ReadCloser: resp.Body, + onClose: func() { + atomic.AddInt32(&bodiesClosed, 1) + }, + } + resp.Body = wrapped + } + return resp, err + }, + } + + client := &http.Client{Transport: transport} + req, _ := http.NewRequest("GET", ts.URL, nil) + ctx := context.Background() + + resp, err := doRequestWithRetry(ctx, client, req, 3, 10*time.Millisecond, 100*time.Millisecond) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Close the final response + resp.Body.Close() + + // We made 3 requests, so we expect 3 bodies total, all should be closed + assert.Equal(t, int32(3), atomic.LoadInt32(&callCount), "Should make 3 requests") + assert.Equal(t, int32(3), atomic.LoadInt32(&bodiesClosed), "All 3 response bodies should be closed after retries") + }) + + t.Run("respects context cancellation", func(t *testing.T) { + callCount := int32(0) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + client := &http.Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + ctx, cancel := context.WithCancel(context.Background()) + + // Cancel after first request + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + resp, err := doRequestWithRetry(ctx, client, req, 10, 100*time.Millisecond, 1*time.Second) + assert.Error(t, err) + assert.Contains(t, err.Error(), "canceled") + // Should not have made all retries + assert.LessOrEqual(t, atomic.LoadInt32(&callCount), int32(2)) + if resp != nil { + resp.Body.Close() + } + }) +} + +// mockTransport is a custom http.RoundTripper for testing +type mockTransport struct { + RoundTripFunc func(req *http.Request) (*http.Response, error) +} + +func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return m.RoundTripFunc(req) +} + +// trackingBody wraps an io.ReadCloser and calls onClose when Close is called +type trackingBody struct { + io.ReadCloser + onClose func() +} + +func (t *trackingBody) Close() error { + if t.onClose != nil { + t.onClose() + } + return t.ReadCloser.Close() +} diff --git a/pkg/notification/validate.go b/pkg/notification/validate.go index 81e8d7f66..d08c39823 100644 --- a/pkg/notification/validate.go +++ b/pkg/notification/validate.go @@ -6,32 +6,23 @@ import ( ) // ValidateConfig checks notification configuration and logs warnings if misconfigured. -// This should be called during application startup when notifications are enabled. +// This should be called during application startup. func ValidateConfig() { - if !config.Config.NotificationEnabled { - return + if config.Config.NotificationSlackEnabled { + if config.Config.NotificationSlackWebhookURL == "" { + logrus.Warn("Slack notifications are enabled, but FLAGR_NOTIFICATION_SLACK_WEBHOOK_URL is not set. Slack notifications will be silently dropped.") + } } - provider := config.Config.NotificationProvider - var configured bool - - switch provider { - case "slack": - configured = config.Config.NotificationSlackWebhookURL != "" - if !configured { - logrus.Warn("Notifications are enabled with provider 'slack', but FLAGR_NOTIFICATION_SLACK_WEBHOOK_URL is not set. Notifications will be silently dropped.") - } - case "email": - configured = config.Config.NotificationEmailURL != "" && config.Config.NotificationEmailTo != "" && config.Config.NotificationEmailFrom != "" - if !configured { - logrus.Warn("Notifications are enabled with provider 'email', but FLAGR_NOTIFICATION_EMAIL_URL, FLAGR_NOTIFICATION_EMAIL_TO, and FLAGR_NOTIFICATION_EMAIL_FROM must all be set. Notifications will be silently dropped.") + if config.Config.NotificationEmailEnabled { + if config.Config.NotificationEmailURL == "" || config.Config.NotificationEmailTo == "" || config.Config.NotificationEmailFrom == "" { + logrus.Warn("Email notifications are enabled, but FLAGR_NOTIFICATION_EMAIL_URL, FLAGR_NOTIFICATION_EMAIL_TO, and FLAGR_NOTIFICATION_EMAIL_FROM should all be set. Email notifications may fail.") } - case "webhook": - configured = config.Config.NotificationWebhookURL != "" - if !configured { - logrus.Warn("Notifications are enabled with provider 'webhook', but FLAGR_NOTIFICATION_WEBHOOK_URL is not set. Notifications will be silently dropped.") + } + + if config.Config.NotificationWebhookEnabled { + if config.Config.NotificationWebhookURL == "" { + logrus.Warn("Webhook notifications are enabled, but FLAGR_NOTIFICATION_WEBHOOK_URL is not set. Webhook notifications will be silently dropped.") } - default: - logrus.Warnf("Unknown notification provider: %s. Notifications will be silently dropped.", provider) } } From 4ee45bab53c3a5562efed2f1bd3fc8a1036d6d78 Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Fri, 27 Feb 2026 12:39:14 +0000 Subject: [PATCH 14/18] docs: remove FLAGR_NOTIFICATION_ENABLED, use provider-specific flags --- docs/flagr_notifications.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/flagr_notifications.md b/docs/flagr_notifications.md index 7871cad4f..d937d598f 100644 --- a/docs/flagr_notifications.md +++ b/docs/flagr_notifications.md @@ -19,13 +19,20 @@ The following operations trigger notifications: ## Global Configuration -You must globally enable the notifications feature via environment variables. +Notifications are enabled automatically when at least one provider is configured. No global toggle is required. -- `FLAGR_NOTIFICATION_ENABLED=true` (Default: `false`) - Globally toggles the notification subsystem. - `FLAGR_NOTIFICATION_PROVIDER=slack` (Options: `slack`, `email`, `webhook`) - Determines the active transport channel. - `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true` (Default: `false`) - When enabled, Flagr will embed the precise visual JSON diff of the modified entity within the notification payload. - `FLAGR_NOTIFICATION_TIMEOUT=10s` (Default: `10s`) - Configures the timeout window for dialing external notification webhooks and email APIs. +### Provider Configuration + +Enable at least one provider to activate the notification system: + +- `FLAGR_NOTIFICATION_SLACK_ENABLED=true` (Default: `false`) - Enable Slack notifications +- `FLAGR_NOTIFICATION_EMAIL_ENABLED=true` (Default: `false`) - Enable email notifications +- `FLAGR_NOTIFICATION_WEBHOOK_ENABLED=true` (Default: `false`) - Enable generic webhook notifications + ### Retry Configuration (HTTP providers only) - `FLAGR_NOTIFICATION_MAX_RETRIES=3` (Default: `3`) - Maximum number of retry attempts for transient HTTP failures (5xx errors). Set to `0` to disable retries. @@ -40,11 +47,9 @@ You must globally enable the notifications feature via environment variables. ### Important Notes - **Asynchronous delivery**: Notifications are sent in background goroutines. Failures are logged but **do not affect the API response**. -- **Startup validation**: When `FLAGR_NOTIFICATION_ENABLED=true`, Flagr validates the configuration based on the selected provider and logs warnings if required settings are missing. Notifications will be silently dropped until properly configured. - -## Provider Configuration +- **Startup validation**: Flagr validates the notification configuration at startup and logs warnings if required settings are missing for the enabled providers. Notifications will be silently dropped until properly configured. -Depending on the `FLAGR_NOTIFICATION_PROVIDER` selected above, configure the target transport mechanism: +## Provider Settings ### 1. Slack From 007a8282b3f94d0136f0a8a360b32b74add2f96a Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Fri, 27 Feb 2026 12:43:32 +0000 Subject: [PATCH 15/18] docs: clarify silent fallback behavior for misconfigured providers --- docs/flagr_notifications.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/flagr_notifications.md b/docs/flagr_notifications.md index d937d598f..318c76811 100644 --- a/docs/flagr_notifications.md +++ b/docs/flagr_notifications.md @@ -47,7 +47,8 @@ Enable at least one provider to activate the notification system: ### Important Notes - **Asynchronous delivery**: Notifications are sent in background goroutines. Failures are logged but **do not affect the API response**. -- **Startup validation**: Flagr validates the notification configuration at startup and logs warnings if required settings are missing for the enabled providers. Notifications will be silently dropped until properly configured. +- **Startup validation**: Flagr validates the notification configuration at startup and logs warnings if required settings are missing for enabled providers. +- **Silent fallback**: If a provider is enabled (e.g., `FLAGR_NOTIFICATION_SLACK_ENABLED=true`) but its required settings are missing (e.g., `FLAGR_NOTIFICATION_SLACK_WEBHOOK_URL`), notifications for that provider will be silently dropped. A warning is logged at startup to help diagnose misconfiguration. ## Provider Settings From 30d6bc5475d03e86d47cd0a375d61d1a1cda3ac0 Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Tue, 3 Mar 2026 13:37:43 +0000 Subject: [PATCH 16/18] fix: add missing github.com/nikoksr/notify dependency --- go.mod | 80 ++++++++++++------------ go.sum | 188 +++++++++++++++++++++++++++++++++------------------------ 2 files changed, 151 insertions(+), 117 deletions(-) diff --git a/go.mod b/go.mod index c1d6f9b25..cc4ad41a3 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/bsm/ratelimit v2.0.0+incompatible github.com/caarlos0/env v3.5.0+incompatible github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect - github.com/davecgh/go-spew v1.1.1 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/dchest/uniuri v1.2.0 github.com/evalphobia/logrus_sentry v0.8.2 github.com/form3tech-oss/jwt-go v3.2.5+incompatible @@ -46,14 +46,14 @@ require ( github.com/yadvendar/negroni-newrelic-go-agent v0.0.0-20160803090806-3dc58758cb67 github.com/zhouzhuojie/conditions v0.2.3 github.com/zhouzhuojie/withtimeout v0.0.0-20190405051827-12b39eb2edd5 - golang.org/x/net v0.47.0 - google.golang.org/api v0.247.0 - google.golang.org/grpc v1.74.2 + golang.org/x/net v0.48.0 + google.golang.org/api v0.257.0 + google.golang.org/grpc v1.77.0 gopkg.in/DataDog/dd-trace-go.v1 v1.46.0 ) require ( - cloud.google.com/go/pubsub v1.49.0 + cloud.google.com/go/pubsub v1.50.1 github.com/glebarez/sqlite v1.6.0 github.com/newrelic/go-agent v2.1.0+incompatible gorm.io/driver/mysql v1.4.5 @@ -62,15 +62,18 @@ require ( ) require ( - github.com/aws/aws-sdk-go-v2/config v1.31.20 + github.com/aws/aws-sdk-go-v2/config v1.32.5 github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.3 + github.com/nikoksr/notify v1.5.0 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 ) require ( - cloud.google.com/go/auth v0.16.4 // indirect + cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.8.0 // indirect - cloud.google.com/go/iam v1.5.2 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.5.3 // indirect + cloud.google.com/go/pubsub/v2 v2.0.0 // indirect github.com/DataDog/datadog-agent/pkg/obfuscate v0.41.1 // indirect github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.42.0-rc.5 // indirect github.com/DataDog/datadog-go/v5 v5.2.0 // indirect @@ -78,19 +81,20 @@ require ( github.com/DataDog/sketches-go v1.4.1 // indirect github.com/Microsoft/go-winio v0.6.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/aws/aws-sdk-go-v2 v1.39.6 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.24 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.5 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 // indirect - github.com/aws/smithy-go v1.23.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect @@ -122,8 +126,9 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect @@ -139,22 +144,23 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.18.0 // indirect - github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.17 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect - go.einride.tech/aip v0.68.1 // indirect + github.com/slack-go/slack v0.17.3 // indirect + github.com/stretchr/objx v0.5.3 // indirect + go.einride.tech/aip v0.73.0 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect @@ -164,19 +170,19 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org/intern v0.0.0-20220617035311-6925f38cc365 // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.18.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.39.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect - google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect inet.af/netaddr v0.0.0-20220811202034-502d2d690317 // indirect modernc.org/libc v1.22.5 // indirect diff --git a/go.sum b/go.sum index 2786cee42..03769ab76 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,22 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.16.4 h1:fXOAIQmkApVvcIn7Pc2+5J8QTMVbUGLscnSVNl11su8= -cloud.google.com/go/auth v0.16.4/go.mod h1:j10ncYwjX/g3cdX7GpEzsdM+d+ZNsXAbb6qXA7p1Y5M= +cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= +cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= -cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= -cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= -cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= -cloud.google.com/go/kms v1.22.0 h1:dBRIj7+GDeeEvatJeTB19oYZNV0aj6wEqSIT/7gLqtk= -cloud.google.com/go/kms v1.22.0/go.mod h1:U7mf8Sva5jpOb4bxYZdtw/9zsbIjrklYwPcvMk34AL8= -cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= -cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= -cloud.google.com/go/pubsub v1.49.0 h1:5054IkbslnrMCgA2MAEPcsN3Ky+AyMpEZcii/DoySPo= -cloud.google.com/go/pubsub v1.49.0/go.mod h1:K1FswTWP+C1tI/nfi3HQecoVeFvL4HUOB1tdaNXKhUY= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/kms v1.23.2 h1:4IYDQL5hG4L+HzJBhzejUySoUOheh3Lk5YT4PCyyW6k= +cloud.google.com/go/kms v1.23.2/go.mod h1:rZ5kK0I7Kn9W4erhYVoIRPtpizjunlrfU4fUkumUp8g= +cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= +cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= +cloud.google.com/go/pubsub v1.50.1 h1:fzbXpPyJnSGvWXF1jabhQeXyxdbCIkXTpjXHy7xviBM= +cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk= +cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0= +cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DataDog/datadog-agent/pkg/obfuscate v0.41.1 h1:AHZu7lzfW6amjOLkbjioAxT+pKiiwD6KdkR0VfT3pMw= github.com/DataDog/datadog-agent/pkg/obfuscate v0.41.1/go.mod h1:DNHeRExTGWQoMgmOgcDtNENOEHN/tYJIicmAUgW1nXk= @@ -45,36 +47,38 @@ github.com/auth0/go-jwt-middleware v1.0.2-0.20210804140707-b4090e955b98 h1:cH5eD github.com/auth0/go-jwt-middleware v1.0.2-0.20210804140707-b4090e955b98/go.mod h1:YSeUX3z6+TF2H+7padiEqNJ73Zy9vXW72U//IgN0BIM= github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= -github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk= -github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= -github.com/aws/aws-sdk-go-v2/config v1.31.20 h1:/jWF4Wu90EhKCgjTdy1DGxcbcbNrjfBHvksEL79tfQc= -github.com/aws/aws-sdk-go-v2/config v1.31.20/go.mod h1:95Hh1Tc5VYKL9NJ7tAkDcqeKt+MCXQB1hQZaRdJIZE0= -github.com/aws/aws-sdk-go-v2/credentials v1.18.24 h1:iJ2FmPT35EaIB0+kMa6TnQ+PwG5A1prEdAw+PsMzfHg= -github.com/aws/aws-sdk-go-v2/credentials v1.18.24/go.mod h1:U91+DrfjAiXPDEGYhh/x29o4p0qHX5HDqG7y5VViv64= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M= +github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8= +github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.3 h1:A2HNxrABEFha5831yAU05G0mYNxaxYH4WG85FV6ZWIQ= github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.3/go.mod h1:jTDNZao/9uv/6JeaeDWEqA4s+l6c8+cqaDeYFpM+818= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 h1:NjShtS1t8r5LUfFVtFeI8xLAHQNTa7UI0VawXlrBMFQ= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.3/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 h1:gTsnx0xXNQ6SBbymoDvcoRHL+q4l/dAFsQuKfDWSaGc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= -github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 h1:HK5ON3KmQV2HcAunnx4sKLB9aPf3gKGwVAf7xnx0QT0= -github.com/aws/aws-sdk-go-v2/service/sts v1.40.2/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= -github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= -github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/brandur/simplebox v0.1.0 h1:6LKvBOuQ/KNDtuNg0e/OTLeS6IDKN1osuXGF67xEynk= @@ -97,11 +101,14 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug= @@ -124,7 +131,12 @@ github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFP github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/evalphobia/logrus_sentry v0.8.2 h1:dotxHq+YLZsT1Bb45bB5UQbfCh3gM/nFFetyN46VoDQ= github.com/evalphobia/logrus_sentry v0.8.2/go.mod h1:pKcp+vriitUqu9KiWj/VRFbRfFNUwz95/UkgG8a6MNc= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -199,6 +211,8 @@ github.com/go-openapi/validate v0.25.0/go.mod h1:SUY7vKrN5FiwK6LyvSwKjDfLNirSfWw github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gohttp/pprof v0.0.0-20141119085724-c9d246cbb3ba h1:OckY4Dk1WhEEEz4zYYMsXG5f6necMtGAyAs19vcpRXk= @@ -248,8 +262,8 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -259,6 +273,8 @@ github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -300,6 +316,8 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= @@ -319,8 +337,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= -github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/meatballhat/negroni-logrus v1.1.1 h1:eDgsDdJYy97gI9kr+YS/uDKCaqK4S6CUQLPG0vNDqZA= github.com/meatballhat/negroni-logrus v1.1.1/go.mod h1:FlwPdXB6PeT8EG/gCd/2766M2LNF7SwZiNGD6t2NRGU= @@ -328,6 +346,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/newrelic/go-agent v2.1.0+incompatible h1:fCuxXeM4eeIKPbzffOWW6y2Dj+eYfc3yylgNZACZqkM= github.com/newrelic/go-agent v2.1.0+incompatible/go.mod h1:a8Fv1b/fYhFSReoTU6HDkTYIMZeSVNffmoS726Y0LzQ= +github.com/nikoksr/notify v1.5.0 h1:mzkCw8eb0P+qHwgmGQyPPGqz4GH+07FJDr44Bs16T9k= +github.com/nikoksr/notify v1.5.0/go.mod h1:CEV9Bw9Y59K5oj7d8h83Xl32ATeL43ZEg9qTQsfwcCc= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -354,8 +374,11 @@ github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= @@ -383,6 +406,8 @@ github.com/secure-systems-lab/go-securesystemslib v0.4.0/go.mod h1:FGBZgq2tXWICs github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= +github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0= github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= @@ -393,8 +418,8 @@ github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qq github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -420,18 +445,18 @@ github.com/zhouzhuojie/conditions v0.2.3 h1:TS3X6vA9CVXXteRdeXtpOw3hAar+01f0TI/d github.com/zhouzhuojie/conditions v0.2.3/go.mod h1:Izhy98HD3MkfwGPz+p9ZV2JuqrpbHjaQbUq9iZHh+ZY= github.com/zhouzhuojie/withtimeout v0.0.0-20190405051827-12b39eb2edd5 h1:YuR5otuPvpk6EPrKy9rVXiQKTqgY6OEqSlzko9kcfCI= github.com/zhouzhuojie/withtimeout v0.0.0-20190405051827-12b39eb2edd5/go.mod h1:nhm/3zpPm56iKoXLEeeevuI5V9qEtNhuhLbPZwcrgcs= -go.einride.tech/aip v0.68.1 h1:16/AfSxcQISGN5z9C5lM+0mLYXihrHbQ1onvYTr93aQ= -go.einride.tech/aip v0.68.1/go.mod h1:XaFtaj4HuA3Zwk9xoBtTWgNubZ0ZZXv9BZJCkuKuWbg= +go.einride.tech/aip v0.73.0 h1:bPo4oqBo2ZQeBKo4ZzLb1kxYXTY1ysJhpvQyfuGzvps= +go.einride.tech/aip v0.73.0/go.mod h1:Mj7rFbmXEgw0dq1dqJ7JGMvYCZZVxmGOR3S4ZcV5LvQ= go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= @@ -464,8 +489,8 @@ golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -474,8 +499,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -496,11 +521,11 @@ golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220725212005-46097bf591d3/go.mod h1:AaygXjzTFtRAg2ttMY5RMuhpJ3cNnI0XpyFJD1iQRSM= golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -509,8 +534,8 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -542,6 +567,7 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -554,10 +580,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -571,34 +597,36 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc= -google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.257.0 h1:8Y0lzvHlZps53PEaw+G29SsQIkuKrumGWs9puiexNAA= +google.golang.org/api v0.257.0/go.mod h1:4eJrr+vbVaZSqs7vovFd1Jb/A6ml6iw2e6FBYf3GAO4= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= -google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 h1:mVXdvnmR3S3BQOqHECm9NGMjYiRtEvDYcqAqedTXY6s= -google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:vYFwMYFbmA8vl6Z/krj/h7+U/AqpHknwJX4Uqgfyc7I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -611,8 +639,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/DataDog/dd-trace-go.v1 v1.46.0 h1:h/SbNfGfDMhBkB+/zzCWKPOlLcdd0Fc+QBAnZm009XM= gopkg.in/DataDog/dd-trace-go.v1 v1.46.0/go.mod h1:kaa8caaECrtY0V/MUtPQAh1lx/euFzPJwrY1taTx3O4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -639,8 +667,8 @@ gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= gorm.io/gorm v1.24.3 h1:WL2ifUmzR/SLp85CSURAfybcHnGZ+yLSGSxgYXlFBHg= gorm.io/gorm v1.24.3/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= inet.af/netaddr v0.0.0-20220811202034-502d2d690317 h1:U2fwK6P2EqmopP/hFLTOAjWTki0qgd4GMJn5X8wOleU= From ab5dc19bb75b3b3bfa83d84acecfe73bd5de1eb2 Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Tue, 3 Mar 2026 13:40:50 +0000 Subject: [PATCH 17/18] fix: migrate pubsub to v2 to resolve deprecation warnings --- pkg/handler/data_recorder_pubsub.go | 8 ++++---- pkg/handler/data_recorder_pubsub_test.go | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/handler/data_recorder_pubsub.go b/pkg/handler/data_recorder_pubsub.go index 6387e072e..17ce4fb13 100644 --- a/pkg/handler/data_recorder_pubsub.go +++ b/pkg/handler/data_recorder_pubsub.go @@ -3,7 +3,7 @@ package handler import ( "context" - "cloud.google.com/go/pubsub" + "cloud.google.com/go/pubsub/v2" "github.com/openflagr/flagr/pkg/config" "github.com/openflagr/flagr/swagger_gen/models" "github.com/sirupsen/logrus" @@ -12,7 +12,7 @@ import ( type pubsubRecorder struct { producer *pubsub.Client - topic *pubsub.Topic + publisher *pubsub.Publisher options DataRecordFrameOptions } @@ -35,7 +35,7 @@ var NewPubsubRecorder = func() DataRecorder { return &pubsubRecorder{ producer: client, - topic: client.Topic(config.Config.RecorderPubsubTopicName), + publisher: client.Publisher(config.Config.RecorderPubsubTopicName), options: DataRecordFrameOptions{ Encrypted: false, // not implemented yet FrameOutputMode: config.Config.RecorderFrameOutputMode, @@ -58,7 +58,7 @@ func (p *pubsubRecorder) AsyncRecord(r models.EvalResult) { return } ctx := context.Background() - res := p.topic.Publish(ctx, &pubsub.Message{Data: output}) + res := p.publisher.Publish(ctx, &pubsub.Message{Data: output}) if config.Config.RecorderPubsubVerbose { go func() { ctx, cancel := context.WithTimeout(ctx, config.Config.RecorderPubsubVerboseCancelTimeout) diff --git a/pkg/handler/data_recorder_pubsub_test.go b/pkg/handler/data_recorder_pubsub_test.go index 5fc994aff..e135170d8 100644 --- a/pkg/handler/data_recorder_pubsub_test.go +++ b/pkg/handler/data_recorder_pubsub_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "cloud.google.com/go/pubsub" - "cloud.google.com/go/pubsub/pstest" + "cloud.google.com/go/pubsub/v2" + "cloud.google.com/go/pubsub/v2/pstest" "github.com/openflagr/flagr/swagger_gen/models" "github.com/prashantv/gostub" "github.com/stretchr/testify/assert" @@ -33,11 +33,11 @@ func TestPubsubAsyncRecord(t *testing.T) { t.Run("enabled and valid", func(t *testing.T) { client := mockClient(t) defer client.Close() - topic := client.Topic("test") + publisher := client.Publisher("test") assert.NotPanics(t, func() { pr := &pubsubRecorder{ producer: client, - topic: topic, + publisher: publisher, } pr.AsyncRecord( From fdabd0bc03fcf000cff72ee2d3c6ea01f2a91018 Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Tue, 3 Mar 2026 13:42:14 +0000 Subject: [PATCH 18/18] mod tidy --- go.mod | 3 +-- go.sum | 6 ------ 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/go.mod b/go.mod index cc4ad41a3..8472fd8b0 100644 --- a/go.mod +++ b/go.mod @@ -53,7 +53,6 @@ require ( ) require ( - cloud.google.com/go/pubsub v1.50.1 github.com/glebarez/sqlite v1.6.0 github.com/newrelic/go-agent v2.1.0+incompatible gorm.io/driver/mysql v1.4.5 @@ -62,6 +61,7 @@ require ( ) require ( + cloud.google.com/go/pubsub/v2 v2.0.0 github.com/aws/aws-sdk-go-v2/config v1.32.5 github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.3 github.com/nikoksr/notify v1.5.0 @@ -73,7 +73,6 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect - cloud.google.com/go/pubsub/v2 v2.0.0 // indirect github.com/DataDog/datadog-agent/pkg/obfuscate v0.41.1 // indirect github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.42.0-rc.5 // indirect github.com/DataDog/datadog-go/v5 v5.2.0 // indirect diff --git a/go.sum b/go.sum index 03769ab76..164f81d08 100644 --- a/go.sum +++ b/go.sum @@ -9,12 +9,6 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= -cloud.google.com/go/kms v1.23.2 h1:4IYDQL5hG4L+HzJBhzejUySoUOheh3Lk5YT4PCyyW6k= -cloud.google.com/go/kms v1.23.2/go.mod h1:rZ5kK0I7Kn9W4erhYVoIRPtpizjunlrfU4fUkumUp8g= -cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= -cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= -cloud.google.com/go/pubsub v1.50.1 h1:fzbXpPyJnSGvWXF1jabhQeXyxdbCIkXTpjXHy7xviBM= -cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk= cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0= cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=