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..318c76811 --- /dev/null +++ b/docs/flagr_notifications.md @@ -0,0 +1,98 @@ +# Notifications + +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**. + +## Tracked Operations + +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 + +Notifications are enabled automatically when at least one provider is configured. No global toggle is required. + +- `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. +- `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**: 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 + +### 1. Slack + +When using Slack, the notification is delivered as a formatted `Mrkdwn` message directly to your channel block. + +- `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 +{ + "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" +} +``` + +> **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. diff --git a/go.mod b/go.mod index c1d6f9b25..8472fd8b0 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,13 @@ 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 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 +61,18 @@ require ( ) require ( - github.com/aws/aws-sdk-go-v2/config v1.31.20 + 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 + 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 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 +80,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 +125,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 +143,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 +169,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..164f81d08 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,16 @@ 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/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 +41,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 +95,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 +125,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 +205,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 +256,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 +267,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 +310,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 +331,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 +340,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 +368,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 +400,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 +412,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 +439,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 +483,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 +493,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 +515,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 +528,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 +561,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 +574,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 +591,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 +633,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 +661,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= diff --git a/pkg/config/env.go b/pkg/config/env.go index a6e2b954b..5cb233b80 100644 --- a/pkg/config/env.go +++ b/pkg/config/env.go @@ -230,6 +230,46 @@ 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:","` + // ===== Notification - Global Settings ===== + // NotificationDetailedDiffEnabled - notify detailed diff of pre and post values + NotificationDetailedDiffEnabled bool `env:"FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED" envDefault:"false"` + // 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:""` + + // ===== 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:""` + // 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 // UI path => localhost:18000/foo" diff --git a/pkg/entity/flag_snapshot.go b/pkg/entity/flag_snapshot.go index b814a1163..0059f9f36 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,10 +22,15 @@ 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 notification.Operation) { tx := db.Begin() f := &Flag{} - if err := tx.First(f, flagID).Error; err != nil { + // 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, "flagID": flagID, @@ -55,7 +61,9 @@ func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string) { f.UpdatedBy = updatedBy f.SnapshotID = fs.ID - if err := tx.Save(f).Error; err != nil { + // 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, "flagID": f.Model.ID, @@ -65,11 +73,36 @@ func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string) { return } + preFS := &FlagSnapshot{} + // 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() + 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( + 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..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,10 +18,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", notification.OperationUpdate) }) 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..d7c528f9b 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)) + 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)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return resp } @@ -316,14 +317,21 @@ 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), notification.OperationRestore) return resp } 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() } @@ -337,7 +345,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), notification.OperationUpdate) return tag.NewDeleteTagOK() } @@ -399,7 +407,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), notification.OperationUpdate) return resp } @@ -418,7 +426,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), notification.OperationUpdate) return resp } @@ -461,7 +469,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), notification.OperationUpdate) return resp } @@ -485,7 +493,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), notification.OperationUpdate) return segment.NewPutSegmentsReorderOK() } @@ -495,7 +503,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), notification.OperationUpdate) return segment.NewDeleteSegmentOK() } @@ -517,7 +525,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), notification.OperationUpdate) return resp } @@ -555,7 +563,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), notification.OperationUpdate) return resp } @@ -566,7 +574,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), notification.OperationUpdate) return resp } @@ -602,7 +610,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), notification.OperationUpdate) return resp } @@ -644,7 +652,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), notification.OperationUpdate) return resp } @@ -695,7 +703,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), notification.OperationUpdate) return resp } @@ -708,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)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate) return variant.NewDeleteVariantOK() } diff --git a/pkg/handler/crud_flag_creation.go b/pkg/handler/crud_flag_creation.go index cffd4ce95..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)) + entity.SaveFlagSnapshot(getDB(), f.ID, getSubjectFromRequest(params.HTTPRequest), notification.OperationCreate) return resp } diff --git a/pkg/handler/crud_notification_test.go b/pkg/handler/crud_notification_test.go new file mode 100644 index 000000000..0fe152d3e --- /dev/null +++ b/pkg/handler/crud_notification_test.go @@ -0,0 +1,194 @@ +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/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() + // Use gostub to set notifiers and reset via defer + stubs := gostub.Stub(¬ification.Notifiers, []notification.Notifier{mockNotifier}) + defer stubs.Reset() + + c := NewCRUD() + + t.Run("CreateFlag sends notification", func(t *testing.T) { + mockNotifier.ClearSent() + params := flag.CreateFlagParams{ + HTTPRequest: &http.Request{}, + Body: &models.CreateFlagRequest{ + Description: new("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: new("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: new("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: new("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\"") + }) + + 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/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( 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..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) @@ -35,7 +38,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 +48,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 +82,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/dispatch.go b/pkg/notification/dispatch.go new file mode 100644 index 000000000..4dcf1108c --- /dev/null +++ b/pkg/notification/dispatch.go @@ -0,0 +1,135 @@ +package notification + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "sync" + + "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) { + // 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") + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), config.Config.NotificationTimeout) + defer cancel() + + 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), + } + + // 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, + "errors": errs, + }).Warn("failed to send notifications to some providers") + } + }() +} + +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 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/dispatch_test.go b/pkg/notification/dispatch_test.go new file mode 100644 index 000000000..27c344fcc --- /dev/null +++ b/pkg/notification/dispatch_test.go @@ -0,0 +1,178 @@ +package notification + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/prashantv/gostub" + "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") + }) +} + +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 new file mode 100644 index 000000000..61f07ed24 --- /dev/null +++ b/pkg/notification/email.go @@ -0,0 +1,134 @@ +package notification + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "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: config.Config.NotificationTimeout}, + } +} + +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) + } + + // 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 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("email service returned error: %d - %s", resp.StatusCode, string(b)) + } + + 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 (e *emailNotifier) Name() string { + return "email" +} + +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 OperationCreate: + emoji = "🚀" + case OperationUpdate: + emoji = "âœī¸" + case OperationDelete: + emoji = "đŸ—‘ī¸" + case OperationRestore: + 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..07348be12 --- /dev/null +++ b/pkg/notification/email_test.go @@ -0,0 +1,110 @@ +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 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", + 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/notifier.go b/pkg/notification/notifier.go new file mode 100644 index 000000000..0f499e6f5 --- /dev/null +++ b/pkg/notification/notifier.go @@ -0,0 +1,134 @@ +package notification + +import ( + "context" + "sync" + + "github.com/openflagr/flagr/pkg/config" +) + +type Notifier interface { + Send(ctx context.Context, n Notification) error + Name() string +} + +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 ( + // Notifiers is the list of configured notifiers. Set directly for testing. + Notifiers []Notifier + once sync.Once +) + +// 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.NotificationSlackEnabled { + if sn := NewSlackNotifier(); sn != nil { + Notifiers = append(Notifiers, sn) + } + } + 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) + } + } + }) + + return Notifiers +} + +type nullNotifier struct{} + +func (n *nullNotifier) Send(ctx context.Context, notification Notification) error { + return nil +} + +func (n *nullNotifier) Name() string { + return "null" +} + +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) Name() string { + return "mock" +} + +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..254543e0c --- /dev/null +++ b/pkg/notification/notifier_test.go @@ -0,0 +1,138 @@ +package notification + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/prashantv/gostub" + "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("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() + + 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(errors.New("test error")) + + 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) + }) +} + +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 new file mode 100644 index 000000000..7ad35333e --- /dev/null +++ b/pkg/notification/retry.go @@ -0,0 +1,69 @@ +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 { + // 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 - caller is responsible for closing body + return resp, lastErr + } + + return lastResp, 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/slack.go b/pkg/notification/slack.go new file mode 100644 index 000000000..a16ac78e0 --- /dev/null +++ b/pkg/notification/slack.go @@ -0,0 +1,83 @@ +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 (s *slackNotifier) Name() string { + return "slack" +} + +func formatNotification(n Notification) string { + var emoji string + switch n.Operation { + case OperationCreate: + emoji = ":rocket:" + case OperationUpdate: + emoji = ":pencil2:" + case OperationDelete: + 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 +} 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/validate.go b/pkg/notification/validate.go new file mode 100644 index 000000000..d08c39823 --- /dev/null +++ b/pkg/notification/validate.go @@ -0,0 +1,28 @@ +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. +func ValidateConfig() { + 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.") + } + } + + 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.") + } + } + + 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.") + } + } +} diff --git a/pkg/notification/webhook.go b/pkg/notification/webhook.go new file mode 100644 index 000000000..abaf18e5f --- /dev/null +++ b/pkg/notification/webhook.go @@ -0,0 +1,74 @@ +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) + } + + // 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() + + 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 +} + +func (w *webhookNotifier) Name() string { + return "webhook" +} 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) + }) + } +}