diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b9743b014..4d67df540 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -78,13 +78,13 @@ jobs: go-version-file: 'flagd/go.mod' - name: Set up QEMU - uses: docker/setup-qemu-action@master + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 with: platforms: all - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@master + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Build uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 # v5 @@ -95,7 +95,7 @@ jobs: tags: flagd-local:test - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 with: input: ${{ github.workspace }}/flagd-local.tar format: "sarif" @@ -124,32 +124,5 @@ jobs: with: go-version-file: 'flagd/go.mod' - - name: Install envoy - run: | - wget -O- https://apt.envoyproxy.io/signing.key | sudo gpg --dearmor -o /etc/apt/keyrings/envoy-keyring.gpg - echo "deb [signed-by=/etc/apt/keyrings/envoy-keyring.gpg] https://apt.envoyproxy.io jammy main" | sudo tee /etc/apt/sources.list.d/envoy.list - sudo apt-get update - sudo apt-get install envoy - envoy --version - - - name: Workspace init - run: make workspace-init - - - name: Build flagd binary - run: make build - - - name: Run flagd binary in background - run: | - ./bin/flagd start \ - -f file:${{ github.workspace }}/test-harness/flags/testing-flags.json \ - -f file:${{ github.workspace }}/test-harness/flags/custom-ops.json \ - -f file:${{ github.workspace }}/test-harness/flags/evaluator-refs.json \ - -f file:${{ github.workspace }}/test-harness/flags/zero-flags.json \ - -f file:${{ github.workspace }}/test-harness/flags/edge-case-flags.json & - - - name: Run envoy proxy in background - run: | - envoy -c ./test/integration/config/envoy.yaml & - - - name: Run evaluation test suite + - name: Run test suite run: make workspace-clean && cd test/integration && go clean -testcache && go test -cover diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml index 30ab6ae7a..cb53ccc0b 100644 --- a/.github/workflows/release-please.yaml +++ b/.github/workflows/release-please.yaml @@ -8,7 +8,7 @@ env: PUBLISHABLE_ITEMS: '["flagd","flagd-proxy"]' REGISTRY: ghcr.io REPO_OWNER: ${{ github.repository_owner }} - DEFAULT_GO_VERSION: '~1.21' + DEFAULT_GO_VERSION: '~1.25' PUBLIC_KEY_FILE: publicKey.pub GOPRIVATE: buf.build/gen/go @@ -22,7 +22,7 @@ jobs: - name: Get current date id: date run: echo "::set-output name=date::$(date +'%Y-%m-%d')" - - uses: google-github-actions/release-please-action@v3 + - uses: google-github-actions/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v3 id: release with: command: manifest @@ -93,13 +93,13 @@ jobs: images: ${{ env.REGISTRY }}/${{ matrix.path }} - name: Set up QEMU - uses: docker/setup-qemu-action@master + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 with: platforms: all - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@master + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Build id: build @@ -211,23 +211,3 @@ jobs: ./sbom.xml ./*.tar.gz ./*.zip - homebrew: - name: Bump homebrew-core formula - needs: release-please - runs-on: ubuntu-latest - # Only run on non-forked flagd releases - if: ${{ github.repository_owner == 'open-feature' && needs.release-please.outputs.flagd_tag_name }} - steps: - - uses: mislav/bump-homebrew-formula-action@v2 - with: - formula-name: flagd - # https://github.com/mislav/bump-homebrew-formula-action/issues/58 - formula-path: Formula/f/flagd.rb - tag-name: ${{ needs.release-please.outputs.flagd_tag_name }} - download-url: https://github.com/${{ github.repository }}.git - commit-message: | - {{formulaName}} ${{ needs.release-please.outputs.flagd_version }} - - Created by https://github.com/mislav/bump-homebrew-formula-action - env: - COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }} diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index c89a936b4..d60ee8408 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -16,6 +16,7 @@ config: descriptive-link-text: false MD007: indent: 4 + MD060: false # unfortunately, this is broken with emojis for now ignores: - "**/CHANGELOG.md" diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b4ecc9e02..a1bf3404f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,5 @@ { - "flagd": "0.12.9", - "flagd-proxy": "0.8.0", - "core": "0.12.1" + "flagd": "0.15.7", + "flagd-proxy": "0.9.6", + "core": "0.15.8" } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8c7d80bbf..bc8822145 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,2 +1,2 @@ -FROM squidfunk/mkdocs-material:9.5 +FROM squidfunk/mkdocs-material:9.7.1 RUN pip install mkdocs-include-markdown-plugin diff --git a/Makefile b/Makefile index c4a210aec..0d8bad056 100644 --- a/Makefile +++ b/Makefile @@ -52,8 +52,9 @@ flagd-benchmark-test: flagd-integration-test-harness: # target used to start a locally built flagd with the e2e flags cd flagd; go run main.go start -f file:../test-harness/flags/testing-flags.json -f file:../test-harness/flags/custom-ops.json -f file:../test-harness/flags/evaluator-refs.json -f file:../test-harness/flags/zero-flags.json -f file:../test-harness/flags/edge-case-flags.json -flagd-integration-test: # dependent on flagd-e2e-test-harness if not running in github actions - go test -count=1 -cover ./test/integration $(ARGS) +flagd-integration-test: workspace-clean +# this is a intentionally an "orphaned" module so that it effectively does e2e testing independently of the rest of the code + cd test/integration && go test -count=1 -cover $(ARGS) run: # default to flagd make run-flagd run-flagd: @@ -73,10 +74,10 @@ uninstall: rm /etc/systemd/system/flagd.service rm -f $(DESTDIR)$(PREFIX)/bin/flagd lint: - go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.2.1 + go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2 $(foreach module, $(ALL_GO_MOD_DIRS), ${GOPATH}/bin/golangci-lint run $(module)/...;) lint-fix: - go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.2.1 + go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2 $(foreach module, $(ALL_GO_MOD_DIRS), ${GOPATH}/bin/golangci-lint run --fix $(module)/...;) install-mockgen: go install go.uber.org/mock/mockgen@v0.4.0 diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 7fdbed322..94b257546 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -1,5 +1,191 @@ # Changelog +## [0.15.8](https://github.com/open-feature/flagd/compare/core/v0.15.7...core/v0.15.8) (2026-05-29) + + +### ๐Ÿ› Bug Fixes + +* **sync:** panic on s3 URI with query string ([#1974](https://github.com/open-feature/flagd/issues/1974)) ([82040ff](https://github.com/open-feature/flagd/commit/82040ff47a33e914e0c4a91a9db321cc1e9510a4)) + +## [0.15.7](https://github.com/open-feature/flagd/compare/core/v0.15.6...core/v0.15.7) (2026-05-28) + + +### โœจ New Features + +* **sync:** forward S3 URI query parameters to the bucket URL ([#1971](https://github.com/open-feature/flagd/issues/1971)) ([7e253f9](https://github.com/open-feature/flagd/commit/7e253f9211bc8fbbb1d665bacb0d3c0a77283680)), closes [#1961](https://github.com/open-feature/flagd/issues/1961) + +## [0.15.6](https://github.com/open-feature/flagd/compare/core/v0.15.5...core/v0.15.6) (2026-05-21) + + +### โœจ New Features + +* add custom headers support for sync providers ([#1960](https://github.com/open-feature/flagd/issues/1960)) ([65d3796](https://github.com/open-feature/flagd/commit/65d37965db5fe287552e2376ce35b63cfcbb2cdf)) + +## [0.15.5](https://github.com/open-feature/flagd/compare/core/v0.15.4...core/v0.15.5) (2026-04-30) + + +### ๐Ÿ› Bug Fixes + +* handle missing/null targeting keys in fractional evaluator ([#1949](https://github.com/open-feature/flagd/issues/1949)) ([651c7bb](https://github.com/open-feature/flagd/commit/651c7bb814eb70f72414ce164e1d2560e6055526)) +* override otel service name and version ([#1956](https://github.com/open-feature/flagd/issues/1956)) ([ec4ff12](https://github.com/open-feature/flagd/commit/ec4ff12e3f8dd37b61d6c7852a1f7dd2a8572d3a)) +* update jsonlogic for and/or bug ([#1957](https://github.com/open-feature/flagd/issues/1957)) ([6edd6e8](https://github.com/open-feature/flagd/commit/6edd6e83e56d7407dc925afe39deae795487dd8c)) +* various custom operator conformance fixes ([#1950](https://github.com/open-feature/flagd/issues/1950)) ([670c91c](https://github.com/open-feature/flagd/commit/670c91cdca80c29fd1cee378d1ea228c4ef36935)) + + +### ๐Ÿงน Chore + +* resolve open dependabot security alerts ([#1954](https://github.com/open-feature/flagd/issues/1954)) ([c5adbb7](https://github.com/open-feature/flagd/commit/c5adbb7e9aefc16dfb69852a3d5f67b4473d4305)) + +## [0.15.4](https://github.com/open-feature/flagd/compare/core/v0.15.3...core/v0.15.4) (2026-04-15) + + +### โœจ New Features + +* add intervalSeed source option ([#1945](https://github.com/open-feature/flagd/issues/1945)) ([7c501eb](https://github.com/open-feature/flagd/commit/7c501eb862c0eeb8eb27129cb0e6edd45125e699)) + +## [0.15.3](https://github.com/open-feature/flagd/compare/core/v0.15.2...core/v0.15.3) (2026-04-14) + + +### ๐Ÿ› Bug Fixes + +* allow single entry in fractional operator ([#1935](https://github.com/open-feature/flagd/issues/1935)) ([5fa86c6](https://github.com/open-feature/flagd/commit/5fa86c6a4b7fcb8f8b7ffcc696f889d2ebf33b1f)) +* web and cli docs do not mention s3 ([#1941](https://github.com/open-feature/flagd/issues/1941)) ([49ff1cf](https://github.com/open-feature/flagd/commit/49ff1cfe2d5543feead69d363dc63ea18c718bc0)) + + +### โœจ New Features + +* metadata support in the kubernetes_sync ([#1905](https://github.com/open-feature/flagd/issues/1905)) ([f8173a4](https://github.com/open-feature/flagd/commit/f8173a4c974128df1b423ff3fff0fb9409f7dcda)) + +## [0.15.2](https://github.com/open-feature/flagd/compare/core/v0.15.1...core/v0.15.2) (2026-04-09) + + +### ๐Ÿ› Bug Fixes + +* **security:** update vulnerability-updates [security] ([#1933](https://github.com/open-feature/flagd/issues/1933)) ([04338dc](https://github.com/open-feature/flagd/commit/04338dc21358b80f96da7a5ac736107f08093d60)) +* **security:** update vulnerability-updates [security] ([#1934](https://github.com/open-feature/flagd/issues/1934)) ([40d444a](https://github.com/open-feature/flagd/commit/40d444abac6b0a40a1b5190c2205540eaaaa0b55)) + + +### โœจ New Features + +* gRPC sync experimental incremental updates ([#1922](https://github.com/open-feature/flagd/issues/1922)) ([d785557](https://github.com/open-feature/flagd/commit/d785557d2df6b89c9b86e886b6b923991dd44696)) + +## [0.15.1](https://github.com/open-feature/flagd/compare/core/v0.15.0...core/v0.15.1) (2026-04-07) + + +### ๐Ÿ› Bug Fixes + +* mem leak due to unbounded metrics cardinality ([#1931](https://github.com/open-feature/flagd/issues/1931)) ([176866e](https://github.com/open-feature/flagd/commit/176866e71625bee9ef7770700d8ce14e8abd8110)) +* **security:** update module github.com/go-jose/go-jose/v4 to v4.1.4 [security] ([#1929](https://github.com/open-feature/flagd/issues/1929)) ([cf22a11](https://github.com/open-feature/flagd/commit/cf22a110652af6f3ef867c17b9c6ea9471c9e5f1)) + +## [0.15.0](https://github.com/open-feature/flagd/compare/core/v0.14.3...core/v0.15.0) (2026-04-01) + + +### โš  BREAKING CHANGES + +* fractional bucketing improvements ([#1909](https://github.com/open-feature/flagd/issues/1909)) + +### โœจ New Features + +* fractional bucketing improvements ([#1909](https://github.com/open-feature/flagd/issues/1909)) ([7190878](https://github.com/open-feature/flagd/commit/7190878fd0ea7a6f16fd8fbcdac68b55d9b9a2a5)) + +## [0.14.3](https://github.com/open-feature/flagd/compare/core/v0.14.2...core/v0.14.3) (2026-03-27) + + +### โœจ New Features + +* gRPC sync endpoint metrics ([#1861](https://github.com/open-feature/flagd/issues/1861)) ([b04dc50](https://github.com/open-feature/flagd/commit/b04dc5074a5be239914c4328653623aad36203ac)) + +## [0.14.2](https://github.com/open-feature/flagd/compare/core/v0.14.1...core/v0.14.2) (2026-03-21) + + +### ๐Ÿ› Bug Fixes + +* **security:** update module google.golang.org/grpc to v1.79.3 [security] ([#1907](https://github.com/open-feature/flagd/issues/1907)) ([ad51d4e](https://github.com/open-feature/flagd/commit/ad51d4e8fe0570474c824273983f54b3ca38b083)) + +## [0.14.1](https://github.com/open-feature/flagd/compare/core/v0.14.0...core/v0.14.1) (2026-03-09) + + +### ๐Ÿ› Bug Fixes + +* **security:** update otel deps, minimum core Go version ([#1897](https://github.com/open-feature/flagd/issues/1897)) ([6b79bf8](https://github.com/open-feature/flagd/commit/6b79bf8419da1e269ecb1d1db03760379fc201cb)) + + +### โœจ New Features + +* make max header and body size configurable, add default ([#1892](https://github.com/open-feature/flagd/issues/1892)) ([25c5fd7](https://github.com/open-feature/flagd/commit/25c5fd7e80c26eb2c00b20317b2456fe6f927ea3)) + +## [0.14.0](https://github.com/open-feature/flagd/compare/core/v0.13.3...core/v0.14.0) (2026-03-04) + + +### โš  BREAKING CHANGES + +* no `defaultVariant` -> code default (previosuly FLAG_NOT_FOUND) ([#1862](https://github.com/open-feature/flagd/issues/1862)) + +### โœจ New Features + +* no `defaultVariant` -> code default (previosuly FLAG_NOT_FOUND) ([#1862](https://github.com/open-feature/flagd/issues/1862)) ([89117d8](https://github.com/open-feature/flagd/commit/89117d8eaba0e9d205b3b47544528c42d5698176)) + +## [0.13.3](https://github.com/open-feature/flagd/compare/core/v0.13.2...core/v0.13.3) (2026-02-09) + + +### ๐Ÿ› Bug Fixes + +* correct parameter order in histogram bucket configuration :warning: ([#1859](https://github.com/open-feature/flagd/issues/1859)) ([335af32](https://github.com/open-feature/flagd/commit/335af32b6f1087d624b77ffb7b50dea612ef234f)) +* Enhance error logs in store's Watch func ([#1865](https://github.com/open-feature/flagd/issues/1865)) ([a58a707](https://github.com/open-feature/flagd/commit/a58a7076ac4aef66a10dee7a40aa2ee4b53c7169)) + +## [0.13.2](https://github.com/open-feature/flagd/compare/core/v0.13.1...core/v0.13.2) (2026-01-09) + + +### ๐Ÿ”™ Reverts + +* use go 1.24 in go.mod for core package ([#1844](https://github.com/open-feature/flagd/issues/1844)) ([c92a159](https://github.com/open-feature/flagd/commit/c92a159251e08ed39aa7c1dae42995e00c3186ac)) + +## [0.13.1](https://github.com/open-feature/flagd/compare/core/v0.13.0...core/v0.13.1) (2025-12-27) + + +### ๐Ÿ› Bug Fixes + +* **security:** update go for various stdlib CVEs ([#1840](https://github.com/open-feature/flagd/issues/1840)) ([6dcb36d](https://github.com/open-feature/flagd/commit/6dcb36d2d6b55b7fe0b6107ac9a25baf302c5cdc)) + +## [0.13.0](https://github.com/open-feature/flagd/compare/core/v0.12.1...core/v0.13.0) (2025-12-23) + + +### โš  BREAKING CHANGES + +* enable parsing of array flag configurations for flagd ([#1797](https://github.com/open-feature/flagd/issues/1797)) +* cleanup evaluator interface ([#1793](https://github.com/open-feature/flagd/issues/1793)) +* removes the `fractionalEvaluation` operator since it has been replaced with `fractional`. ([#1704](https://github.com/open-feature/flagd/issues/1704)) + +### ๐Ÿ› Bug Fixes + +* **security:** update module github.com/go-viper/mapstructure/v2 to v2.4.0 [security] ([#1784](https://github.com/open-feature/flagd/issues/1784)) ([037e30b](https://github.com/open-feature/flagd/commit/037e30b2f87897499580b25497049b88da7e386c)) +* **security:** update module golang.org/x/crypto to v0.45.0 [security] ([#1825](https://github.com/open-feature/flagd/issues/1825)) ([44edcc9](https://github.com/open-feature/flagd/commit/44edcc97e9fc11af721527cc3d30ab491ddea44e)) +* **security:** update module golang.org/x/crypto to v0.45.0 [security] ([#1826](https://github.com/open-feature/flagd/issues/1826)) ([7e0762b](https://github.com/open-feature/flagd/commit/7e0762b921ea70bed7915bcaab50e450e0a51158)) + + +### โœจ New Features + +* Add OAuth support for HTTP Sync ([#1791](https://github.com/open-feature/flagd/issues/1791)) ([268fd75](https://github.com/open-feature/flagd/commit/268fd75039588f285913bf100d9972d26c2003a6)) +* Add OTEL default variables ([#1812](https://github.com/open-feature/flagd/issues/1812)) ([c2e3fc6](https://github.com/open-feature/flagd/commit/c2e3fc62e06faf870db74e1a26b141075e6fbaa4)) +* allow null flagSetId Selector, restrict Selector to single key-value-pairs ([#1708](https://github.com/open-feature/flagd/issues/1708)) ([#1811](https://github.com/open-feature/flagd/issues/1811)) ([c12a0ae](https://github.com/open-feature/flagd/commit/c12a0ae01e2991a8365192a5cebf8cc11ff8bcd1)) +* change jsonschema parser ([#1794](https://github.com/open-feature/flagd/issues/1794)) ([bf3f722](https://github.com/open-feature/flagd/commit/bf3f7220227428715422ea9f2311e6bd5f46ed97)) +* cleanup evaluator interface ([#1793](https://github.com/open-feature/flagd/issues/1793)) ([aa504f7](https://github.com/open-feature/flagd/commit/aa504f7077093746f886248a4766d9ae5587bf3d)) +* enable parsing of array flag configurations for flagd ([#1797](https://github.com/open-feature/flagd/issues/1797)) ([97c6ffa](https://github.com/open-feature/flagd/commit/97c6ffaf2b51765ccd6aaec38c2902ed2ac8f5f3)) +* multi-project support via selectors and flagSetId namespacing ([#1702](https://github.com/open-feature/flagd/issues/1702)) ([f9ce46f](https://github.com/open-feature/flagd/commit/f9ce46f1032e7cb423e0e5c75a7c02f91ab5a88f)) + + +### ๐Ÿงน Chore + +* **refactor:** use memdb for flag storage ([#1697](https://github.com/open-feature/flagd/issues/1697)) ([5c5c1cf](https://github.com/open-feature/flagd/commit/5c5c1cfe84890c4cdd74c9b82504fd2632965221)) +* removes the `fractionalEvaluation` operator since it has been replaced with `fractional`. ([#1704](https://github.com/open-feature/flagd/issues/1704)) ([3228ad8](https://github.com/open-feature/flagd/commit/3228ad895117ed179325f80d3b0b318f575a4584)) + + +### ๐Ÿ”„ Refactoring + +* remove deprecated bearerToken option ([#1816](https://github.com/open-feature/flagd/issues/1816)) ([efda06a](https://github.com/open-feature/flagd/commit/efda06aa6d4cd7472a7f2f64fe69b7ce8d9fcbd1)) +* removed unused Selector from Flag and Store. ([#1747](https://github.com/open-feature/flagd/issues/1747)) ([1083005](https://github.com/open-feature/flagd/commit/108300529241de7221f4f143c60ecd62991b5c63)) +* store cleanup ([#1705](https://github.com/open-feature/flagd/issues/1705)) ([bcff8d7](https://github.com/open-feature/flagd/commit/bcff8d757b6d0ca69bccee26ba41880bdf2b5040)) + ## [0.12.1](https://github.com/open-feature/flagd/compare/core/v0.12.0...core/v0.12.1) (2025-07-28) diff --git a/core/go.mod b/core/go.mod index 353135e4c..b00169b54 100644 --- a/core/go.mod +++ b/core/go.mod @@ -1,56 +1,52 @@ module github.com/open-feature/flagd/core -go 1.24.0 - -toolchain go1.24.4 +go 1.25.0 require ( - buf.build/gen/go/open-feature/flagd/grpc/go v1.5.1-20250529171031-ebdc14163473.2 - buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.6-20250529171031-ebdc14163473.1 - connectrpc.com/connect v1.18.1 + buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1 + buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1 + connectrpc.com/connect v1.19.1 connectrpc.com/otelconnect v0.7.2 - github.com/diegoholiveira/jsonlogic/v3 v3.8.4 + github.com/diegoholiveira/jsonlogic/v3 v3.9.1 github.com/fsnotify/fsnotify v1.9.0 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-memdb v1.3.5 github.com/open-feature/flagd-schemas v0.2.13 - github.com/open-feature/open-feature-operator/apis v0.2.45 - github.com/prometheus/client_golang v1.22.0 - github.com/robfig/cron v1.2.0 + github.com/open-feature/open-feature-operator/api v0.2.47 + github.com/prometheus/client_golang v1.23.2 + github.com/robfig/cron/v3 v3.0.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/twmb/murmur3 v1.1.8 - go.opentelemetry.io/otel v1.37.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 - go.opentelemetry.io/otel/exporters/prometheus v0.59.0 - go.opentelemetry.io/otel/metric v1.37.0 - go.opentelemetry.io/otel/sdk v1.37.0 - go.opentelemetry.io/otel/sdk/metric v1.37.0 - go.opentelemetry.io/otel/trace v1.37.0 + go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/exporters/prometheus v0.64.0 + go.opentelemetry.io/otel/metric v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 go.uber.org/mock v0.5.2 go.uber.org/zap v1.27.0 gocloud.dev v0.42.0 - golang.org/x/crypto v0.45.0 + golang.org/x/crypto v0.49.0 golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac - golang.org/x/mod v0.29.0 - golang.org/x/oauth2 v0.30.0 - golang.org/x/sync v0.18.0 - google.golang.org/grpc v1.73.0 - google.golang.org/protobuf v1.36.6 + golang.org/x/mod v0.33.0 + golang.org/x/oauth2 v0.35.0 + golang.org/x/sync v0.20.0 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.33.2 k8s.io/client-go v0.33.2 ) require ( - cel.dev/expr v0.23.0 // indirect + cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.121.1 // indirect cloud.google.com/go/auth v0.16.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect cloud.google.com/go/storage v1.55.0 // indirect @@ -61,56 +57,54 @@ require ( github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect github.com/aws/aws-sdk-go v1.55.6 // indirect - github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/config v1.29.12 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.65 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.25.2 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 // indirect - github.com/aws/smithy-go v1.22.3 // indirect - github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cenkalti/backoff/v5 v5.0.2 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.0 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect - github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect - github.com/evanphx/json-patch/v5 v5.9.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/wire v0.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-uuid v1.0.2 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect @@ -127,43 +121,52 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.65.0 // indirect - github.com/prometheus/procfs v0.16.1 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/x448/float16 v0.8.4 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect - github.com/zeebo/errs v1.4.0 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect + go.opentelemetry.io/otel/log v0.19.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/api v0.235.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/api v0.33.2 // indirect - k8s.io/apiextensions-apiserver v0.31.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect - k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect - sigs.k8s.io/controller-runtime v0.19.3 // indirect + k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect + sigs.k8s.io/controller-runtime v0.20.1 // indirect sigs.k8s.io/gateway-api v1.2.1 // indirect - sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect diff --git a/core/go.sum b/core/go.sum index 432563c9b..50c2bce23 100644 --- a/core/go.sum +++ b/core/go.sum @@ -1,9 +1,9 @@ -buf.build/gen/go/open-feature/flagd/grpc/go v1.5.1-20250529171031-ebdc14163473.2 h1:TZ+7u106u7C7lgNctxG03ABliF46eLhcIZG5Mdo67/E= -buf.build/gen/go/open-feature/flagd/grpc/go v1.5.1-20250529171031-ebdc14163473.2/go.mod h1:4u0WLwfkLob3dC/F8qNctqhtiEv2Mlyi8YgCDDzgYDs= -buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.6-20250529171031-ebdc14163473.1 h1:LdC4xAuUaNdduzQr5VvhjsgrCfpW9IYxYsjyCF0ANs0= -buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.6-20250529171031-ebdc14163473.1/go.mod h1:cCQ49+ttXE2MZ/ciRNb0tCG+F3kj2ZVbP+0/psbhrLY= -cel.dev/expr v0.23.0 h1:wUb94w6OYQS4uXraxo9U+wUAs9jT47Xvl4iPgAwM2ss= -cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1 h1:Vw1UTeqrKDQMasR9eSOh7JsA3Ii1dov0lPMPFwW16gg= +buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1/go.mod h1:uCFRckBTXlZTJczpxd0j8qhQfLIWT8ds/3PlURS54wI= +buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1 h1:vzILwV5p1s2kk4FuaaYNqKPSdivPqyaDsjtQi2qSRuc= +buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1/go.mod h1:itSRQViN+Mq9URSJbXJRlAT9irP54/x5n5sHn9NTKrU= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.121.1 h1:S3kTQSydxmu1JfLRLpKtxRPA7rSrYPRPEUmL/PavVUw= cloud.google.com/go v0.121.1/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw= @@ -11,8 +11,8 @@ cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU= cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= 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.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +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.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= @@ -25,8 +25,8 @@ cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2zn cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY= cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= -connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= -connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= connectrpc.com/otelconnect v0.7.2 h1:WlnwFzaW64dN06JXU+hREPUGeEzpz3Acz2ACOmN8cMI= connectrpc.com/otelconnect v0.7.2/go.mod h1:JS7XUKfuJs2adhCnXhNHPHLz6oAaZniCJdSF00OZSew= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo= @@ -50,8 +50,8 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mo github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk= @@ -60,10 +60,10 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk= github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= -github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.29.12 h1:Y/2a+jLPrPbHpFkpAAYkVEtJmxORlXoo5k2g1fa2sUo= github.com/aws/aws-sdk-go-v2/config v1.29.12/go.mod h1:xse1YTjmORlb/6fhkWi8qJh3cvZi4JoVNhc+NbJt4kI= github.com/aws/aws-sdk-go-v2/credentials v1.17.65 h1:q+nV2yYegofO/SUXruT+pn4KxkxmaQ++1B/QedcKBFM= @@ -72,53 +72,51 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mln github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69 h1:6VFPH/Zi9xYFMJKPQOX5URYkQoXRWeJ7V/7Y6ZDYoms= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69/go.mod h1:GJj8mmO6YT6EqgduWocwhMoxTLFitkhIrK+owzrYL2I= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 h1:lguz0bmOoGzozP9XfRJR1QIayEYo+2vP/No3OfLF0pU= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 h1:jIiopHEV22b4yQP2q36Y0OmwLbsxNWdWwfZRR5QRRO4= -github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2/go.mod h1:U5SNqwhXB3Xe6F47kXvWihPl/ilGaEDe8HD/50Z9wxc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/sso v1.25.2 h1:pdgODsAhGo4dvzC3JAG5Ce0PX8kWXrTZGx+jxADD+5E= github.com/aws/aws-sdk-go-v2/service/sso v1.25.2/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0 h1:90uX0veLKcdHVfvxhkWUQSCi5VabtwMLFutYiRke4oo= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 h1:PZV5W8yk4OtH1JAuhV2PXwwO9v5G5Aoj+eMCn4T+1Kc= github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= -github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= -github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= -github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= -github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= -github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/diegoholiveira/jsonlogic/v3 v3.8.4 h1:IVVU/VLz2hn10ImbmibjiUkdVsSFIB1vfDaOVsaipH4= -github.com/diegoholiveira/jsonlogic/v3 v3.8.4/go.mod h1:OYRb6FSTVmMM+MNQ7ElmMsczyNSepw+OU4Z8emDSi4w= +github.com/diegoholiveira/jsonlogic/v3 v3.9.1 h1:BMZ4DxiZyIHVxoip29bc9alMg4cBvZ0lDzBC+/osOtQ= +github.com/diegoholiveira/jsonlogic/v3 v3.9.1/go.mod h1:807lvTWhwOX6yHwkr42unn3VpC2eMLkvT/WsGGqWDfE= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/emicklei/go-restful/v3 v3.12.0 h1:y2DdzBAURM29NFF94q6RaY4vjIH1rtwDapwQtU84iWk= @@ -126,34 +124,28 @@ github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRr 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.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +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/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= -github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= -github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= -github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= -github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -164,8 +156,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -198,12 +190,10 @@ github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6 github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20250125003558-7fdb3d7e6fa0 h1:my2ucqBZmv+cWHIhZNSIYKzgN8EBGyHdC7zD5sASRAg= +github.com/google/pprof v0.0.0-20250125003558-7fdb3d7e6fa0/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= @@ -216,8 +206,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo= @@ -263,8 +253,8 @@ github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/open-feature/flagd-schemas v0.2.13 h1:LzoyQfirfpR8cxI4PKnoFRtpwPjpC/cOO8N0n8dpbRc= github.com/open-feature/flagd-schemas v0.2.13/go.mod h1:C0jnJ4C3j2LzGuqKgLDdTsdfKEWQp6sOHZyxu3QohFU= -github.com/open-feature/open-feature-operator/apis v0.2.45 h1:URnUf22ZoAx7/W8ek8dXCBYgY8FmnFEuEOSDLROQafY= -github.com/open-feature/open-feature-operator/apis v0.2.45/go.mod h1:PYh/Hfyna1lZYZUeu/8LM0qh0ZgpH7kKEXRLYaaRhGs= +github.com/open-feature/open-feature-operator/api v0.2.47 h1:Q8g3Ks63J+AreouX0pn+YMLfoWuQoWfmBb28VCPCxAE= +github.com/open-feature/open-feature-operator/api v0.2.47/go.mod h1:Y3jZiRdhJu7V96VH8jXuV19yHE/02468NWWtX/ehmf0= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -274,27 +264,29 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 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/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= -github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= -github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= -github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= 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= @@ -304,56 +296,69 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= -github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= 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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= -go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= +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/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 h1:zG8GlgXCJQd5BU98C0hZnBbElszTmUgCNCfYneaDL0A= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0/go.mod h1:hOfBCz8kv/wuq73Mx2H2QnWokh/kHZxkh6SNF2bdKtw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= -go.opentelemetry.io/otel/exporters/prometheus v0.59.0 h1:HHf+wKS6o5++XZhS98wvILrLVgHxjA/AMjqHKes+uzo= -go.opentelemetry.io/otel/exporters/prometheus v0.59.0/go.mod h1:R8GpRXTZrqvXHDEGVH5bF6+JqAZcK8PjJcZ5nGhEWiE= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= -go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= @@ -362,6 +367,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= gocloud.dev v0.42.0 h1:qzG+9ItUL3RPB62/Amugws28n+4vGZXEoJEAMfjutzw= gocloud.dev v0.42.0/go.mod h1:zkaYAapZfQisXOA4bzhsbA4ckiStGQ3Psvs9/OQ5dPM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -370,10 +377,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= @@ -386,10 +391,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= 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-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -405,13 +408,11 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= 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.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.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= @@ -421,10 +422,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -438,20 +437,16 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -459,10 +454,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -477,16 +470,16 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.235.0 h1:C3MkpQSRxS1Jy6AkzTGKKrpSCOd2WOGrezZ+icKSkKo= google.golang.org/api v0.235.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= @@ -496,17 +489,17 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98 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-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= 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.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= 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= @@ -516,8 +509,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -535,8 +528,6 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= -k8s.io/apiextensions-apiserver v0.31.1 h1:L+hwULvXx+nvTYX/MKM3kKMZyei+UiSXQWciX/N6E40= -k8s.io/apiextensions-apiserver v0.31.1/go.mod h1:tWMPR3sgW+jsl2xm9v7lAyRF1rYEK71i9G5dRtkknoQ= k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= @@ -545,14 +536,14 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/controller-runtime v0.19.3 h1:XO2GvC9OPftRst6xWCpTgBZO04S2cbp0Qqkj8bX1sPw= -sigs.k8s.io/controller-runtime v0.19.3/go.mod h1:j4j87DqtsThvwTv5/Tc5NFRyyF/RF0ip4+62tbTSIUM= +k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= +k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.20.1 h1:JbGMAG/X94NeM3xvjenVUaBjy6Ui4Ogd/J5ZtjZnHaE= +sigs.k8s.io/controller-runtime v0.20.1/go.mod h1:BrP3w158MwvB3ZbNpaAcIKkHQ7YGpYnzpoSTZ8E14WU= sigs.k8s.io/gateway-api v1.2.1 h1:fZZ/+RyRb+Y5tGkwxFKuYuSRQHu9dZtbjenblleOLHM= sigs.k8s.io/gateway-api v1.2.1/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= diff --git a/core/pkg/evaluator/fractional.go b/core/pkg/evaluator/fractional.go index dd3f7f855..5d3973789 100644 --- a/core/pkg/evaluator/fractional.go +++ b/core/pkg/evaluator/fractional.go @@ -9,6 +9,8 @@ import ( "github.com/twmb/murmur3" ) +const maxWeightSum = math.MaxInt32 // 2,147,483,647 + const FractionEvaluationName = "fractional" type Fractional struct { @@ -16,16 +18,18 @@ type Fractional struct { } type fractionalEvaluationDistribution struct { - totalWeight int + totalWeight int32 weightedVariants []fractionalEvaluationVariant + data any + logger *logger.Logger } type fractionalEvaluationVariant struct { - variant string - weight int + variant any // string, bool, number or nil + weight int32 } -func (v fractionalEvaluationVariant) getPercentage(totalWeight int) float64 { +func (v fractionalEvaluationVariant) getPercentage(totalWeight int32) float64 { if totalWeight == 0 { return 0 } @@ -38,22 +42,27 @@ func NewFractional(logger *logger.Logger) *Fractional { } func (fe *Fractional) Evaluate(values, data any) any { - valueToDistribute, feDistributions, err := parseFractionalEvaluationData(values, data) + valueToDistribute, feDistributions, err := parseFractionalEvaluationData(values, data, fe.Logger) if err != nil { fe.Logger.Warn(fmt.Sprintf("parse fractional evaluation data: %v", err)) return nil } - return distributeValue(valueToDistribute, feDistributions) + if feDistributions == nil { + return nil + } + + hashValue := uint32(murmur3.StringSum32(valueToDistribute)) + return distributeValue(hashValue, feDistributions) } -func parseFractionalEvaluationData(values, data any) (string, *fractionalEvaluationDistribution, error) { +func parseFractionalEvaluationData(values, data any, logger *logger.Logger) (string, *fractionalEvaluationDistribution, error) { valuesArray, ok := values.([]any) if !ok { return "", nil, errors.New("fractional evaluation data is not an array") } - if len(valuesArray) < 2 { - return "", nil, errors.New("fractional evaluation data has length under 2") + if len(valuesArray) < 1 { + return "", nil, errors.New("fractional evaluation data must contain at least one distribution") } dataMap, ok := data.(map[string]any) @@ -61,9 +70,8 @@ func parseFractionalEvaluationData(values, data any) (string, *fractionalEvaluat return "", nil, errors.New("data isn't of type map[string]any") } - // Ignore the error as we can't really do anything if the properties are - // somehow missing. properties, _ := getFlagdProperties(dataMap) + flagKey := properties.FlagKey bucketBy, ok := valuesArray[0].(string) if ok { @@ -74,15 +82,22 @@ func parseFractionalEvaluationData(values, data any) (string, *fractionalEvaluat valuesArray = valuesArray[1:] } + if dataMap[targetingKeyKey] == nil { + return "", nil, nil + } targetingKey, ok := dataMap[targetingKeyKey].(string) if !ok { - return "", nil, errors.New("bucketing value not supplied and no targetingKey in context") + return "", nil, fmt.Errorf("flag %q: bucketing value not supplied and no targetingKey in context", flagKey) + } + + if targetingKey == "" { + return "", nil, nil } bucketBy = fmt.Sprintf("%s%s", properties.FlagKey, targetingKey) } - feDistributions, err := parseFractionalEvaluationDistributions(valuesArray) + feDistributions, err := parseFractionalEvaluationDistributions(valuesArray, data, logger, flagKey) if err != nil { return "", nil, err } @@ -90,59 +105,109 @@ func parseFractionalEvaluationData(values, data any) (string, *fractionalEvaluat return bucketBy, feDistributions, nil } -func parseFractionalEvaluationDistributions(values []any) (*fractionalEvaluationDistribution, error) { +func parseFractionalEvaluationDistributions(values []any, data any, logger *logger.Logger, flagKey string) (*fractionalEvaluationDistribution, error) { feDistributions := &fractionalEvaluationDistribution{ totalWeight: 0, weightedVariants: make([]fractionalEvaluationVariant, len(values)), + data: data, + logger: logger, } + + // parse all weights first to validate the sum + var totalWeightInt64 int64 = 0 + for i := 0; i < len(values); i++ { distributionArray, ok := values[i].([]any) if !ok { - return nil, errors.New("distribution elements aren't of type []any. " + - "please check your rule in flag definition") + return nil, fmt.Errorf("flag %q: distribution elements aren't of type []any. "+ + "please check your rule in flag definition", flagKey) } if len(distributionArray) == 0 { - return nil, errors.New("distribution element needs at least one element") + return nil, fmt.Errorf("flag %q: distribution element needs at least one element", flagKey) } - variant, ok := distributionArray[0].(string) - if !ok { - return nil, errors.New("first element of distribution element isn't string") + // JSONLogic pre-evaluates all arguments before they reach fractional. + // Pre-evaluated operators become primitive values (strings, numbers, etc.), never map[string]any nodes. + var variant any + switch v := distributionArray[0].(type) { + case string: + variant = v + case bool: + variant = v + case float64: + variant = v + case nil: + variant = nil + default: + return nil, fmt.Errorf("flag %q: first element of distribution element must be a string, bool, number, or nil", flagKey) } - weight := 1.0 + weight := int64(1) if len(distributionArray) >= 2 { + // parse as float64 first since that's what JSON gives us distributionWeight, ok := distributionArray[1].(float64) + if !ok && distributionArray[1] != nil { + return nil, fmt.Errorf("flag %q: weight must be a number", flagKey) + } if ok { - // default the weight to 1 if not specified explicitly - weight = distributionWeight + weight = int64(distributionWeight) } } - feDistributions.totalWeight += int(weight) + // validate weight is a whole number + if len(distributionArray) >= 2 { + distributionWeight, ok := distributionArray[1].(float64) + if ok && distributionWeight != float64(int64(distributionWeight)) { + return nil, fmt.Errorf("flag %q: weights must be integers", flagKey) + } + } + + // validate individual weight doesn't exceed int32 + if weight > math.MaxInt32 { + return nil, fmt.Errorf("flag %q: weight %d exceeds maximum allowed value %d", flagKey, weight, math.MaxInt32) + } + + // clamp negative weights to 0 + if weight < 0 { + // negative weights can be the result of rollout calculations, so we log and clamp to 0 rather than returning an error + logger.Debug(fmt.Sprintf("flag %q: negative weight %d clamped to 0", flagKey, weight)) + weight = 0 + } + + totalWeightInt64 += weight feDistributions.weightedVariants[i] = fractionalEvaluationVariant{ variant: variant, - weight: int(weight), + weight: int32(weight), } } + // validate total weight doesn't exceed MaxInt32 + if totalWeightInt64 > int64(maxWeightSum) { + return nil, fmt.Errorf("flag %q: sum of all weights (%d) exceeds maximum allowed value (%d)", flagKey, totalWeightInt64, maxWeightSum) + } + + feDistributions.totalWeight = int32(totalWeightInt64) return feDistributions, nil } -// distributeValue calculate hash for given hash key and find the bucket distributions belongs to -func distributeValue(value string, feDistribution *fractionalEvaluationDistribution) string { - hashValue := int32(murmur3.StringSum32(value)) - hashRatio := math.Abs(float64(hashValue)) / math.MaxInt32 - bucket := hashRatio * 100 // in range [0, 100] +// distributeValue accepts a pre-computed 32-bit hash value and distributes it to a variant using high-precision integer arithmetic. +// It maps a 32-bit hash to the range [0, totalWeight) and finds the variant bucket that contains that value. +func distributeValue(hashValue uint32, feDistribution *fractionalEvaluationDistribution) any { + if feDistribution.totalWeight == 0 { + return nil + } + + bucket := (uint64(hashValue) * uint64(feDistribution.totalWeight)) >> 32 - rangeEnd := float64(0) - for _, weightedVariant := range feDistribution.weightedVariants { - rangeEnd += weightedVariant.getPercentage(feDistribution.totalWeight) + var rangeEnd uint64 = 0 + for _, variant := range feDistribution.weightedVariants { + rangeEnd += uint64(variant.weight) if bucket < rangeEnd { - return weightedVariant.variant + return variant.variant } } - return "" + // unreachable given validation + return nil } diff --git a/core/pkg/evaluator/fractional_test.go b/core/pkg/evaluator/fractional_test.go index c1dfb9a38..789a626d4 100644 --- a/core/pkg/evaluator/fractional_test.go +++ b/core/pkg/evaluator/fractional_test.go @@ -10,17 +10,61 @@ import ( "github.com/stretchr/testify/assert" ) +// Test constants +const ( + emailField = "email" + localeField = "locale" + tierField = "tier" + targetingKeyField = "targetingKey" + + rachelEmail = "rachel@faas.com" + monicaEmail = "monica@faas.com" + joeyEmail = "joey@faas.com" + rossEmail = "ross@faas.com" + testAEmail = "test_a@faas.com" + testBEmail = "test_b@faas.com" + testCEmail = "test_c@faas.com" + testDEmail = "test_d@faas.com" + test4Email = "test4@faas.com" + fooEmail = "foo@foo.com" + + usLocale = "us" + caLocale = "ca" + premiumTier = "premium" + + redVariant = "red" + blueVariant = "blue" + greenVariant = "green" + yellowVariant = "yellow" + + redHex = "#FF0000" + blueHex = "#0000FF" + greenHex = "#00FF00" + yellowHex = "#FFFF00" +) + +// setupEvaluator creates and initializes a JSON evaluator with the given flags +func setupEvaluator(source string, flags []model.Flag) (*JSON, error) { + log := logger.NewLogger(nil, false) + s, err := store.NewStore(log, []string{source}) + if err != nil { + return nil, err + } + je := NewJSON(log, s) + je.store.Update(source, flags, model.Metadata{}, false) + return je, nil +} + func TestFractionalEvaluation(t *testing.T) { const source = "testSource" - var sources = []string{source} ctx := context.Background() commonFlags := []model.Flag{ { Key: "headerColor", State: "ENABLED", - DefaultVariant: "red", - Variants: colorVariants, + DefaultVariant: redVariant, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -55,8 +99,8 @@ func TestFractionalEvaluation(t *testing.T) { { Key: "customSeededHeaderColor", State: "ENABLED", - DefaultVariant: "red", - Variants: colorVariants, + DefaultVariant: redVariant, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -87,44 +131,44 @@ func TestFractionalEvaluation(t *testing.T) { expectedReason string expectedErrorCode string }{ - "rachel@faas.com": { + rachelEmail: { flags: commonFlags, flagKey: "headerColor", context: map[string]any{ - "email": "rachel@faas.com", + emailField: rachelEmail, }, - expectedVariant: "yellow", - expectedValue: "#FFFF00", + expectedVariant: blueVariant, + expectedValue: blueHex, expectedReason: model.TargetingMatchReason, }, - "monica@faas.com": { + monicaEmail: { flags: commonFlags, flagKey: "headerColor", context: map[string]any{ - "email": "monica@faas.com", + emailField: monicaEmail, }, - expectedVariant: "blue", - expectedValue: "#0000FF", + expectedVariant: yellowVariant, + expectedValue: yellowHex, expectedReason: model.TargetingMatchReason, }, - "joey@faas.com": { + joeyEmail: { flags: commonFlags, flagKey: "headerColor", context: map[string]any{ - "email": "joey@faas.com", + emailField: joeyEmail, }, - expectedVariant: "red", - expectedValue: "#FF0000", + expectedVariant: redVariant, + expectedValue: redHex, expectedReason: model.TargetingMatchReason, }, - "ross@faas.com": { + rossEmail: { flags: commonFlags, flagKey: "headerColor", context: map[string]any{ - "email": "ross@faas.com", + emailField: rossEmail, }, - expectedVariant: "green", - expectedValue: "#00FF00", + expectedVariant: blueVariant, + expectedValue: blueHex, expectedReason: model.TargetingMatchReason, }, "rachel@faas.com with custom seed": { @@ -133,8 +177,8 @@ func TestFractionalEvaluation(t *testing.T) { context: map[string]any{ "email": "rachel@faas.com", }, - expectedVariant: "green", - expectedValue: "#00FF00", + expectedVariant: greenVariant, + expectedValue: greenHex, expectedReason: model.TargetingMatchReason, }, "monica@faas.com with custom seed": { @@ -143,8 +187,8 @@ func TestFractionalEvaluation(t *testing.T) { context: map[string]any{ "email": "monica@faas.com", }, - expectedVariant: "red", - expectedValue: "#FF0000", + expectedVariant: redVariant, + expectedValue: redHex, expectedReason: model.TargetingMatchReason, }, "joey@faas.com with custom seed": { @@ -153,8 +197,8 @@ func TestFractionalEvaluation(t *testing.T) { context: map[string]any{ "email": "joey@faas.com", }, - expectedVariant: "green", - expectedValue: "#00FF00", + expectedVariant: blueVariant, + expectedValue: blueHex, expectedReason: model.TargetingMatchReason, }, "ross@faas.com with custom seed": { @@ -163,16 +207,16 @@ func TestFractionalEvaluation(t *testing.T) { context: map[string]any{ "email": "ross@faas.com", }, - expectedVariant: "green", - expectedValue: "#00FF00", + expectedVariant: greenVariant, + expectedValue: greenHex, expectedReason: model.TargetingMatchReason, }, "ross@faas.com with different flag key": { flags: []model.Flag{{ Key: "footerColor", State: "ENABLED", - DefaultVariant: "red", - Variants: colorVariants, + DefaultVariant: redVariant, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -209,16 +253,16 @@ func TestFractionalEvaluation(t *testing.T) { context: map[string]any{ "email": "ross@faas.com", }, - expectedVariant: "red", - expectedValue: "#FF0000", + expectedVariant: redVariant, + expectedValue: redHex, expectedReason: model.TargetingMatchReason, }, "non even split": { flags: []model.Flag{{ Key: "headerColor", State: "ENABLED", - DefaultVariant: "red", - Variants: colorVariants, + DefaultVariant: redVariant, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -251,16 +295,16 @@ func TestFractionalEvaluation(t *testing.T) { context: map[string]any{ "email": "test4@faas.com", }, - expectedVariant: "red", - expectedValue: "#FF0000", + expectedVariant: greenVariant, + expectedValue: greenHex, expectedReason: model.TargetingMatchReason, }, "fallback to default variant if no email provided": { flags: []model.Flag{{ Key: "headerColor", State: "ENABLED", - DefaultVariant: "red", - Variants: colorVariants, + DefaultVariant: redVariant, + Variants: colorVariants, Targeting: []byte(`{ "fractional": [ {"var": "email"}, @@ -286,16 +330,16 @@ func TestFractionalEvaluation(t *testing.T) { }, flagKey: "headerColor", context: map[string]any{}, - expectedVariant: "red", - expectedValue: "#FF0000", + expectedVariant: redVariant, + expectedValue: redHex, expectedReason: model.DefaultReason, }, "get variant for non-percentage weight values": { flags: []model.Flag{{ Key: "headerColor", State: "ENABLED", - DefaultVariant: "red", - Variants: colorVariants, + DefaultVariant: redVariant, + Variants: colorVariants, Targeting: []byte(`{ "fractional": [ {"var": "email"}, @@ -315,16 +359,16 @@ func TestFractionalEvaluation(t *testing.T) { context: map[string]any{ "email": "foo@foo.com", }, - expectedVariant: "red", - expectedValue: "#FF0000", + expectedVariant: blueVariant, + expectedValue: blueHex, expectedReason: model.TargetingMatchReason, }, "get variant for non-specified weight values": { flags: []model.Flag{{ Key: "headerColor", State: "ENABLED", - DefaultVariant: "red", - Variants: colorVariants, + DefaultVariant: redVariant, + Variants: colorVariants, Targeting: []byte(`{ "fractional": [ {"var": "email"}, @@ -342,16 +386,16 @@ func TestFractionalEvaluation(t *testing.T) { context: map[string]any{ "email": "foo@foo.com", }, - expectedVariant: "red", - expectedValue: "#FF0000", + expectedVariant: blueVariant, + expectedValue: blueHex, expectedReason: model.TargetingMatchReason, }, "default to targetingKey if no bucket key provided": { flags: []model.Flag{{ Key: "headerColor", State: "ENABLED", - DefaultVariant: "red", - Variants: colorVariants, + DefaultVariant: redVariant, + Variants: colorVariants, Targeting: []byte(`{ "fractional": [ [ @@ -370,16 +414,16 @@ func TestFractionalEvaluation(t *testing.T) { context: map[string]any{ "targetingKey": "foo@foo.com", }, - expectedVariant: "blue", - expectedValue: "#0000FF", + expectedVariant: greenVariant, + expectedValue: greenHex, expectedReason: model.TargetingMatchReason, }, "missing email - parser should ignore nil/missing custom variables and continue": { flags: []model.Flag{{ Key: "headerColor", State: "ENABLED", - DefaultVariant: "red", - Variants: colorVariants, + DefaultVariant: redVariant, + Variants: colorVariants, Targeting: []byte( `{ "fractional": [ @@ -394,23 +438,141 @@ func TestFractionalEvaluation(t *testing.T) { context: map[string]any{ "targetingKey": "foo@foo.com", }, - expectedVariant: "red", - expectedValue: "#FF0000", + expectedVariant: blueVariant, + expectedValue: blueHex, + expectedReason: model.TargetingMatchReason, + }, + "null targetingKey returns default variant": { + flags: []model.Flag{{ + Key: "headerColor", + State: "ENABLED", + DefaultVariant: redVariant, + Variants: colorVariants, + Targeting: []byte(`{ + "fractional": [ + ["blue", 50], + ["green", 50] + ] + }`), + }}, + flagKey: "headerColor", + context: map[string]any{ + "targetingKey": nil, + }, + expectedVariant: redVariant, + expectedValue: redHex, + expectedReason: model.DefaultReason, + }, + "missing targetingKey returns default variant": { + flags: []model.Flag{{ + Key: "headerColor", + State: "ENABLED", + DefaultVariant: redVariant, + Variants: colorVariants, + Targeting: []byte(`{ + "fractional": [ + ["blue", 50], + ["green", 50] + ] + }`), + }}, + flagKey: "headerColor", + context: map[string]any{}, + expectedVariant: redVariant, + expectedValue: redHex, + expectedReason: model.DefaultReason, + }, + "empty targetingKey returns default variant": { + flags: []model.Flag{{ + Key: "headerColor", + State: "ENABLED", + DefaultVariant: redVariant, + Variants: colorVariants, + Targeting: []byte(`{ + "fractional": [ + ["blue", 50], + ["green", 50] + ] + }`), + }}, + flagKey: "headerColor", + context: map[string]any{ + "targetingKey": "", + }, + expectedVariant: redVariant, + expectedValue: redHex, + expectedReason: model.DefaultReason, + }, + "single-entry always returns the sole variant": { + flags: []model.Flag{{ + Key: "headerColor", + State: "ENABLED", + DefaultVariant: redVariant, + Variants: colorVariants, + Targeting: []byte(`{ + "fractional": [ + ["blue", 1] + ] + }`), + }}, + flagKey: "headerColor", + context: map[string]any{ + "targetingKey": "any-user", + }, + expectedVariant: blueVariant, + expectedValue: blueHex, + expectedReason: model.TargetingMatchReason, + }, + "single-entry with explicit bucket-by always returns the sole variant": { + flags: []model.Flag{{ + Key: "headerColor", + State: "ENABLED", + DefaultVariant: redVariant, + Variants: colorVariants, + Targeting: []byte(`{ + "fractional": [ + {"var": "email"}, + ["green", 100] + ] + }`), + }}, + flagKey: "headerColor", + context: map[string]any{ + "email": "any@user.com", + }, + expectedVariant: greenVariant, + expectedValue: greenHex, + expectedReason: model.TargetingMatchReason, + }, + "single-entry shorthand without weight always returns the sole variant": { + flags: []model.Flag{{ + Key: "headerColor", + State: "ENABLED", + DefaultVariant: redVariant, + Variants: colorVariants, + Targeting: []byte(`{ + "fractional": [ + ["yellow"] + ] + }`), + }}, + flagKey: "headerColor", + context: map[string]any{ + "targetingKey": "any-user", + }, + expectedVariant: yellowVariant, + expectedValue: yellowHex, expectedReason: model.TargetingMatchReason, }, } const reqID = "default" for name, tt := range tests { t.Run(name, func(t *testing.T) { - log := logger.NewLogger(nil, false) - s, err := store.NewStore(log, sources) + je, err := setupEvaluator(source, tt.flags) if err != nil { - t.Fatalf("NewStore failed: %v", err) + t.Fatalf("setupEvaluator failed: %v", err) } - je := NewJSON(log, s) - je.store.Update(source, tt.flags, model.Metadata{}) - value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant) if value != tt.expectedValue { @@ -437,14 +599,13 @@ func TestFractionalEvaluation(t *testing.T) { func BenchmarkFractionalEvaluation(b *testing.B) { const source = "testSource" - var sources = []string{source} ctx := context.Background() flags := []model.Flag{{ Key: "headerColor", State: "ENABLED", - DefaultVariant: "red", - Variants: colorVariants, + DefaultVariant: redVariant, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -487,57 +648,54 @@ func BenchmarkFractionalEvaluation(b *testing.B) { expectedReason string expectedErrorCode string }{ - "test_a@faas.com": { + testAEmail: { flags: flags, flagKey: "headerColor", context: map[string]any{ - "email": "test_a@faas.com", + emailField: testAEmail, }, - expectedVariant: "blue", - expectedValue: "#0000FF", + expectedVariant: blueVariant, + expectedValue: blueHex, expectedReason: model.TargetingMatchReason, }, - "test_b@faas.com": { + testBEmail: { flags: flags, flagKey: "headerColor", context: map[string]any{ - "email": "test_b@faas.com", + emailField: testBEmail, }, - expectedVariant: "red", - expectedValue: "#FF0000", + expectedVariant: redVariant, + expectedValue: redHex, expectedReason: model.TargetingMatchReason, }, - "test_c@faas.com": { + testCEmail: { flags: flags, flagKey: "headerColor", context: map[string]any{ - "email": "test_c@faas.com", + emailField: testCEmail, }, - expectedVariant: "green", - expectedValue: "#00FF00", + expectedVariant: greenVariant, + expectedValue: greenHex, expectedReason: model.TargetingMatchReason, }, - "test_d@faas.com": { + testDEmail: { flags: flags, flagKey: "headerColor", context: map[string]any{ - "email": "test_d@faas.com", + emailField: testDEmail, }, - expectedVariant: "blue", - expectedValue: "#0000FF", + expectedVariant: blueVariant, + expectedValue: blueHex, expectedReason: model.TargetingMatchReason, }, } reqID := "test" for name, tt := range tests { b.Run(name, func(b *testing.B) { - log := logger.NewLogger(nil, false) - s, err := store.NewStore(log, sources) + je, err := setupEvaluator(source, tt.flags) if err != nil { - b.Fatalf("NewStore failed: %v", err) + b.Fatalf("setupEvaluator failed: %v", err) } - je := NewJSON(log, s) - je.store.Update(source, tt.flags, model.Metadata{}) for i := 0; i < b.N; i++ { value, variant, reason, _, err := resolve[string]( @@ -569,10 +727,10 @@ func BenchmarkFractionalEvaluation(b *testing.B) { func Test_fractionalEvaluationVariant_getPercentage(t *testing.T) { type fields struct { variant string - weight int + weight int32 } type args struct { - totalWeight int + totalWeight int32 } tests := []struct { name string @@ -611,3 +769,361 @@ func Test_fractionalEvaluationVariant_getPercentage(t *testing.T) { }) } } + +func TestFractionalEvaluationNegativeClamping(t *testing.T) { + ctx := context.Background() + flagKey := "clampedWeightFlag" + + evalContext := map[string]any{ + targetingKeyField: "some-targeting-key", + } + + commonFlags := []model.Flag{ + { + Key: flagKey, + State: "ENABLED", + DefaultVariant: blueVariant, + Variants: colorVariants, + Targeting: []byte(`{ + "fractional": [ + [ + "red", + -1000 + ], + [ + "green", + 1 + ] + ] + }`), + }, + } + + je, err := setupEvaluator("testSource", commonFlags) + if err != nil { + t.Fatalf("setupEvaluator failed: %v", err) + } + + value, variant, reason, _, err := resolve[string](ctx, "default", flagKey, evalContext, je.evaluateVariant) + assert.Equal(t, greenVariant, variant) + assert.Equal(t, greenHex, value) + assert.Equal(t, model.TargetingMatchReason, reason) + assert.NoError(t, err) +} + +func TestFractionalEvaluationWithNestedJSONLogic(t *testing.T) { + const source = "testSource" + ctx := context.Background() + + commonFlags := []model.Flag{ + { + Key: "nestedIfVariant", + State: "ENABLED", + DefaultVariant: redVariant, + Variants: colorVariants, + Targeting: []byte(`{ + "fractional": [ + "email", + [ + { + "if": [ + {"in": ["us", {"var": "locale"}]}, + "red", + "blue" + ] + }, + 25 + ], + [ + "green", + 75 + ] + ] + }`), + }, + { + Key: "nestedFractional", + State: "ENABLED", + DefaultVariant: redVariant, + Variants: colorVariants, + Targeting: []byte(`{ + "fractional": [ + "email", + [ + { + "fractional": [ + "tier", + ["red", 25], + ["blue", 25], + ["green", 25], + ["yellow", 25] + ] + }, + 25 + ], + [ + "green", + 75 + ] + ] + }`), + }, + { + Key: "dynamicWeights", + State: "ENABLED", + DefaultVariant: redVariant, + Variants: colorVariants, + Targeting: []byte(`{ + "fractional": [ + "email", + ["red", {"var": "redWeight"}], + ["blue", {"var": "blueWeight"}], + ["green", {"var": "greenWeight"}] + ] + }`), + }, + } + + tests := map[string]struct { + flags []model.Flag + flagKey string + context map[string]any + expectedVariant string + expectedValue string + expectedReason string + expectedErrorCode string + validVariants []string // for tests where exact variant depends on hash + }{ + "nested if - us locale in second bucket returns static variant": { + flags: commonFlags, + flagKey: "nestedIfVariant", + context: map[string]any{ + emailField: rachelEmail, + localeField: usLocale, + }, + expectedVariant: greenVariant, + expectedValue: greenHex, + expectedReason: model.TargetingMatchReason, + }, + "nested if - non-us locale in second bucket returns static value": { + flags: commonFlags, + flagKey: "nestedIfVariant", + context: map[string]any{ + emailField: rachelEmail, + localeField: caLocale, + }, + expectedVariant: greenVariant, + expectedValue: greenHex, + expectedReason: model.TargetingMatchReason, + }, + "nested fractional in second bucket returns one of variants": { + flags: commonFlags, + flagKey: "nestedFractional", + context: map[string]any{ + emailField: rachelEmail, + tierField: premiumTier, + }, + expectedVariant: greenVariant, + expectedValue: greenHex, + expectedReason: model.TargetingMatchReason, + }, + "weights computed from context variables": { + flags: commonFlags, + flagKey: "dynamicWeights", + context: map[string]any{ + emailField: testAEmail, + "redWeight": float64(0), + "blueWeight": float64(1), + "greenWeight": float64(0), + }, + expectedVariant: blueVariant, + expectedValue: blueHex, + expectedReason: model.TargetingMatchReason, + }, + "weights computed: red": { + flags: commonFlags, + flagKey: "dynamicWeights", + context: map[string]any{ + emailField: testAEmail, + "redWeight": float64(1), + "blueWeight": float64(0), + "greenWeight": float64(0), + }, + expectedVariant: redVariant, + expectedValue: redHex, + expectedReason: model.TargetingMatchReason, + }, + "weights computed: green": { + flags: commonFlags, + flagKey: "dynamicWeights", + context: map[string]any{ + emailField: testAEmail, + "redWeight": float64(0), + "blueWeight": float64(0), + "greenWeight": float64(1), + }, + expectedVariant: greenVariant, + expectedValue: greenHex, + expectedReason: model.TargetingMatchReason, + }, + } + const reqID = "default" + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + je, err := setupEvaluator(source, tt.flags) + if err != nil { + t.Fatalf("setupEvaluator failed: %v", err) + } + + value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant) + + if tt.expectedVariant != "" && variant != tt.expectedVariant { + t.Errorf("expected variant '%s', got '%s'", tt.expectedVariant, variant) + } + + // check valid variants if specified (for hash-dependent tests) + if len(tt.validVariants) > 0 { + valid := false + for _, v := range tt.validVariants { + if variant == v { + valid = true + break + } + } + if !valid { + t.Errorf("expected variant to be one of %v, got '%s'", tt.validVariants, variant) + } + } + + if tt.expectedValue != "" && value != tt.expectedValue { + t.Errorf("expected value '%s', got '%s'", tt.expectedValue, value) + } + + if reason != tt.expectedReason { + t.Errorf("expected reason '%s', got '%s'", tt.expectedReason, reason) + } + + if err != nil { + errorCode := err.Error() + if errorCode != tt.expectedErrorCode { + t.Errorf("expected err '%v', got '%v'", tt.expectedErrorCode, err) + } + } + }) + } +} + +func TestFractionalVariantBoolNumericAndOperators(t *testing.T) { + log := logger.NewLogger(nil, false) + fractional := NewFractional(log) + + tests := []struct { + name string + values any + data any + expected any + expectedOptions []any // for hash-dependent or operators variants + }{ + { + name: "bool variant true", + values: []any{ + "user123", + []any{true, float64(50)}, + []any{false, float64(50)}, + }, + data: map[string]any{ + flagdPropertiesKey: map[string]any{ + "flagKey": "test", + "timestamp": int64(0), + }, + }, + expectedOptions: []any{true, false}, + }, + { + name: "numeric variant 0", + values: []any{ + "user789", + []any{float64(0), float64(33)}, + []any{float64(1), float64(33)}, + []any{float64(2), float64(34)}, + }, + data: map[string]any{ + flagdPropertiesKey: map[string]any{ + "flagKey": "test", + "timestamp": int64(0), + }, + }, + expectedOptions: []any{float64(0), float64(1), float64(2)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := fractional.Evaluate(tt.values, tt.data) + + // Check if result is one of the expected options + found := false + for _, v := range tt.expectedOptions { + if result == v { + found = true + break + } + } + assert.True(t, found, "expected one of %v, got %v", tt.expectedOptions, result) + }) + } +} + +func TestFractionalEvaluation_ErrorFallbackWhenUsedDirectly(t *testing.T) { + const source = "testSource" + ctx := context.Background() + + tests := map[string]struct { + targeting string + context map[string]any + }{ + "missing bucket key falls back": { + targeting: `{ + "fractional": [ + {"var": "missing_key"}, + ["one", 50], + ["two", 50] + ] + }`, + context: map[string]any{}, + }, + "all zero weights fall back": { + targeting: `{ + "fractional": [ + {"var": "targetingKey"}, + ["one", 0], + ["two", 0] + ] + }`, + context: map[string]any{"targetingKey": "any-user"}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + je, err := setupEvaluator(source, []model.Flag{{ + Key: "fractional-op-error-fallback", + State: "ENABLED", + DefaultVariant: "fallback", + Variants: map[string]any{ + "one": "one", + "two": "two", + "fallback": "fallback", + }, + Targeting: []byte(tt.targeting), + }}) + assert.NoError(t, err) + + value, variant, reason, _, err := resolve[string](ctx, "default", "fractional-op-error-fallback", tt.context, je.evaluateVariant) + assert.NoError(t, err) + assert.Equal(t, "fallback", value) + assert.Equal(t, "fallback", variant) + assert.Equal(t, model.DefaultReason, reason) + }) + } +} diff --git a/core/pkg/evaluator/json.go b/core/pkg/evaluator/json.go index 5849bb5f3..bb3cdc223 100644 --- a/core/pkg/evaluator/json.go +++ b/core/pkg/evaluator/json.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "regexp" "strings" "time" @@ -32,14 +31,9 @@ const ( // evaluation if the user did not supply the optional bucketing property. targetingKeyKey = "targetingKey" Disabled = "DISABLED" + ProtoVersionKey = "__flagd.protoVersion__" // used to mark if the request is coming from an older proto source, which has different fallback behavior ) -var regBrace *regexp.Regexp - -func init() { - regBrace = regexp.MustCompile("^[^{]*{|}[^}]*$") -} - func addSchemaResource(compiler *jsonschema.Compiler, url string, schemaData string) error { unmarshalJSON, err := jsonschema.UnmarshalJSON(strings.NewReader(schemaData)) if err != nil { @@ -136,7 +130,7 @@ func (je *JSON) SetState(payload sync.DataSync) error { return err } - je.store.Update(payload.Source, definition.Flags, definition.Metadata) + je.store.Update(payload.Source, definition.Flags, definition.Metadata, payload.IncrementalUpdates) return nil } @@ -196,6 +190,13 @@ func (je *Resolver) ResolveAllValues(ctx context.Context, reqID string, context value, variant, reason, metadata, err = resolve[float64](ctx, reqID, flag.Key, context, je.evaluateVariant) case map[string]any: value, variant, reason, metadata, err = resolve[map[string]any](ctx, reqID, flag.Key, context, je.evaluateVariant) + default: + if ctx.Value(ProtoVersionKey) == nil { + value, variant, reason, metadata, err = resolve[interface{}](ctx, reqID, flag.Key, context, je.evaluateVariant) + } else { + // old proto version behavior + continue + } } if err != nil { je.Logger.ErrorWithID(reqID, fmt.Sprintf("bulk evaluation: key: %s returned error: %s", flag.Key, err.Error())) @@ -307,6 +308,11 @@ func resolve[T constraints](ctx context.Context, reqID string, key string, conte return value, variant, reason, metadata, err } + if reason == model.FallbackReason { + var zero T + return zero, variant, model.FallbackReason, metadata, nil + } + var ok bool value, ok = variants[variant].(T) if !ok { @@ -380,7 +386,12 @@ func (je *Resolver) evaluateVariant(ctx context.Context, reqID string, flagKey s if trimmed == "null" { if flag.DefaultVariant == "" { - return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.FlagNotFoundErrorCode) + if ctx.Value(ProtoVersionKey) != nil { + // old proto version behavior + return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.FlagNotFoundErrorCode) + } + + return "", flag.Variants, model.FallbackReason, metadata, nil } return flag.DefaultVariant, flag.Variants, model.DefaultReason, metadata, nil @@ -399,7 +410,11 @@ func (je *Resolver) evaluateVariant(ctx context.Context, reqID string, flagKey s } if flag.DefaultVariant == "" { - return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.FlagNotFoundErrorCode) + if ctx.Value(ProtoVersionKey) != nil { + // old proto version behavior + return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.FlagNotFoundErrorCode) + } + return "", flag.Variants, model.FallbackReason, metadata, nil } return flag.DefaultVariant, flag.Variants, model.StaticReason, metadata, nil @@ -526,13 +541,18 @@ func transposeEvaluators(state string) (string, error) { return "", fmt.Errorf("unmarshal: %w", err) } - for evalName, evalRaw := range evaluators.Evaluators { - // replace any occurrences of "evaluator": "evalName" - regex, err := regexp.Compile(fmt.Sprintf(`"\$ref":(\s)*"%s"`, evalName)) - if err != nil { - return "", fmt.Errorf("compile regex: %w", err) - } + // round-trip to normalize whitespace so we can use plain string matching + var raw interface{} + if err := json.Unmarshal([]byte(state), &raw); err != nil { + return "", fmt.Errorf("normalize: %w", err) + } + normalizedBytes, err := json.Marshal(raw) + if err != nil { + return "", fmt.Errorf("normalize marshal: %w", err) + } + result := string(normalizedBytes) + for evalName, evalRaw := range evaluators.Evaluators { marshalledEval, err := evalRaw.MarshalJSON() if err != nil { return "", fmt.Errorf("marshal evaluator: %w", err) @@ -542,9 +562,10 @@ func transposeEvaluators(state string) (string, error) { if len(evalValue) < 3 { return "", errors.New("evaluator object is empty") } - evalValue = regBrace.ReplaceAllString(evalValue, "") - state = regex.ReplaceAllString(state, evalValue) + + refPattern := `{"$ref":"` + evalName + `"}` + result = strings.ReplaceAll(result, refPattern, evalValue) } - return state, nil + return result, nil } diff --git a/core/pkg/evaluator/json_test.go b/core/pkg/evaluator/json_test.go index 60880b12a..14993f0e0 100644 --- a/core/pkg/evaluator/json_test.go +++ b/core/pkg/evaluator/json_test.go @@ -5,10 +5,11 @@ import ( "context" "encoding/json" "fmt" - "github.com/stretchr/testify/require" "testing" "time" + "github.com/stretchr/testify/require" + flagdEvaluator "github.com/open-feature/flagd/core/pkg/evaluator" "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/model" @@ -927,10 +928,10 @@ func TestResolve_DefaultVariant(t *testing.T) { reason string errorCode string }{ - {NullDefault, ValidFlag, nil, model.ErrorReason, model.FlagNotFoundErrorCode}, - {UndefinedDefault, ValidFlag, nil, model.ErrorReason, model.FlagNotFoundErrorCode}, - {NullDefaultWithTargetting, ValidFlag, nil, model.ErrorReason, model.FlagNotFoundErrorCode}, - {UndefinedDefaultWithTargetting, ValidFlag, nil, model.ErrorReason, model.FlagNotFoundErrorCode}, + {NullDefault, ValidFlag, nil, model.FallbackReason, ""}, + {UndefinedDefault, ValidFlag, nil, model.FallbackReason, ""}, + {NullDefaultWithTargetting, ValidFlag, nil, model.FallbackReason, ""}, + {UndefinedDefaultWithTargetting, ValidFlag, nil, model.FallbackReason, ""}, } for _, test := range tests { @@ -944,8 +945,9 @@ func TestResolve_DefaultVariant(t *testing.T) { anyResult := evaluator.ResolveAsAnyValue(context.TODO(), "", test.flagKey, test.context) - assert.Equal(t, model.ErrorReason, anyResult.Reason) - assert.EqualError(t, anyResult.Error, test.errorCode) + assert.Equal(t, model.FallbackReason, anyResult.Reason) + // for code defaults, there should be no error + assert.NoError(t, anyResult.Error) }) } } @@ -1143,37 +1145,56 @@ func TestState_Evaluator(t *testing.T) { }, }, }, - "invalid evaluator json": { + "string-valued evaluator": { + // string-valued evaluators are valid; the string is substituted as-is (with quotes) inputState: ` { "flags": { - "fibAlgo": { - "variants": { - "recursive": "recursive", - "memo": "memo", - "loop": "loop", - "binet": "binet" - }, - "defaultVariant": "recursive", - "state": "ENABLED", - "metadata": { - "flagSetId": "flagSetId" - }, - "targeting": { - "if": [ - { - "$ref": "emailWithFaas" - }, "binet", null - ] - } - } + "fibAlgo": { + "variants": { + "recursive": "recursive", + "memo": "memo", + "loop": "loop", + "binet": "binet" + }, + "defaultVariant": "recursive", + "state": "ENABLED", + "metadata": { + "flagSetId": "flagSetId" + }, + "targeting": { + "if": [ + { + "$ref": "emailWithFaas" + }, "binet", null + ] + } + } + }, + "$evaluators": { + "emailWithFaas": "foo" + } + } + `, + expectedOutputState: map[string]model.Flag{ + "fibAlgo": { + Key: "fibAlgo", + Variants: map[string]any{ + "recursive": "recursive", + "memo": "memo", + "loop": "loop", + "binet": "binet", }, - "$evaluators": { - "emailWithFaas": "foo" - } - } - `, - expectedError: true, + DefaultVariant: "recursive", + State: "ENABLED", + Source: "testSource", + Targeting: json.RawMessage(`{"if":["foo","binet",null]}`), + Metadata: map[string]interface{}{ + "flagSetId": "flagSetId", + }, + FlagSetId: "flagSetId", + }, + }, }, "invalid targeting": { inputState: ` diff --git a/core/pkg/evaluator/semver.go b/core/pkg/evaluator/semver.go index 9d338a359..56327780d 100644 --- a/core/pkg/evaluator/semver.go +++ b/core/pkg/evaluator/semver.go @@ -85,12 +85,12 @@ func (je *SemVerComparison) SemVerEvaluation(values, _ interface{}) interface{} actualVersion, targetVersion, operator, err := parseSemverEvaluationData(values) if err != nil { je.Logger.Error(fmt.Sprintf("parse sem_ver evaluation data: %v", err)) - return false + return nil } res, err := operator.compare(actualVersion, targetVersion) if err != nil { je.Logger.Error(fmt.Sprintf("sem_ver evaluation: %v", err)) - return false + return nil } return res } @@ -105,7 +105,7 @@ func parseSemverEvaluationData(values interface{}) (string, string, SemVerOperat return "", "", "", errors.New("sem_ver evaluation must contain a value, an operator, and a comparison target") } - actualVersion, err := parseSemanticVersion(parsed[0]) + actualVersion, err := normalizeVersion(parsed[0]) if err != nil { return "", "", "", fmt.Errorf("sem_ver evaluation: could not parse target property value: %w", err) } @@ -115,7 +115,7 @@ func parseSemverEvaluationData(values interface{}) (string, string, SemVerOperat return "", "", "", fmt.Errorf("sem_ver evaluation: could not parse operator: %w", err) } - targetVersion, err := parseSemanticVersion(parsed[2]) + targetVersion, err := normalizeVersion(parsed[2]) if err != nil { return "", "", "", fmt.Errorf("sem_ver evaluation: could not parse target value: %w", err) } @@ -131,11 +131,13 @@ func ensureString(v interface{}) string { return fmt.Sprintf("%v", v) } -func parseSemanticVersion(v interface{}) (string, error) { +func normalizeVersion(v interface{}) (string, error) { version := ensureString(v) // version strings are only valid in the semver package if they start with a 'v' // if it's not present in the given value, we prepend it + // 'V' is normalized to 'v' if !strings.HasPrefix(version, "v") { + version = strings.TrimPrefix(version, "V") version = "v" + version } diff --git a/core/pkg/evaluator/semver_test.go b/core/pkg/evaluator/semver_test.go index 52f59a913..47b61b371 100644 --- a/core/pkg/evaluator/semver_test.go +++ b/core/pkg/evaluator/semver_test.go @@ -2,12 +2,9 @@ package evaluator import ( "context" - "errors" "testing" - "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/model" - "github.com/open-feature/flagd/core/pkg/store" "github.com/stretchr/testify/require" ) @@ -83,6 +80,16 @@ func TestSemVerOperator_Compare(t *testing.T) { want: true, wantErr: false, }, + { + name: "uppercase V prefix equals lowercase or no prefix", + svo: Equals, + args: args{ + v1: "V1.0.0", + v2: "1.0.0", + }, + want: true, + wantErr: false, + }, { name: "no prefixed v both", svo: Greater, @@ -320,15 +327,7 @@ func TestJSONEvaluator_semVerEvaluation(t *testing.T) { var sources = []string{source} ctx := context.Background() - tests := map[string]struct { - flags []model.Flag - flagKey string - context map[string]any - expectedValue string - expectedVariant string - expectedReason string - expectedError error - }{ + tests := map[string]stringFlagEvalTestCase{ "versions and operator provided - match": { flags: []model.Flag{{ Key: "headerColor", @@ -789,34 +788,27 @@ func TestJSONEvaluator_semVerEvaluation(t *testing.T) { }, } - const reqID = "default" - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - log := logger.NewLogger(nil, false) - s, err := store.NewStore(log, sources) - if err != nil { - t.Fatalf("NewStore failed: %v", err) - } - je := NewJSON(log, s) - je.store.Update(source, tt.flags, model.Metadata{}) - - value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant) - - if value != tt.expectedValue { - t.Errorf("expected value '%s', got '%s'", tt.expectedValue, value) - } - - if variant != tt.expectedVariant { - t.Errorf("expected variant '%s', got '%s'", tt.expectedVariant, variant) - } + runStringFlagEvalTests(t, ctx, source, sources, tests) +} - if reason != tt.expectedReason { - t.Errorf("expected reason '%s', got '%s'", tt.expectedReason, reason) - } +func TestSemVerEvaluation_ErrorFallbackWhenUsedDirectly(t *testing.T) { + const source = "testSource" + ctx := context.Background() - if !errors.Is(err, tt.expectedError) { - t.Errorf("expected err '%v', got '%v'", tt.expectedError, err) - } - }) + tests := map[string]errorFallbackTestCase{ + "invalid context version falls back": { + targeting: `{"sem_ver": [{"var": "version"}, "=", "1.0.0"]}`, + context: map[string]any{"version": "not-a-version"}, + }, + "invalid operator falls back": { + targeting: `{"sem_ver": [{"var": "version"}, "===", "1.0.0"]}`, + context: map[string]any{"version": "1.0.0"}, + }, + "wrong arg count falls back": { + targeting: `{"sem_ver": [{"var": "version"}, "="]}`, + context: map[string]any{"version": "1.0.0"}, + }, } + + runErrorFallbackTests(t, ctx, source, "semver-op-error-fallback", tests) } diff --git a/core/pkg/evaluator/string_comparison.go b/core/pkg/evaluator/string_comparison.go index d4a0d6b7c..9449b9430 100644 --- a/core/pkg/evaluator/string_comparison.go +++ b/core/pkg/evaluator/string_comparison.go @@ -72,7 +72,7 @@ func (sce *StringComparisonEvaluator) EndsWithEvaluation(values, _ interface{}) propertyValue, target, err := parseStringComparisonEvaluationData(values) if err != nil { sce.Logger.Error(fmt.Sprintf("parse ends_with evaluation data: %v", err)) - return false + return nil } return strings.HasSuffix(propertyValue, target) } diff --git a/core/pkg/evaluator/string_comparison_test.go b/core/pkg/evaluator/string_comparison_test.go index f22466f02..9049caf15 100644 --- a/core/pkg/evaluator/string_comparison_test.go +++ b/core/pkg/evaluator/string_comparison_test.go @@ -2,13 +2,10 @@ package evaluator import ( "context" - "errors" "fmt" "testing" - "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/model" - "github.com/open-feature/flagd/core/pkg/store" "github.com/stretchr/testify/assert" ) @@ -17,21 +14,13 @@ func TestJSONEvaluator_startsWithEvaluation(t *testing.T) { var sources = []string{source} ctx := context.Background() - tests := map[string]struct { - flags []model.Flag - flagKey string - context map[string]any - expectedValue string - expectedVariant string - expectedReason string - expectedError error - }{ + tests := map[string]stringFlagEvalTestCase{ "two strings provided - match": { flags: []model.Flag{{ Key: "headerColor", State: "ENABLED", DefaultVariant: "red", - Variants: colorVariants, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -55,7 +44,7 @@ func TestJSONEvaluator_startsWithEvaluation(t *testing.T) { Key: "headerColor", State: "ENABLED", DefaultVariant: "red", - Variants: colorVariants, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -79,7 +68,7 @@ func TestJSONEvaluator_startsWithEvaluation(t *testing.T) { Key: "headerColor", State: "ENABLED", DefaultVariant: "red", - Variants: colorVariants, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -103,7 +92,7 @@ func TestJSONEvaluator_startsWithEvaluation(t *testing.T) { Key: "headerColor", State: "ENABLED", DefaultVariant: "red", - Variants: colorVariants, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -127,7 +116,7 @@ func TestJSONEvaluator_startsWithEvaluation(t *testing.T) { Key: "headerColor", State: "ENABLED", DefaultVariant: "red", - Variants: colorVariants, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -148,36 +137,7 @@ func TestJSONEvaluator_startsWithEvaluation(t *testing.T) { }, } - const reqID = "default" - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - log := logger.NewLogger(nil, false) - s, err := store.NewStore(log, sources) - if err != nil { - t.Fatalf("NewStore failed: %v", err) - } - je := NewJSON(log, s) - je.store.Update(source, tt.flags, model.Metadata{}) - - value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant) - - if value != tt.expectedValue { - t.Errorf("expected value '%s', got '%s'", tt.expectedValue, value) - } - - if variant != tt.expectedVariant { - t.Errorf("expected variant '%s', got '%s'", tt.expectedVariant, variant) - } - - if reason != tt.expectedReason { - t.Errorf("expected reason '%s', got '%s'", tt.expectedReason, reason) - } - - if !errors.Is(err, tt.expectedError) { - t.Errorf("expected err '%v', got '%v'", tt.expectedError, err) - } - }) - } + runStringFlagEvalTests(t, ctx, source, sources, tests) } func TestJSONEvaluator_endsWithEvaluation(t *testing.T) { @@ -185,21 +145,13 @@ func TestJSONEvaluator_endsWithEvaluation(t *testing.T) { var sources = []string{source} ctx := context.Background() - tests := map[string]struct { - flags []model.Flag - flagKey string - context map[string]any - expectedValue string - expectedVariant string - expectedReason string - expectedError error - }{ + tests := map[string]stringFlagEvalTestCase{ "two strings provided - match": { flags: []model.Flag{{ Key: "headerColor", State: "ENABLED", DefaultVariant: "red", - Variants: colorVariants, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -223,7 +175,7 @@ func TestJSONEvaluator_endsWithEvaluation(t *testing.T) { Key: "headerColor", State: "ENABLED", DefaultVariant: "red", - Variants: colorVariants, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -247,7 +199,7 @@ func TestJSONEvaluator_endsWithEvaluation(t *testing.T) { Key: "headerColor", State: "ENABLED", DefaultVariant: "red", - Variants: colorVariants, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -271,7 +223,7 @@ func TestJSONEvaluator_endsWithEvaluation(t *testing.T) { Key: "headerColor", State: "ENABLED", DefaultVariant: "red", - Variants: colorVariants, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -295,7 +247,7 @@ func TestJSONEvaluator_endsWithEvaluation(t *testing.T) { Key: "headerColor", State: "ENABLED", DefaultVariant: "red", - Variants: colorVariants, + Variants: colorVariants, Targeting: []byte(`{ "if": [ { @@ -316,36 +268,7 @@ func TestJSONEvaluator_endsWithEvaluation(t *testing.T) { }, } - const reqID = "default" - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - log := logger.NewLogger(nil, false) - s, err := store.NewStore(log, sources) - if err != nil { - t.Fatalf("NewStore failed: %v", err) - } - je := NewJSON(log, s) - je.store.Update(source, tt.flags, model.Metadata{}) - - value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant) - - if value != tt.expectedValue { - t.Errorf("expected value '%s', got '%s'", tt.expectedValue, value) - } - - if variant != tt.expectedVariant { - t.Errorf("expected variant '%s', got '%s'", tt.expectedVariant, variant) - } - - if reason != tt.expectedReason { - t.Errorf("expected reason '%s', got '%s'", tt.expectedReason, reason) - } - - if err != tt.expectedError { - t.Errorf("expected err '%v', got '%v'", tt.expectedError, err) - } - }) - } + runStringFlagEvalTests(t, ctx, source, sources, tests) } func Test_parseStringComparisonEvaluationData(t *testing.T) { @@ -431,3 +354,21 @@ func Test_parseStringComparisonEvaluationData(t *testing.T) { }) } } + +func TestStringComparisonEvaluation_ErrorFallbackWhenUsedDirectly(t *testing.T) { + const source = "testSource" + ctx := context.Background() + + tests := map[string]errorFallbackTestCase{ + "starts_with invalid input falls back": { + targeting: `{"starts_with": [{"var": "num"}, "abc"]}`, + context: map[string]any{"num": 123.0}, + }, + "ends_with invalid input falls back": { + targeting: `{"ends_with": [{"var": "num"}, "xyz"]}`, + context: map[string]any{"num": 123.0}, + }, + } + + runErrorFallbackTests(t, ctx, source, "string-op-error-fallback", tests) +} diff --git a/core/pkg/evaluator/utils_test.go b/core/pkg/evaluator/utils_test.go index 3a9cde66d..d466d364a 100644 --- a/core/pkg/evaluator/utils_test.go +++ b/core/pkg/evaluator/utils_test.go @@ -1,8 +1,84 @@ package evaluator +import ( + "context" + "testing" + + "github.com/open-feature/flagd/core/pkg/logger" + "github.com/open-feature/flagd/core/pkg/model" + "github.com/open-feature/flagd/core/pkg/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + var colorVariants = map[string]any{ "red": "#FF0000", "blue": "#0000FF", "green": "#00FF00", "yellow": "#FFFF00", } + +type stringFlagEvalTestCase struct { + flags []model.Flag + flagKey string + context map[string]any + expectedValue string + expectedVariant string + expectedReason string + expectedError error +} + +func runStringFlagEvalTests(t *testing.T, ctx context.Context, source string, sources []string, tests map[string]stringFlagEvalTestCase) { + t.Helper() + const reqID = "default" + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + log := logger.NewLogger(nil, false) + s, err := store.NewStore(log, sources) + require.NoError(t, err) + je := NewJSON(log, s) + je.store.Update(source, tt.flags, model.Metadata{}, false) + + value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant) + + assert.Equal(t, tt.expectedValue, value) + assert.Equal(t, tt.expectedVariant, variant) + assert.Equal(t, tt.expectedReason, reason) + assert.ErrorIs(t, err, tt.expectedError) + }) + } +} + +type errorFallbackTestCase struct { + targeting string + context map[string]any +} + +func runErrorFallbackTests(t *testing.T, ctx context.Context, source, flagKey string, tests map[string]errorFallbackTestCase) { + t.Helper() + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + log := logger.NewLogger(nil, false) + s, err := store.NewStore(log, []string{source}) + require.NoError(t, err) + je := NewJSON(log, s) + je.store.Update(source, []model.Flag{{ + Key: flagKey, + State: "ENABLED", + DefaultVariant: "fallback", + Variants: map[string]any{ + "true": "true", + "false": "false", + "fallback": "fallback", + }, + Targeting: []byte(tt.targeting), + }}, model.Metadata{}, false) + + value, variant, reason, _, err := resolve[string](ctx, "default", flagKey, tt.context, je.evaluateVariant) + assert.NoError(t, err) + assert.Equal(t, "fallback", value) + assert.Equal(t, "fallback", variant) + assert.Equal(t, model.DefaultReason, reason) + }) + } +} diff --git a/core/pkg/model/reason.go b/core/pkg/model/reason.go index 3eef9f24e..96f19fe30 100644 --- a/core/pkg/model/reason.go +++ b/core/pkg/model/reason.go @@ -10,4 +10,7 @@ const ( UnknownReason = "UNKNOWN" ErrorReason = "ERROR" StaticReason = "STATIC" + // only used internally if no default value could be determined + // will be translated to DefaultReason in the API response + FallbackReason = "FALLBACK" ) diff --git a/core/pkg/service/iservice.go b/core/pkg/service/iservice.go index 97d8ec27f..52fe43660 100644 --- a/core/pkg/service/iservice.go +++ b/core/pkg/service/iservice.go @@ -36,6 +36,8 @@ type Configuration struct { ContextValues map[string]any HeaderToContextKeyMappings map[string]string StreamDeadline time.Duration + MaxRequestHeaderBytes int64 + MaxRequestBodyBytes int64 } /* diff --git a/core/pkg/service/ofrep/models.go b/core/pkg/service/ofrep/models.go index 4accb51b5..be08bf948 100644 --- a/core/pkg/service/ofrep/models.go +++ b/core/pkg/service/ofrep/models.go @@ -12,10 +12,10 @@ type Request struct { } type EvaluationSuccess struct { - Value interface{} `json:"value"` + Value interface{} `json:"value,omitempty"` Key string `json:"key"` Reason string `json:"reason"` - Variant string `json:"variant"` + Variant string `json:"variant,omitempty"` Metadata model.Metadata `json:"metadata"` } @@ -60,6 +60,16 @@ func BulkEvaluationResponseFrom(resolutions []evaluator.AnyValue, metadata model } func SuccessResponseFrom(result evaluator.AnyValue) EvaluationSuccess { + // if reason is fallback, we want to omit the value and variant from the response, and set reason to default + if result.Reason == model.FallbackReason { + return EvaluationSuccess{ + Value: nil, // not marshalled due to omitempty + Key: result.FlagKey, + Reason: model.DefaultReason, + Variant: "", // not marshalled due to omitempty + Metadata: result.Metadata, + } + } return EvaluationSuccess{ Value: result.Value, Key: result.FlagKey, diff --git a/core/pkg/service/ofrep/models_test.go b/core/pkg/service/ofrep/models_test.go index 947b8ded6..4d0b955b9 100644 --- a/core/pkg/service/ofrep/models_test.go +++ b/core/pkg/service/ofrep/models_test.go @@ -46,16 +46,86 @@ func TestSuccessResult(t *testing.T) { } } +func TestSuccessResultCodeDefault(t *testing.T) { + value := evaluator.AnyValue{ + Value: false, // zero value for boolean + Variant: "", // empty variant + Reason: model.FallbackReason, + FlagKey: "noDefaultFlag", + Metadata: map[string]interface{}{}, + } + + // when + evaluationSuccess := SuccessResponseFrom(value) + + // then verify the reason is converted to DEFAULT + if evaluationSuccess.Reason != model.DefaultReason { + t.Errorf("expected reason %v, got %v", model.DefaultReason, evaluationSuccess.Reason) + } + + if evaluationSuccess.Value != nil { + t.Errorf("expected nil value for code default, got %v", evaluationSuccess.Value) + } + + if evaluationSuccess.Variant != "" { + t.Errorf("expected empty variant for code default, got %v", evaluationSuccess.Variant) + } + + if evaluationSuccess.Key != value.FlagKey { + t.Errorf("expected key %v, got %v", value.FlagKey, evaluationSuccess.Key) + } + + // Verify JSON marshaling omits the value field + marshaled, err := json.Marshal(evaluationSuccess) + if err != nil { + t.Errorf("error marshalling: %v", err) + } + + // Check that "value" field is not in the JSON + if string(marshaled) == "" { + t.Errorf("marshalled output is empty") + } + + // Parse back to verify structure + var result map[string]interface{} + err = json.Unmarshal(marshaled, &result) + if err != nil { + t.Errorf("error unmarshalling: %v", err) + } + + if _, hasValue := result["value"]; hasValue { + t.Errorf("value field should be omitted for code defaults, but found in: %s", string(marshaled)) + } + + if _, hasVariant := result["variant"]; hasVariant { + t.Errorf("variant field should be omitted for code defaults, but found in: %s", string(marshaled)) + } + + if reason, ok := result["reason"].(string); !ok || reason != model.DefaultReason { + t.Errorf("reason should be DEFAULT, got: %v", result["reason"]) + } +} + func TestBulkEvaluationResponse(t *testing.T) { tests := []struct { - name string - input []evaluator.AnyValue - marshalledOutput string + name string + input []evaluator.AnyValue + verify func(*testing.T, []byte) }{ { - name: "empty input", - input: nil, - marshalledOutput: "{\"flags\":[],\"metadata\":{}}", + name: "empty input", + input: nil, + verify: func(t *testing.T, data []byte) { + var result BulkEvaluationResponse + err := json.Unmarshal(data, &result) + if err != nil { + t.Errorf("error unmarshalling: %v", err) + return + } + if len(result.Flags) != 0 { + t.Errorf("expected 0 flags, got %d", len(result.Flags)) + } + }, }, { name: "valid values", @@ -78,21 +148,167 @@ func TestBulkEvaluationResponse(t *testing.T) { Metadata: map[string]interface{}{}, }, }, - marshalledOutput: "{\"flags\":[{\"value\":false,\"key\":\"key\",\"reason\":\"STATIC\",\"variant\":\"false\",\"metadata\":{\"key\":\"value\"}},{\"key\":\"errorFlag\",\"errorCode\":\"FLAG_NOT_FOUND\",\"errorDetails\":\"flag `errorFlag` does not exist\",\"metadata\":{}}],\"metadata\":{}}", + verify: func(t *testing.T, data []byte) { + var result BulkEvaluationResponse + err := json.Unmarshal(data, &result) + if err != nil { + t.Errorf("error unmarshalling: %v", err) + return + } + if len(result.Flags) != 2 { + t.Errorf("expected 2 flags, got %d", len(result.Flags)) + return + } + + // Verify first flag (success case) + firstFlag, ok := result.Flags[0].(map[string]interface{}) + if !ok { + t.Errorf("first flag: expected map[string]interface{}, got %T", result.Flags[0]) + return + } + if firstFlag["key"] != "key" { + t.Errorf("first flag: expected key 'key', got '%v'", firstFlag["key"]) + } + if firstFlag["value"] != false { + t.Errorf("first flag: expected value false, got %v", firstFlag["value"]) + } + if firstFlag["variant"] != "false" { + t.Errorf("first flag: expected variant 'false', got '%v'", firstFlag["variant"]) + } + if firstFlag["reason"] != model.StaticReason { + t.Errorf("first flag: expected reason %v, got %v", model.StaticReason, firstFlag["reason"]) + } + metadata, ok := firstFlag["metadata"].(map[string]interface{}) + if !ok || metadata["key"] != "value" { + t.Errorf("first flag: expected metadata['key']='value', got %v", firstFlag["metadata"]) + } + + // Verify second flag (error case) + secondFlag, ok := result.Flags[1].(map[string]interface{}) + if !ok { + t.Errorf("second flag: expected map[string]interface{}, got %T", result.Flags[1]) + return + } + if secondFlag["key"] != "errorFlag" { + t.Errorf("second flag: expected key 'errorFlag', got '%v'", secondFlag["key"]) + } + if secondFlag["errorCode"] != model.FlagNotFoundErrorCode { + t.Errorf("second flag: expected errorCode %v, got %v", model.FlagNotFoundErrorCode, secondFlag["errorCode"]) + } + }, + }, + { + name: "mixed with code defaults", + input: []evaluator.AnyValue{ + { + Value: "on", + Variant: "on", + Reason: model.StaticReason, + FlagKey: "featureA", + Metadata: map[string]interface{}{ + "description": "feature A", + }, + }, + { + Value: false, // code default (no defaultVariant set) + Variant: "", + Reason: model.FallbackReason, + FlagKey: "featureNoDefault", + Metadata: map[string]interface{}{}, + }, + { + Value: 42, + Variant: "high", + Reason: model.TargetingMatchReason, + FlagKey: "priority", + Metadata: map[string]interface{}{ + "tier": "premium", + }, + }, + }, + verify: func(t *testing.T, data []byte) { + var result BulkEvaluationResponse + err := json.Unmarshal(data, &result) + if err != nil { + t.Errorf("error unmarshalling: %v", err) + return + } + if len(result.Flags) != 3 { + t.Errorf("expected 3 flags, got %d", len(result.Flags)) + return + } + + // Verify first flag (normal static evaluation) + firstFlag, ok := result.Flags[0].(map[string]interface{}) + if !ok { + t.Errorf("first flag: expected map[string]interface{}, got %T", result.Flags[0]) + return + } + if firstFlag["key"] != "featureA" { + t.Errorf("first flag: expected key 'featureA', got '%v'", firstFlag["key"]) + } + if firstFlag["value"] != "on" { + t.Errorf("first flag: expected value 'on', got %v", firstFlag["value"]) + } + if firstFlag["variant"] != "on" { + t.Errorf("first flag: expected variant 'on', got '%v'", firstFlag["variant"]) + } + if firstFlag["reason"] != model.StaticReason { + t.Errorf("first flag: expected reason %v, got %v", model.StaticReason, firstFlag["reason"]) + } + + // Verify second flag (code default, should omit value and variant fields) + secondFlag, ok := result.Flags[1].(map[string]interface{}) + if !ok { + t.Errorf("second flag: expected map[string]interface{}, got %T", result.Flags[1]) + return + } + if secondFlag["key"] != "featureNoDefault" { + t.Errorf("second flag: expected key 'featureNoDefault', got '%v'", secondFlag["key"]) + } + if secondFlag["reason"] != model.DefaultReason { + t.Errorf("second flag: expected reason %v, got %v", model.DefaultReason, secondFlag["reason"]) + } + if _, hasValue := secondFlag["value"]; hasValue { + t.Errorf("second flag: value field should be omitted for code defaults, but found: %v", secondFlag["value"]) + } + if _, hasVariant := secondFlag["variant"]; hasVariant { + t.Errorf("second flag: variant field should be omitted for code defaults, but found: %v", secondFlag["variant"]) + } + + // Verify third flag (targeting match evaluation) + thirdFlag, ok := result.Flags[2].(map[string]interface{}) + if !ok { + t.Errorf("third flag: expected map[string]interface{}, got %T", result.Flags[2]) + return + } + if thirdFlag["key"] != "priority" { + t.Errorf("third flag: expected key 'priority', got '%v'", thirdFlag["key"]) + } + if thirdFlag["value"] != float64(42) { + t.Errorf("third flag: expected value 42, got %v", thirdFlag["value"]) + } + if thirdFlag["variant"] != "high" { + t.Errorf("third flag: expected variant 'high', got '%v'", thirdFlag["variant"]) + } + if thirdFlag["reason"] != model.TargetingMatchReason { + t.Errorf("third flag: expected reason %v, got %v", model.TargetingMatchReason, thirdFlag["reason"]) + } + }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { response := BulkEvaluationResponseFrom(test.input, model.Metadata{}) - marshal, err := json.Marshal(response) if err != nil { t.Errorf("error marshalling the response: %v", err) + return } - if test.marshalledOutput != string(marshal) { - t.Errorf("expected %s, got %s", test.marshalledOutput, string(marshal)) + if test.verify != nil { + test.verify(t, marshal) } }) } @@ -151,3 +367,84 @@ func TestErrorStatus(t *testing.T) { }) } } + +func TestZeroValuesAreMarshaled(t *testing.T) { + // This test verifies that zero-values (false, 0, empty string) are properly + // communicated in OFREP responses, even with omitempty tags on fields + tests := []struct { + name string + value interface{} + variant string + expectedInJSON bool + }{ + { + name: "boolean false is included", + value: false, + variant: "false", + expectedInJSON: true, + }, + { + name: "numeric zero is included", + value: float64(0), + variant: "zero", + expectedInJSON: true, + }, + { + name: "empty string is included", + value: "", + variant: "empty", + expectedInJSON: true, + }, + { + name: "nil value is omitted", + value: nil, + variant: "", + expectedInJSON: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + anyValue := evaluator.AnyValue{ + Value: test.value, + Variant: test.variant, + Reason: model.StaticReason, + FlagKey: "testFlag", + Metadata: map[string]interface{}{}, + } + + if test.value == nil { + anyValue.Reason = model.FallbackReason + } + + success := SuccessResponseFrom(anyValue) + marshaled, err := json.Marshal(success) + if err != nil { + t.Errorf("error marshalling: %v", err) + return + } + + var result map[string]interface{} + err = json.Unmarshal(marshaled, &result) + if err != nil { + t.Errorf("error unmarshalling: %v", err) + return + } + + _, hasValue := result["value"] + if test.expectedInJSON && !hasValue { + t.Errorf("expected value field to be in JSON for %s, but it was omitted: %s", test.name, string(marshaled)) + } + if !test.expectedInJSON && hasValue { + t.Errorf("expected value field to be omitted from JSON for %s, but it was present: %v", test.name, result["value"]) + } + + // For non-nil values, verify the actual value matches + if test.expectedInJSON && test.value != nil { + if result["value"] != test.value { + t.Errorf("expected value %v, but got %v", test.value, result["value"]) + } + } + }) + } +} diff --git a/core/pkg/store/query.go b/core/pkg/store/query.go index 17968fd83..478440a8b 100644 --- a/core/pkg/store/query.go +++ b/core/pkg/store/query.go @@ -1,6 +1,7 @@ package store import ( + "fmt" "maps" "sort" "strings" @@ -133,3 +134,19 @@ func (s *Selector) ToMetadata() model.Metadata { } return meta } + +func (s *Selector) ToLogString() string { + if s == nil || len(s.indexMap) == 0 { + return "" + } + keys := make([]string, 0, len(s.indexMap)) + for k := range s.indexMap { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%s", k, s.indexMap[k])) + } + return "'" + strings.Join(parts, ",") + "'" +} diff --git a/core/pkg/store/store.go b/core/pkg/store/store.go index ee25d9910..48b80b107 100644 --- a/core/pkg/store/store.go +++ b/core/pkg/store/store.go @@ -2,6 +2,7 @@ package store import ( "context" + "errors" "fmt" "slices" "sort" @@ -9,6 +10,7 @@ import ( "github.com/hashicorp/go-memdb" "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/model" + "go.uber.org/zap" ) var noValidatedSources = []string{} @@ -23,7 +25,7 @@ type IStore interface { Get(ctx context.Context, key string, selector *Selector) (model.Flag, model.Metadata, error) GetAll(ctx context.Context, selector *Selector) ([]model.Flag, model.Metadata, error) Watch(ctx context.Context, selector *Selector, watcher chan<- FlagQueryResult) - Update(source string, flags []model.Flag, metadata model.Metadata) + Update(source string, flags []model.Flag, metadata model.Metadata, incrementalUpdate bool) } var _ IStore = (*Store)(nil) @@ -212,10 +214,16 @@ type flagIdentifier struct { } // Update the flag state with the provided flags. +// When incrementalUpdate is true, deletion is scoped to only the flagSetIds present in +// this payload (from metadata and flag-level overrides), allowing flags from other +// flagSetIds to accumulate across updates. When false, all flags for the source are +// replaced (the default full-snapshot behavior). +// EXPERIMENTAL: incrementalUpdate support may change or be removed in a future release. func (s *Store) Update( source string, flags []model.Flag, metadata model.Metadata, + incrementalUpdate bool, ) { if source == "" { panic("source cannot be empty") @@ -252,9 +260,39 @@ func (s *Store) Update( txn := s.db.Txn(true) defer txn.Abort() - // get all flags for the source we are updating - selector := NewSelector(sourceIndex + "=" + source) - oldFlags, _, _ := s.GetAll(context.Background(), &selector) + // When incrementalUpdate is enabled, scope deletion to only the flagSetIds touched + // by this payload (metadata-level + flag-level overrides). This allows per-flagSetId + // updates (e.g., from per-project stream messages) to accumulate without deleting + // flags from unrelated flagSetIds. Otherwise, replace all flags for the source. + var oldFlags []model.Flag + if incrementalUpdate { + seenFlagSetIds := make(map[string]struct{}) + if fsi, ok := metadata["flagSetId"].(string); ok && fsi != "" { + seenFlagSetIds[fsi] = struct{}{} + } + for id := range newFlags { + seenFlagSetIds[id.flagSetId] = struct{}{} + } + for fsi := range seenFlagSetIds { + sel := NewSelector(flagSetIdIndex+"="+fsi).WithIndex(sourceIndex, source) + indexId, constraints := sel.ToQuery() + it, err := txn.Get(flagsTable, indexId, constraints...) + if err != nil { + s.logger.Error(fmt.Sprintf("unable to query flags for flagSetId %s: %v", fsi, err)) + return + } + oldFlags = append(oldFlags, s.collect(it)...) + } + } else { + sel := NewSelector(sourceIndex + "=" + source) + indexId, constraints := sel.ToQuery() + it, err := txn.Get(flagsTable, indexId, constraints...) + if err != nil { + s.logger.Error(fmt.Sprintf("unable to query flags for source %s: %v", source, err)) + return + } + oldFlags = s.collect(it) + } for _, oldFlag := range oldFlags { if _, ok := newFlags[flagIdentifier{flagSetId: oldFlag.FlagSetId, key: oldFlag.Key}]; !ok { @@ -313,7 +351,7 @@ func (s *Store) Watch(ctx context.Context, selector *Selector, watcher chan<- Fl ws := memdb.NewWatchSet() it, err := s.selectOrAll(selector) if err != nil { - s.logger.Error(fmt.Sprintf("error watching flags: %v", err)) + s.logger.WithFields(zap.String("selector", selector.ToLogString()), zap.Error(err)).Error("error getting flags") close(watcher) return } @@ -326,7 +364,11 @@ func (s *Store) Watch(ctx context.Context, selector *Selector, watcher chan<- Fl } if err = ws.WatchCtx(ctx); err != nil { - s.logger.Error(fmt.Sprintf("error watching flags: %v", err)) + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + s.logger.WithFields(zap.String("selector", selector.ToLogString()), zap.Error(err)).Debug("context cancellation while watching flags") + } else { + s.logger.WithFields(zap.String("selector", selector.ToLogString()), zap.Error(err)).Error("context error watching flags") + } close(watcher) return } diff --git a/core/pkg/store/store_test.go b/core/pkg/store/store_test.go index 677bc59d0..a729c4e49 100644 --- a/core/pkg/store/store_test.go +++ b/core/pkg/store/store_test.go @@ -80,7 +80,7 @@ func TestUpdateFlags(t *testing.T) { } s.Update(source1, []model.Flag{ {Key: "waka", DefaultVariant: "off"}, - }, nil) + }, nil, false) return s }, newFlags: []model.Flag{ @@ -100,7 +100,7 @@ func TestUpdateFlags(t *testing.T) { } s.Update(source1, []model.Flag{ {Key: "waka", DefaultVariant: "off"}, - }, nil) + }, nil, false) return s }, newFlags: []model.Flag{ @@ -119,7 +119,7 @@ func TestUpdateFlags(t *testing.T) { if err != nil { t.Fatalf("NewStore failed: %v", err) } - s.Update(source1, []model.Flag{}, model.Metadata{}) + s.Update(source1, []model.Flag{}, model.Metadata{}, false) return s }, setMetadata: model.Metadata{ @@ -142,7 +142,7 @@ func TestUpdateFlags(t *testing.T) { if err != nil { t.Fatalf("NewStore failed: %v", err) } - s.Update(source1, []model.Flag{}, model.Metadata{}) + s.Update(source1, []model.Flag{}, model.Metadata{}, false) return s }, @@ -168,7 +168,7 @@ func TestUpdateFlags(t *testing.T) { if err != nil { t.Fatalf("NewStore failed: %v", err) } - s.Update(source1, []model.Flag{}, model.Metadata{}) + s.Update(source1, []model.Flag{}, model.Metadata{}, false) return s }, setMetadata: model.Metadata{ @@ -195,7 +195,7 @@ func TestUpdateFlags(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() store := tt.setup(t) - store.Update(tt.source, tt.newFlags, tt.setMetadata) + store.Update(tt.source, tt.newFlags, tt.setMetadata, false) gotFlags, _, _ := store.GetAll(context.Background(), nil) sort.Slice(tt.wantFlags, func(i, j int) bool { return tt.wantFlags[i].FlagSetId+"|"+tt.wantFlags[i].Key > tt.wantFlags[j].FlagSetId+"|"+tt.wantFlags[j].Key @@ -330,7 +330,7 @@ func TestGet(t *testing.T) { } for _, source := range s.order { - store.Update(source.Name, source.flags, nil) + store.Update(source.Name, source.flags, nil, false) } gotFlag, _, err := store.Get(context.Background(), tt.key, tt.selector) @@ -473,7 +473,7 @@ func TestGetAllNoWatcher(t *testing.T) { } for _, source := range s.order { - store.Update(source.Name, source.flags, nil) + store.Update(source.Name, source.flags, nil, false) } gotFlags, _, _ := store.GetAll(context.Background(), tt.selector) @@ -556,9 +556,9 @@ func TestWatch(t *testing.T) { } // setup initial flags - store.Update(sourceA, sourceAFlags, model.Metadata{}) - store.Update(sourceB, sourceBFlags, model.Metadata{}) - store.Update(sourceC, sourceCFlags, model.Metadata{}) + store.Update(sourceA, sourceAFlags, model.Metadata{}, false) + store.Update(sourceB, sourceBFlags, model.Metadata{}, false) + store.Update(sourceC, sourceCFlags, model.Metadata{}, false) watcher := make(chan FlagQueryResult, 1) time.Sleep(pauseTime) @@ -573,14 +573,14 @@ func TestWatch(t *testing.T) { // changing a flag default variant should trigger an update store.Update(sourceA, []model.Flag{ {Key: "flagA", DefaultVariant: "on"}, - }, model.Metadata{}) + }, model.Metadata{}, false) time.Sleep(pauseTime) // changing a flag default variant should trigger an update store.Update(sourceB, []model.Flag{ {Key: "flagB", DefaultVariant: "on", Metadata: model.Metadata{"flagSetId": myFlagSetId}}, - }, model.Metadata{}) + }, model.Metadata{}, false) time.Sleep(pauseTime) @@ -588,14 +588,14 @@ func TestWatch(t *testing.T) { // TODO: challenge this test and behaviour store.Update(sourceB, []model.Flag{ {Key: "flagB", DefaultVariant: "on"}, - }, model.Metadata{}) + }, model.Metadata{}, false) time.Sleep(pauseTime) // adding a flag set id should trigger an update store.Update(sourceB, []model.Flag{ {Key: "flagB", DefaultVariant: "on", Metadata: model.Metadata{"flagSetId": myFlagSetId}}, - }, model.Metadata{}) + }, model.Metadata{}, false) }() updates := 0 @@ -618,6 +618,152 @@ func TestWatch(t *testing.T) { } } +func TestUpdateFlagSetIdScoping(t *testing.T) { + t.Parallel() + + const src = "src1" + sources := []string{src} + + type updateStep struct { + flags []model.Flag + metadata model.Metadata + incrementalUpdate bool // false (default): full-snapshot; true: per-flagSetId scoped deletion + } + + tests := []struct { + name string + updates []updateStep + wantPresent []string // "flagSetId/key" entries expected in the store + wantAbsent []string // "flagSetId/key" entries expected to be gone + }{ + { + name: "per-flagSetId update preserves flags from other flagSetIds", + updates: []updateStep{ + {flags: []model.Flag{{Key: "flagA1"}, {Key: "flagA2"}}, metadata: model.Metadata{"flagSetId": "A"}, incrementalUpdate: true}, + {flags: []model.Flag{{Key: "flagB1"}}, metadata: model.Metadata{"flagSetId": "B"}, incrementalUpdate: true}, + {flags: []model.Flag{{Key: "flagA1"}}, metadata: model.Metadata{"flagSetId": "A"}, incrementalUpdate: true}, + }, + wantPresent: []string{"A/flagA1", "B/flagB1"}, + wantAbsent: []string{"A/flagA2"}, + }, + { + name: "out-of-scope flag-level override persists when not in batch", + updates: []updateStep{ + {flags: []model.Flag{{Key: "kept"}, {Key: "override", Metadata: model.Metadata{"flagSetId": "Y"}}}, metadata: model.Metadata{"flagSetId": "X"}, incrementalUpdate: true}, + {flags: []model.Flag{{Key: "kept"}}, metadata: model.Metadata{"flagSetId": "X"}, incrementalUpdate: true}, + }, + wantPresent: []string{"X/kept", "Y/override"}, + }, + { + name: "stale flag deleted when its flagSetId is in scope", + updates: []updateStep{ + {flags: []model.Flag{{Key: "inX"}, {Key: "inY", Metadata: model.Metadata{"flagSetId": "Y"}}}, metadata: model.Metadata{"flagSetId": "X"}, incrementalUpdate: true}, + {flags: []model.Flag{{Key: "inY", Metadata: model.Metadata{"flagSetId": "Y"}}}, metadata: model.Metadata{"flagSetId": "X"}, incrementalUpdate: true}, + }, + wantPresent: []string{"Y/inY"}, + wantAbsent: []string{"X/inX"}, + }, + { + name: "empty update with incrementalUpdate=false clears all flags", + updates: []updateStep{ + {flags: []model.Flag{{Key: "flagA"}}, metadata: model.Metadata{"flagSetId": "A"}, incrementalUpdate: true}, + {flags: []model.Flag{{Key: "flagB"}}, metadata: model.Metadata{"flagSetId": "B"}, incrementalUpdate: true}, + {flags: []model.Flag{}, metadata: nil}, + }, + wantAbsent: []string{"A/flagA", "B/flagB"}, + }, + { + name: "incrementalUpdate=false with flagSetId still does full-source deletion", + updates: []updateStep{ + {flags: []model.Flag{{Key: "flagA"}}, metadata: model.Metadata{"flagSetId": "A"}, incrementalUpdate: true}, + {flags: []model.Flag{{Key: "flagB"}}, metadata: model.Metadata{"flagSetId": "B"}, incrementalUpdate: true}, + {flags: []model.Flag{{Key: "flagA"}}, metadata: model.Metadata{"flagSetId": "A"}}, + }, + wantPresent: []string{"A/flagA"}, + wantAbsent: []string{"B/flagB"}, + }, + { + name: "empty update with flagSetId clears only that set", + updates: []updateStep{ + {flags: []model.Flag{{Key: "flagA"}}, metadata: model.Metadata{"flagSetId": "A"}, incrementalUpdate: true}, + {flags: []model.Flag{{Key: "flagB"}}, metadata: model.Metadata{"flagSetId": "B"}, incrementalUpdate: true}, + {flags: []model.Flag{}, metadata: model.Metadata{"flagSetId": "A"}, incrementalUpdate: true}, + }, + wantPresent: []string{"B/flagB"}, + wantAbsent: []string{"A/flagA"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + s, err := NewStore(logger.NewLogger(nil, false), sources) + require.NoError(t, err) + + for _, step := range tt.updates { + s.Update(src, step.flags, step.metadata, step.incrementalUpdate) + } + + allFlags, _, _ := s.GetAll(context.Background(), nil) + flagKeys := make(map[string]struct{}, len(allFlags)) + for _, f := range allFlags { + flagKeys[f.FlagSetId+"/"+f.Key] = struct{}{} + } + + for _, key := range tt.wantPresent { + assert.Contains(t, flagKeys, key) + } + for _, key := range tt.wantAbsent { + assert.NotContains(t, flagKeys, key) + } + }) + } +} + +func TestToLogStringCompound(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + selector *Selector + want string + }{ + { + name: "nil selector", + selector: nil, + want: "", + }, + { + name: "empty selector", + selector: &Selector{indexMap: map[string]string{}}, + want: "", + }, + { + name: "single key", + selector: &Selector{indexMap: map[string]string{"source": "mySource"}}, + want: "'source=mySource'", + }, + { + name: "compound selector", + selector: &Selector{indexMap: map[string]string{"flagSetId": "abc", "source": "mySource"}}, + want: "'flagSetId=abc,source=mySource'", + }, + { + name: "three keys sorted", + selector: &Selector{indexMap: map[string]string{"source": "s", "key": "k", "flagSetId": "f"}}, + want: "'flagSetId=f,key=k,source=s'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.selector.ToLogString() + assert.Equal(t, tt.want, got) + }) + } +} + func TestQueryMetadata(t *testing.T) { sourceA := "sourceA" @@ -635,7 +781,7 @@ func TestQueryMetadata(t *testing.T) { } // setup initial flags - store.Update(sourceA, sourceAFlags, model.Metadata{}) + store.Update(sourceA, sourceAFlags, model.Metadata{}, false) // #1708 Until we decide on the Selector syntax, only a single key=value pair is supported // these tests should then also cover more complex selectors diff --git a/core/pkg/sync/blob/blob_sync.go b/core/pkg/sync/blob/blob_sync.go index b3ed9cc8f..c5806d484 100644 --- a/core/pkg/sync/blob/blob_sync.go +++ b/core/pkg/sync/blob/blob_sync.go @@ -10,6 +10,8 @@ import ( "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/sync" + "github.com/open-feature/flagd/core/pkg/sync/internal/bloburi" + "github.com/open-feature/flagd/core/pkg/sync/internal/polling" "github.com/open-feature/flagd/core/pkg/utils" "gocloud.dev/blob" _ "gocloud.dev/blob/azureblob" // needed to initialize Azure Blob Storage driver @@ -21,20 +23,13 @@ type Sync struct { Bucket string Object string BlobURLMux *blob.URLMux - Cron Cron + Poller polling.Poller Logger *logger.Logger Interval uint32 ready bool lastUpdated time.Time } -// Cron defines the behaviour required of a cron -type Cron interface { - AddFunc(spec string, cmd func()) error - Start() - Stop() -} - func (hs *Sync) Init(_ context.Context) error { if hs.Bucket == "" { return errors.New("no bucket string set") @@ -50,24 +45,26 @@ func (hs *Sync) IsReady() bool { } func (hs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error { - hs.Logger.Info(fmt.Sprintf("starting sync from %s/%s with interval %ds", hs.Bucket, hs.Object, hs.Interval)) - _ = hs.Cron.AddFunc(fmt.Sprintf("*/%d * * * *", hs.Interval), func() { - err := hs.sync(ctx, dataSync, false) - if err != nil { - hs.Logger.Warn(fmt.Sprintf("sync failed: %v", err)) - } - }) + hs.Logger.Info(fmt.Sprintf("starting sync from %s/%s (interval: %ds)", hs.Bucket, hs.Object, hs.Interval)) + // Initial fetch - hs.Logger.Debug(fmt.Sprintf("initial sync of the %s/%s", hs.Bucket, hs.Object)) + hs.Logger.Debug(fmt.Sprintf("initial fetch from %s/%s", hs.Bucket, hs.Object)) err := hs.sync(ctx, dataSync, false) if err != nil { return err } hs.ready = true - hs.Cron.Start() - <-ctx.Done() - hs.Cron.Stop() + + hs.Logger.Debug(fmt.Sprintf("polling %s/%s every %ds (offset: %ds)", + hs.Bucket, hs.Object, hs.Interval, hs.Poller.Offset())) + + hs.Poller.Start(ctx, func() { + err := hs.sync(ctx, dataSync, false) + if err != nil { + hs.Logger.Warn(fmt.Sprintf("sync failed: %v", err)) + } + }) return nil } @@ -104,7 +101,7 @@ func (hs *Sync) sync(ctx context.Context, dataSync chan<- sync.DataSync, skipChe if !skipCheckingModTime { hs.lastUpdated = updated } - dataSync <- sync.DataSync{FlagData: msg, Source: hs.Bucket + hs.Object} + dataSync <- sync.DataSync{FlagData: msg, Source: bloburi.Join(hs.Bucket, hs.Object)} return nil } diff --git a/core/pkg/sync/blob/blob_sync_test.go b/core/pkg/sync/blob/blob_sync_test.go index a661e695a..66cc4de2d 100644 --- a/core/pkg/sync/blob/blob_sync_test.go +++ b/core/pkg/sync/blob/blob_sync_test.go @@ -9,7 +9,6 @@ import ( "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/sync" synctesting "github.com/open-feature/flagd/core/pkg/sync/testing" - "go.uber.org/mock/gomock" ) func TestBlobSync(t *testing.T) { @@ -38,17 +37,12 @@ func TestBlobSync(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { - ctrl := gomock.NewController(t) - mockCron := synctesting.NewMockCron(ctrl) - mockCron.EXPECT().AddFunc(gomock.Any(), gomock.Any()).DoAndReturn(func(spec string, cmd func()) error { - return nil - }) - mockCron.EXPECT().Start().Times(1) + mockPoller := synctesting.NewMockPoller() blobSync := &Sync{ Bucket: tt.scheme + "://" + tt.bucket, Object: tt.object, - Cron: mockCron, + Poller: mockPoller, Logger: logger.NewLogger(nil, false), } blobMock := NewMockBlob(tt.scheme, func() *Sync { @@ -73,19 +67,19 @@ func TestBlobSync(t *testing.T) { if data.FlagData != tt.convertedContent { t.Errorf("expected content: %s, but received content: %s", tt.convertedContent, data.FlagData) } - tickWithConfigChange(t, mockCron, dataSyncChan, blobMock, tt.object, tt.convertedContent) - tickWithoutConfigChange(t, mockCron, dataSyncChan) - tickWithConfigChange(t, mockCron, dataSyncChan, blobMock, tt.object, tt.convertedContent) - tickWithoutConfigChange(t, mockCron, dataSyncChan) - tickWithoutConfigChange(t, mockCron, dataSyncChan) + tickWithConfigChange(t, mockPoller, dataSyncChan, blobMock, tt.object, tt.convertedContent) + tickWithoutConfigChange(t, mockPoller, dataSyncChan) + tickWithConfigChange(t, mockPoller, dataSyncChan, blobMock, tt.object, tt.convertedContent) + tickWithoutConfigChange(t, mockPoller, dataSyncChan) + tickWithoutConfigChange(t, mockPoller, dataSyncChan) }) } } -func tickWithConfigChange(t *testing.T, mockCron *synctesting.MockCron, dataSyncChan chan sync.DataSync, blobMock *MockBlob, object string, newConfig string) { +func tickWithConfigChange(t *testing.T, mockPoller *synctesting.MockPoller, dataSyncChan chan sync.DataSync, blobMock *MockBlob, object string, newConfig string) { time.Sleep(1 * time.Millisecond) // sleep so the new file has different modification date blobMock.AddObject(object, newConfig) - mockCron.Tick() + mockPoller.Tick() select { case data, ok := <-dataSyncChan: if ok { @@ -100,8 +94,8 @@ func tickWithConfigChange(t *testing.T, mockCron *synctesting.MockCron, dataSync } } -func tickWithoutConfigChange(t *testing.T, mockCron *synctesting.MockCron, dataSyncChan chan sync.DataSync) { - mockCron.Tick() +func tickWithoutConfigChange(t *testing.T, mockPoller *synctesting.MockPoller, dataSyncChan chan sync.DataSync) { + mockPoller.Tick() select { case data, ok := <-dataSyncChan: if ok { @@ -119,13 +113,12 @@ func TestReSync(t *testing.T) { bucket = "b" object = "flags.json" ) - ctrl := gomock.NewController(t) - mockCron := synctesting.NewMockCron(ctrl) + mockPoller := synctesting.NewMockPoller() blobSync := &Sync{ Bucket: scheme + "://" + bucket, Object: object, - Cron: mockCron, + Poller: mockPoller, Logger: logger.NewLogger(nil, false), } blobMock := NewMockBlob(scheme, func() *Sync { diff --git a/core/pkg/sync/builder/syncbuilder.go b/core/pkg/sync/builder/syncbuilder.go index 947b9fec3..17e89cbd0 100644 --- a/core/pkg/sync/builder/syncbuilder.go +++ b/core/pkg/sync/builder/syncbuilder.go @@ -12,8 +12,9 @@ import ( "github.com/open-feature/flagd/core/pkg/sync/grpc" "github.com/open-feature/flagd/core/pkg/sync/grpc/credentials" httpSync "github.com/open-feature/flagd/core/pkg/sync/http" + "github.com/open-feature/flagd/core/pkg/sync/internal/bloburi" + "github.com/open-feature/flagd/core/pkg/sync/internal/polling" "github.com/open-feature/flagd/core/pkg/sync/kubernetes" - "github.com/robfig/cron" "go.uber.org/zap" "gocloud.dev/blob" "k8s.io/client-go/dynamic" @@ -110,19 +111,19 @@ func (sb *SyncBuilder) syncFromConfig(sourceConfig sync.SourceConfig, logger *lo return sb.newK8s(sourceConfig.URI, logger) case syncProviderHTTP: logger.Debug(fmt.Sprintf("using remote sync-provider for: %s", sourceConfig.URI)) - return httpSync.NewHTTP(sourceConfig, logger), nil + return sb.newHTTP(sourceConfig, logger) case syncProviderGrpc: logger.Debug(fmt.Sprintf("using grpc sync-provider for: %s", sourceConfig.URI)) return sb.newGRPC(sourceConfig, logger), nil case syncProviderGcs: logger.Debug(fmt.Sprintf("using blob sync-provider with gcs driver for: %s", sourceConfig.URI)) - return sb.newGcs(sourceConfig, logger), nil + return sb.newGcs(sourceConfig, logger) case syncProviderAzblob: logger.Debug(fmt.Sprintf("using blob sync-provider with azblob driver for: %s", sourceConfig.URI)) return sb.newAzblob(sourceConfig, logger) case syncProviderS3: logger.Debug(fmt.Sprintf("using blob sync-provider with s3 driver for: %s", sourceConfig.URI)) - return sb.newS3(sourceConfig, logger), nil + return sb.newS3(sourceConfig, logger) default: return nil, fmt.Errorf("invalid sync provider: %s, must be one of with "+ @@ -191,26 +192,48 @@ func (sb *SyncBuilder) newGRPC(config sync.SourceConfig, logger *logger.Logger) zap.String("component", "sync"), zap.String("sync", "grpc"), ), - CredentialBuilder: &credentials.CredentialBuilder{}, - CertPath: config.CertPath, - ProviderID: config.ProviderID, - Secure: config.TLS, - Selector: config.Selector, - MaxMsgSize: config.MaxMsgSize, + CredentialBuilder: &credentials.CredentialBuilder{}, + CertPath: config.CertPath, + ProviderID: config.ProviderID, + Secure: config.TLS, + Selector: config.Selector, + MaxMsgSize: config.MaxMsgSize, + IncrementalUpdates: config.IncrementalUpdates, + Headers: config.Headers, } } -func (sb *SyncBuilder) newGcs(config sync.SourceConfig, logger *logger.Logger) *blobSync.Sync { +// defaultInterval returns the configured interval or 5 seconds if unset, +// along with a validated poller. +func newPoller(config sync.SourceConfig) (uint32, *polling.CronPoller, error) { + var interval uint32 = 5 + if config.Interval != 0 { + interval = config.Interval + } + poller, err := polling.NewCronPoller(interval, config.IntervalSeed) + if err != nil { + return 0, nil, err + } + return interval, poller, nil +} + +func (sb *SyncBuilder) newHTTP(config sync.SourceConfig, logger *logger.Logger) (*httpSync.Sync, error) { + interval, poller, err := newPoller(config) + if err != nil { + return nil, fmt.Errorf("invalid http sync configuration: %w", err) + } + return httpSync.NewHTTP(config, logger, poller, interval), nil +} + +func (sb *SyncBuilder) newGcs(config sync.SourceConfig, logger *logger.Logger) (*blobSync.Sync, error) { // Extract bucket uri and object name from the full URI: // gs://bucket/path/to/object results in gs://bucket/ as bucketUri and // path/to/object as an object name. - bucketURI := regGcs.FindString(config.URI) - objectName := regGcs.ReplaceAllString(config.URI, "") + bucketURI, objectName := bloburi.Split(config.URI, regGcs) - // Defaults to 5 seconds if interval is not set. - var interval uint32 = 5 - if config.Interval != 0 { - interval = config.Interval + interval, poller, err := newPoller(config) + if err != nil { + return nil, fmt.Errorf("invalid gcs sync configuration: %w", err) } return &blobSync.Sync{ @@ -224,8 +247,8 @@ func (sb *SyncBuilder) newGcs(config sync.SourceConfig, logger *logger.Logger) * zap.String("sync", "gcs"), ), Interval: interval, - Cron: cron.New(), - } + Poller: poller, + }, nil } func (sb *SyncBuilder) newAzblob(config sync.SourceConfig, logger *logger.Logger) (*blobSync.Sync, error) { @@ -241,13 +264,11 @@ func (sb *SyncBuilder) newAzblob(config sync.SourceConfig, logger *logger.Logger // Extract bucket uri and object name from the full URI: // azblob://bucket/path/to/object results in azblob://bucket/ as bucketUri and // path/to/object as an object name. - bucketURI := regAzblob.FindString(config.URI) - objectName := regAzblob.ReplaceAllString(config.URI, "") + bucketURI, objectName := bloburi.Split(config.URI, regAzblob) - // Defaults to 5 seconds if interval is not set. - var interval uint32 = 5 - if config.Interval != 0 { - interval = config.Interval + interval, poller, err := newPoller(config) + if err != nil { + return nil, fmt.Errorf("invalid azblob sync configuration: %w", err) } return &blobSync.Sync{ @@ -261,21 +282,20 @@ func (sb *SyncBuilder) newAzblob(config sync.SourceConfig, logger *logger.Logger zap.String("sync", "azblob"), ), Interval: interval, - Cron: cron.New(), + Poller: poller, }, nil } -func (sb *SyncBuilder) newS3(config sync.SourceConfig, logger *logger.Logger) *blobSync.Sync { +func (sb *SyncBuilder) newS3(config sync.SourceConfig, logger *logger.Logger) (*blobSync.Sync, error) { // Extract bucket uri and object name from the full URI: - // gs://bucket/path/to/object results in gs://bucket/ as bucketUri and - // path/to/object as an object name. - bucketURI := regS3.FindString(config.URI) - objectName := regS3.ReplaceAllString(config.URI, "") + // s3://bucket/path/to/object results in s3://bucket/ as bucketUri and + // path/to/object as an object name. Any query string (e.g. use_path_style, + // region) is moved onto the bucket URL so gocloud's s3blob driver reads it. + bucketURI, objectName := bloburi.Split(config.URI, regS3) - // Defaults to 5 seconds if interval is not set. - var interval uint32 = 5 - if config.Interval != 0 { - interval = config.Interval + interval, poller, err := newPoller(config) + if err != nil { + return nil, fmt.Errorf("invalid s3 sync configuration: %w", err) } return &blobSync.Sync{ @@ -289,8 +309,8 @@ func (sb *SyncBuilder) newS3(config sync.SourceConfig, logger *logger.Logger) *b zap.String("sync", "s3"), ), Interval: interval, - Cron: cron.New(), - } + Poller: poller, + }, nil } type IK8sClientBuilder interface { diff --git a/core/pkg/sync/builder/syncbuilder_test.go b/core/pkg/sync/builder/syncbuilder_test.go index ed2d81023..0c898f7c5 100644 --- a/core/pkg/sync/builder/syncbuilder_test.go +++ b/core/pkg/sync/builder/syncbuilder_test.go @@ -277,6 +277,24 @@ func Test_SyncsFromFromConfig(t *testing.T) { } } +func Test_GrpcIncrementalUpdates(t *testing.T) { + lg := logger.NewLogger(nil, false) + sb := NewSyncBuilder() + + syncs, err := sb.SyncsFromConfig([]sync.SourceConfig{ + { + URI: "grpc://host:port", + Provider: syncProviderGrpc, + IncrementalUpdates: true, + }, + }, lg) + require.NoError(t, err) + require.Len(t, syncs, 1) + grpcSync, ok := syncs[0].(*grpc.Sync) + require.True(t, ok) + require.True(t, grpcSync.IncrementalUpdates, "IncrementalUpdates should be propagated from SourceConfig to grpc.Sync") +} + func Test_GcsConfig(t *testing.T) { lg := logger.NewLogger(nil, false) defaultInterval := uint32(5) @@ -320,10 +338,11 @@ func Test_GcsConfig(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gcsSync := NewSyncBuilder().newGcs(sync.SourceConfig{ + gcsSync, err := NewSyncBuilder().newGcs(sync.SourceConfig{ URI: tt.uri, Interval: tt.interval, }, lg) + require.NoError(t, err) require.Equal(t, tt.expectedBucket, gcsSync.Bucket) require.Equal(t, tt.expectedObject, gcsSync.Object) require.Equal(t, int(tt.expectedInterval), int(gcsSync.Interval)) @@ -444,6 +463,13 @@ func Test_S3Config(t *testing.T) { expectedObject: "path/to/object", expectedInterval: defaultInterval, }, + { + name: "bucket options in query", + uri: "s3://bucket/path/to/object?use_path_style=true", + expectedBucket: "s3://bucket?use_path_style=true", + expectedObject: "path/to/object", + expectedInterval: defaultInterval, + }, { name: "no object set", // Blob syncer will return error when fetching uri: "s3://bucket/", @@ -461,10 +487,11 @@ func Test_S3Config(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s3Sync := NewSyncBuilder().newS3(sync.SourceConfig{ + s3Sync, err := NewSyncBuilder().newS3(sync.SourceConfig{ URI: tt.uri, Interval: tt.interval, }, lg) + require.NoError(t, err) require.Equal(t, tt.expectedBucket, s3Sync.Bucket) require.Equal(t, tt.expectedObject, s3Sync.Object) require.Equal(t, int(tt.expectedInterval), int(s3Sync.Interval)) diff --git a/core/pkg/sync/builder/utils.go b/core/pkg/sync/builder/utils.go index c3eca9691..f0a629a69 100644 --- a/core/pkg/sync/builder/utils.go +++ b/core/pkg/sync/builder/utils.go @@ -81,7 +81,7 @@ func ParseSyncProviderURIs(uris []string) ([]sync.SourceConfig, error) { }) default: return syncProvidersParsed, fmt.Errorf("invalid sync uri argument: %s, must start with 'file:', "+ - "'http(s)://', 'grpc(s)://', 'gs://', 'azblob://' or 'core.openfeature.dev'", uri) + "'http(s)://', 'grpc(s)://', 'gs://', 'azblob://', 's3://' or 'core.openfeature.dev'", uri) } } return syncProvidersParsed, nil diff --git a/core/pkg/sync/builder/utils_test.go b/core/pkg/sync/builder/utils_test.go index 5cc02ede5..986192f4e 100644 --- a/core/pkg/sync/builder/utils_test.go +++ b/core/pkg/sync/builder/utils_test.go @@ -40,8 +40,8 @@ func TestParseSource(t *testing.T) { Provider: syncProviderFile, }, { - URI: "http://test.com", - Provider: syncProviderHTTP, + URI: "http://test.com", + Provider: syncProviderHTTP, AuthHeader: "Bearer :)", }, { @@ -71,7 +71,7 @@ func TestParseSource(t *testing.T) { {"uri":"config/samples/example_flags.json","provider":"file"}, {"uri":"https://secure-remote","provider":"http","authHeader":"Bearer bearer-dji34ld2l"}, {"uri":"https://secure-remote","provider":"http","authHeader":"Basic dXNlcjpwYXNz"}, - {"uri":"http://site.com","provider":"http","interval":77 }, + {"uri":"http://site.com","provider":"http","interval":77,"intervalSeed":"my-pod-123"}, {"uri":"default/my-flag-config","provider":"kubernetes"}, {"uri":"grpc-source:8080","provider":"grpc"}, {"uri":"my-flag-source:8080","provider":"grpc", "tls":true, "certPath": "/certs/ca.cert", "providerID": "flagd-weatherapp-sidecar", "selector": "source=database,app=weatherapp"} @@ -93,9 +93,10 @@ func TestParseSource(t *testing.T) { AuthHeader: "Basic dXNlcjpwYXNz", }, { - URI: "http://site.com", - Provider: syncProviderHTTP, - Interval: 77, + URI: "http://site.com", + Provider: syncProviderHTTP, + Interval: 77, + IntervalSeed: "my-pod-123", }, { URI: "default/my-flag-config", @@ -125,6 +126,17 @@ func TestParseSource(t *testing.T) { expectErr: true, out: []sync.SourceConfig{}, }, + "with-headers": { + in: `[{"uri":"http://test.com","provider":"http","headers":{"X-Custom":"value","X-Another":"val2"}}]`, + expectErr: false, + out: []sync.SourceConfig{ + { + URI: "http://test.com", + Provider: syncProviderHTTP, + Headers: map[string]string{"X-Custom": "value", "X-Another": "val2"}, + }, + }, + }, } for name, tt := range test { diff --git a/core/pkg/sync/file/filepath_sync.go b/core/pkg/sync/file/filepath_sync.go index e624df147..50b4d3b1b 100644 --- a/core/pkg/sync/file/filepath_sync.go +++ b/core/pkg/sync/file/filepath_sync.go @@ -94,9 +94,11 @@ func (fs *Sync) setReady(val bool) { //nolint:funlen func (fs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error { defer fs.watcher.Close() + fs.Logger.Info(fmt.Sprintf("starting sync from %s", fs.URI)) + fs.Logger.Debug(fmt.Sprintf("initial fetch from %s", fs.URI)) fs.sendDataSync(ctx, dataSync) fs.setReady(true) - fs.Logger.Info(fmt.Sprintf("watching filepath: %s", fs.URI)) + fs.Logger.Debug(fmt.Sprintf("watching %s for changes", fs.URI)) for { select { case event, ok := <-fs.watcher.Events(): diff --git a/core/pkg/sync/grpc/grpc_sync.go b/core/pkg/sync/grpc/grpc_sync.go index 53dca4105..5d203ad7f 100644 --- a/core/pkg/sync/grpc/grpc_sync.go +++ b/core/pkg/sync/grpc/grpc_sync.go @@ -15,6 +15,7 @@ import ( _ "github.com/open-feature/flagd/core/pkg/sync/grpc/nameresolvers" // initialize custom resolvers e.g. envoy.Init() "google.golang.org/grpc" "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" ) const ( @@ -53,6 +54,8 @@ type Sync struct { Selector string URI string MaxMsgSize int + IncrementalUpdates bool + Headers map[string]string client FlagSyncServiceClient ready bool @@ -96,16 +99,28 @@ func (g *Sync) Init(_ context.Context) error { return nil } +func (g *Sync) contextWithHeaders(ctx context.Context) context.Context { + if len(g.Headers) == 0 { + return ctx + } + pairs := make([]string, 0, len(g.Headers)*2) + for k, v := range g.Headers { + pairs = append(pairs, k, v) + } + return metadata.AppendToOutgoingContext(ctx, pairs...) +} + func (g *Sync) ReSync(ctx context.Context, dataSync chan<- sync.DataSync) error { - res, err := g.client.FetchAllFlags(ctx, &v1.FetchAllFlagsRequest{ProviderId: g.ProviderID, Selector: g.Selector}) + res, err := g.client.FetchAllFlags(g.contextWithHeaders(ctx), &v1.FetchAllFlagsRequest{ProviderId: g.ProviderID, Selector: g.Selector}) if err != nil { err = fmt.Errorf("error fetching all flags: %w", err) g.Logger.Error(err.Error()) return err } dataSync <- sync.DataSync{ - FlagData: res.GetFlagConfiguration(), - Source: g.URI, + FlagData: res.GetFlagConfiguration(), + Source: g.URI, + IncrementalUpdates: g.IncrementalUpdates, } return nil } @@ -115,12 +130,17 @@ func (g *Sync) IsReady() bool { } func (g *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error { + g.Logger.Info(fmt.Sprintf("starting sync from %s", g.URI)) + // Initialize SyncFlags client. This fails if server connection establishment fails (ex:- grpc server offline) - syncClient, err := g.client.SyncFlags(ctx, &v1.SyncFlagsRequest{ProviderId: g.ProviderID, Selector: g.Selector}) + g.Logger.Debug(fmt.Sprintf("initial stream connection to %s", g.URI)) + syncClient, err := g.client.SyncFlags(g.contextWithHeaders(ctx), &v1.SyncFlagsRequest{ProviderId: g.ProviderID, Selector: g.Selector}) if err != nil { return fmt.Errorf("unable to sync flags: %w", err) } + g.Logger.Debug(fmt.Sprintf("watching %s for changes", g.URI)) + // Initial stream listening. Error will be logged and continue and retry connection establishment err = g.handleFlagSync(syncClient, dataSync) if err == nil { @@ -175,7 +195,7 @@ func (g *Sync) connectWithRetry( g.Logger.Warn(fmt.Sprintf("connection re-establishment attempt in-progress for grpc target: %s", g.URI)) - syncClient, err := g.client.SyncFlags(ctx, &v1.SyncFlagsRequest{ProviderId: g.ProviderID, Selector: g.Selector}) + syncClient, err := g.client.SyncFlags(g.contextWithHeaders(ctx), &v1.SyncFlagsRequest{ProviderId: g.ProviderID, Selector: g.Selector}) if err != nil { g.Logger.Debug(fmt.Sprintf("error opening service client: %s", err.Error())) continue @@ -199,10 +219,11 @@ func (g *Sync) handleFlagSync(stream syncv1grpc.FlagSyncService_SyncFlagsClient, } dataSync <- sync.DataSync{ - FlagData: data.FlagConfiguration, - SyncContext: data.SyncContext, - Source: g.URI, - Selector: g.Selector, + FlagData: data.FlagConfiguration, + SyncContext: data.SyncContext, + Source: g.URI, + Selector: g.Selector, + IncrementalUpdates: g.IncrementalUpdates, } g.Logger.Debug("received full configuration payload") diff --git a/core/pkg/sync/grpc/grpc_sync_test.go b/core/pkg/sync/grpc/grpc_sync_test.go index 75bd20619..961f4acce 100644 --- a/core/pkg/sync/grpc/grpc_sync_test.go +++ b/core/pkg/sync/grpc/grpc_sync_test.go @@ -24,6 +24,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/test/bufconn" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" @@ -192,6 +193,38 @@ func Test_ReSyncTests(t *testing.T) { } } +func Test_IncrementalUpdatesPropagatesToDataSync(t *testing.T) { + const target = "localBufCon" + + bufCon := bufconn.Listen(5) + bufServer := bufferedServer{ + listener: bufCon, + fetchAllFlagsResponse: &v1.FetchAllFlagsResponse{FlagConfiguration: "flags"}, + } + go serve(&bufServer) + + dial, err := grpc.Dial(target, + grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) { + return bufCon.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + + grpcSync := Sync{ + URI: target, + Logger: logger.NewLogger(nil, false), + IncrementalUpdates: true, + client: syncv1grpc.NewFlagSyncServiceClient(dial), + } + + syncChan := make(chan sync.DataSync, 1) + err = grpcSync.ReSync(context.Background(), syncChan) + require.NoError(t, err) + + out := <-syncChan + require.True(t, out.IncrementalUpdates, "IncrementalUpdates should be propagated from Sync to DataSync via ReSync") +} + func Test_StreamListener(t *testing.T) { const target = "localBufCon" @@ -508,3 +541,142 @@ func (b *bufferedServer) FetchAllFlags(_ context.Context, _ *v1.FetchAllFlagsReq func (b *bufferedServer) GetMetadata(_ context.Context, _ *v1.GetMetadataRequest) (*v1.GetMetadataResponse, error) { return &v1.GetMetadataResponse{}, nil } + +func Test_contextWithHeaders(t *testing.T) { + tests := []struct { + name string + headers map[string]string + expectUnchanged bool + }{ + { + name: "nil headers returns unchanged context", + headers: nil, + expectUnchanged: true, + }, + { + name: "empty headers returns unchanged context", + headers: map[string]string{}, + expectUnchanged: true, + }, + { + name: "headers are appended as metadata", + headers: map[string]string{ + "X-Proxy-Gateway-Host": "myhost.service", + "X-Tenant-ID": "tenant1", + }, + expectUnchanged: false, + }, + { + name: "single header", + headers: map[string]string{ + "Authorization": "Bearer token123", + }, + expectUnchanged: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + grpcSync := Sync{ + Logger: logger.NewLogger(nil, false), + Headers: tt.headers, + } + + ctx := context.Background() + result := grpcSync.contextWithHeaders(ctx) + + if tt.expectUnchanged { + require.Equal(t, ctx, result) + return + } + + md, ok := metadata.FromOutgoingContext(result) + require.True(t, ok, "expected outgoing metadata in context") + + for key, expectedVal := range tt.headers { + vals := md.Get(key) + require.Len(t, vals, 1, "expected exactly one value for key %s", key) + require.Equal(t, expectedVal, vals[0]) + } + }) + } +} + +func Test_ReSyncWithHeaders(t *testing.T) { + const target = "localBufCon" + + bufCon := bufconn.Listen(5) + receivedHeaders := make(chan map[string]string, 1) + + server := grpc.NewServer() + syncv1grpc.RegisterFlagSyncServiceServer(server, &headerCapturingServer{ + receivedHeaders: receivedHeaders, + response: &v1.FetchAllFlagsResponse{ + FlagConfiguration: "{}", + }, + }) + + go func() { + if err := server.Serve(bufCon); err != nil { + log.Fatalf("Server exited with error: %v", err) + } + }() + + dial, err := grpc.Dial(target, + grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) { + return bufCon.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + + grpcSync := Sync{ + URI: target, + Logger: logger.NewLogger(nil, false), + Headers: map[string]string{ + "x-proxy-gateway-host": "myhost.service", + "x-tenant-id": "tenant1", + }, + client: syncv1grpc.NewFlagSyncServiceClient(dial), + } + + syncChan := make(chan sync.DataSync, 1) + err = grpcSync.ReSync(context.Background(), syncChan) + require.NoError(t, err) + + select { + case headers := <-receivedHeaders: + require.Equal(t, "myhost.service", headers["x-proxy-gateway-host"]) + require.Equal(t, "tenant1", headers["x-tenant-id"]) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for headers") + } +} + +// headerCapturingServer captures incoming gRPC metadata headers +type headerCapturingServer struct { + syncv1grpc.UnimplementedFlagSyncServiceServer + receivedHeaders chan map[string]string + response *v1.FetchAllFlagsResponse +} + +func (s *headerCapturingServer) FetchAllFlags(ctx context.Context, _ *v1.FetchAllFlagsRequest) (*v1.FetchAllFlagsResponse, error) { + md, ok := metadata.FromIncomingContext(ctx) + headers := make(map[string]string) + if ok { + for k, v := range md { + if len(v) > 0 { + headers[k] = v[0] + } + } + } + s.receivedHeaders <- headers + return s.response, nil +} + +func (s *headerCapturingServer) SyncFlags(_ *v1.SyncFlagsRequest, _ syncv1grpc.FlagSyncService_SyncFlagsServer) error { + return nil +} + +func (s *headerCapturingServer) GetMetadata(_ context.Context, _ *v1.GetMetadataRequest) (*v1.GetMetadataResponse, error) { + return &v1.GetMetadataResponse{}, nil +} diff --git a/core/pkg/sync/http/http_sync.go b/core/pkg/sync/http/http_sync.go index 64a4682c0..e68f1f6d8 100644 --- a/core/pkg/sync/http/http_sync.go +++ b/core/pkg/sync/http/http_sync.go @@ -15,8 +15,8 @@ import ( "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/sync" + "github.com/open-feature/flagd/core/pkg/sync/internal/polling" "github.com/open-feature/flagd/core/pkg/utils" - "github.com/robfig/cron" "go.uber.org/zap" "golang.org/x/crypto/sha3" //nolint:gosec "golang.org/x/oauth2" @@ -26,10 +26,11 @@ import ( type Sync struct { uri string client Client - cron Cron + poller polling.Poller lastBodySHA string logger *logger.Logger authHeader string + headers map[string]string interval uint32 ready bool eTag string @@ -89,13 +90,6 @@ type Client interface { Do(req *http.Request) (*http.Response, error) } -// Cron defines the behaviour required of a cron -type Cron interface { - AddFunc(spec string, cmd func()) error - Start() - Stop() -} - func (hs *Sync) ReSync(ctx context.Context, dataSync chan<- sync.DataSync) error { msg, _, err := hs.fetchBody(ctx, true) if err != nil { @@ -114,7 +108,10 @@ func (hs *Sync) IsReady() bool { } func (hs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error { + hs.logger.Info(fmt.Sprintf("starting sync from %s (interval: %ds)", hs.uri, hs.interval)) + // Initial fetch + hs.logger.Debug(fmt.Sprintf("initial fetch from %s", hs.uri)) fetch, _, err := hs.fetchBody(ctx, true) if err != nil { return err @@ -123,8 +120,11 @@ func (hs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error { // Set ready state hs.ready = true - hs.logger.Debug(fmt.Sprintf("polling %s every %d seconds", hs.uri, hs.interval)) - _ = hs.cron.AddFunc(fmt.Sprintf("*/%d * * * *", hs.interval), func() { + hs.logger.Debug(fmt.Sprintf("polling %s every %ds (offset: %ds)", hs.uri, hs.interval, hs.poller.Offset())) + + dataSync <- sync.DataSync{FlagData: fetch, Source: hs.uri} + + hs.poller.Start(ctx, func() { hs.logger.Debug(fmt.Sprintf("fetching configuration from %s", hs.uri)) previousBodySHA := hs.lastBodySHA body, noChange, err := hs.fetchBody(ctx, false) @@ -147,16 +147,19 @@ func (hs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error { } }) - hs.cron.Start() - - dataSync <- sync.DataSync{FlagData: fetch, Source: hs.uri} - - <-ctx.Done() - hs.cron.Stop() - return nil } +func (hs *Sync) applyHeaders(req *http.Request) { + for key, value := range hs.headers { + if http.CanonicalHeaderKey(key) == "Host" { + req.Host = value + } else { + req.Header.Set(key, value) + } + } +} + func (hs *Sync) fetchBody(ctx context.Context, fetchAll bool) (string, bool, error) { if hs.uri == "" { return "", false, errors.New("no HTTP URL string set") @@ -177,6 +180,9 @@ func (hs *Sync) fetchBody(ctx context.Context, fetchAll bool) (string, bool, err if hs.eTag != "" && !fetchAll { req.Header.Set("If-None-Match", hs.eTag) } + + hs.applyHeaders(req) + client := hs.getClient() resp, err := client.Do(req) if err != nil { @@ -267,13 +273,7 @@ func (hs *Sync) getClient() Client { return client } -func NewHTTP(config sync.SourceConfig, logger *logger.Logger) *Sync { - // Default to 5 seconds - var interval uint32 = 5 - if config.Interval != 0 { - interval = config.Interval - } - +func NewHTTP(config sync.SourceConfig, logger *logger.Logger, poller polling.Poller, interval uint32) *Sync { var oauthCredential *oauthCredentialHandler if config.OAuth != nil { oauthCredential = &oauthCredentialHandler{ @@ -286,6 +286,11 @@ func NewHTTP(config sync.SourceConfig, logger *logger.Logger) *Sync { } } + canonicalHeaders := make(map[string]string, len(config.Headers)) + for k, v := range config.Headers { + canonicalHeaders[http.CanonicalHeaderKey(k)] = v + } + return &Sync{ uri: config.URI, logger: logger.WithFields( @@ -293,8 +298,9 @@ func NewHTTP(config sync.SourceConfig, logger *logger.Logger) *Sync { zap.String("sync", "http"), ), authHeader: config.AuthHeader, + headers: canonicalHeaders, interval: interval, - cron: cron.New(), + poller: poller, oauthCredential: oauthCredential, timeoutS: time.Duration(config.TimeoutS), } diff --git a/core/pkg/sync/http/http_sync_test.go b/core/pkg/sync/http/http_sync_test.go index 916d9f172..d099d0f42 100644 --- a/core/pkg/sync/http/http_sync_test.go +++ b/core/pkg/sync/http/http_sync_test.go @@ -35,11 +35,7 @@ func buildHeaders(m map[string][]string) http.Header { func TestSimpleSync(t *testing.T) { ctrl := gomock.NewController(t) - mockCron := synctesting.NewMockCron(ctrl) - mockCron.EXPECT().AddFunc(gomock.Any(), gomock.Any()).DoAndReturn(func(_ string, _ func()) error { - return nil - }) - mockCron.EXPECT().Start().Times(1) + mockPoller := synctesting.NewMockPoller() mockClient := syncmock.NewMockClient(ctrl) responseBody := "test response" @@ -53,13 +49,13 @@ func TestSimpleSync(t *testing.T) { httpSync := Sync{ uri: "http://localhost/flags", client: mockClient, - cron: mockCron, + poller: mockPoller, lastBodySHA: "", logger: logger.NewLogger(nil, false), } ctx := context.Background() - dataSyncChan := make(chan sync.DataSync) + dataSyncChan := make(chan sync.DataSync, 1) go func() { err := httpSync.Sync(ctx, dataSyncChan) @@ -78,11 +74,7 @@ func TestSimpleSync(t *testing.T) { func TestExtensionWithQSSync(t *testing.T) { ctrl := gomock.NewController(t) - mockCron := synctesting.NewMockCron(ctrl) - mockCron.EXPECT().AddFunc(gomock.Any(), gomock.Any()).DoAndReturn(func(_ string, _ func()) error { - return nil - }) - mockCron.EXPECT().Start().Times(1) + mockPoller := synctesting.NewMockPoller() mockClient := syncmock.NewMockClient(ctrl) responseBody := "test response" @@ -96,13 +88,13 @@ func TestExtensionWithQSSync(t *testing.T) { httpSync := Sync{ uri: "http://localhost/flags.json?env=dev", client: mockClient, - cron: mockCron, + poller: mockPoller, lastBodySHA: "", logger: logger.NewLogger(nil, false), } ctx := context.Background() - dataSyncChan := make(chan sync.DataSync) + dataSyncChan := make(chan sync.DataSync, 1) go func() { err := httpSync.Sync(ctx, dataSyncChan) @@ -328,6 +320,83 @@ func TestHTTPSync_Fetch(t *testing.T) { } } +func TestNewHTTP_PassesHeaders(t *testing.T) { + headers := map[string]string{"x-custom": "value"} + config := sync.SourceConfig{ + URI: "http://localhost", + Provider: "http", + Headers: headers, + } + httpSync := NewHTTP(config, logger.NewLogger(nil, false), nil, 5) + require.Equal(t, map[string]string{"X-Custom": "value"}, httpSync.headers) +} + +func TestHTTPSync_CustomHeaders(t *testing.T) { + tests := map[string]struct { + authHeader string + headers map[string]string + assertRequest func(t *testing.T, req *http.Request) + }{ + "injects custom headers": { + headers: map[string]string{"X-Interop-Gateway-Host": "myhost", "X-Tenant-ID": "tenant1"}, + assertRequest: func(t *testing.T, req *http.Request) { + require.Equal(t, "myhost", req.Header.Get("X-Interop-Gateway-Host")) + require.Equal(t, "tenant1", req.Header.Get("X-Tenant-ID")) + }, + }, + "sets Host header via req.Host": { + headers: map[string]string{"Host": "custom-host.example.com"}, + assertRequest: func(t *testing.T, req *http.Request) { + require.Equal(t, "custom-host.example.com", req.Host) + require.Empty(t, req.Header.Get("Host")) + }, + }, + "custom headers override authHeader": { + authHeader: "Bearer original-token", + headers: map[string]string{"Authorization": "Bearer custom-token", "X-Custom": "custom-value"}, + assertRequest: func(t *testing.T, req *http.Request) { + require.Equal(t, "Bearer custom-token", req.Header.Get("Authorization")) + require.Equal(t, "custom-value", req.Header.Get("X-Custom")) + }, + }, + "authHeader preserved when not overridden": { + authHeader: "Bearer token123", + headers: map[string]string{"X-Custom": "custom-value"}, + assertRequest: func(t *testing.T, req *http.Request) { + require.Equal(t, "Bearer token123", req.Header.Get("Authorization")) + require.Equal(t, "custom-value", req.Header.Get("X-Custom")) + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := syncmock.NewMockClient(ctrl) + + mockClient.EXPECT().Do(gomock.Any()).DoAndReturn(func(req *http.Request) (*http.Response, error) { + tt.assertRequest(t, req) + return &http.Response{ + Header: buildHeaders(map[string][]string{"Content-Type": {"application/json"}}), + Body: io.NopCloser(strings.NewReader("{}")), + StatusCode: http.StatusOK, + }, nil + }) + + httpSync := Sync{ + uri: "http://localhost", + client: mockClient, + authHeader: tt.authHeader, + headers: tt.headers, + logger: logger.NewLogger(nil, false), + } + + _, err := httpSync.Fetch(context.Background()) + require.NoError(t, err) + }) + } +} + func TestHTTPSync_Resync(t *testing.T) { ctrl := gomock.NewController(t) source := "http://localhost" @@ -479,7 +548,7 @@ func TestHTTPSync_getClient(t *testing.T) { l := logger.NewLogger(nil, false) for name, tt := range tests { t.Run(name, func(t *testing.T) { - httpSync := NewHTTP(tt.config, l) + httpSync := NewHTTP(tt.config, l, synctesting.NewMockPoller(), 5) if tt.client != nil { // we have a cached HTTP client already httpSync.client = tt.client @@ -557,7 +626,7 @@ func TestHTTPSync_OAuth(t *testing.T) { defer ts.Close() l := logger.NewLogger(nil, false) s := NewHTTP(sync.SourceConfig{ - URI: ts.URL, + URI: ts.URL, AuthHeader: "Bearer it_should_be_replaced_by_oauth", OAuth: &sync.OAuthCredentialHandler{ ClientID: clientID, @@ -565,7 +634,7 @@ func TestHTTPSync_OAuth(t *testing.T) { TokenURL: ts.URL + oauthPath, ReloadDelayS: 10000, }, - }, l) + }, l, synctesting.NewMockPoller(), 5) d := make(chan sync.DataSync, 1) // when we call resync multiple times err := s.ReSync(context.Background(), d) @@ -620,11 +689,14 @@ func TestHTTPSync_OAuthFolderSecrets(t *testing.T) { return } else if strings.HasSuffix(r.URL.Path, flagsPath) { // mock flags response - io.ReadAll(r.Body) + _, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("cannot read request: %v", err) + } w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") - _, err := w.Write([]byte(fmt.Sprintf(`{"flagKey": {"default": true}}`))) + _, err = w.Write([]byte(fmt.Sprintf(`{"flagKey": {"default": true}}`))) if err != nil { t.Fatalf("cannot write response: %v", err) } @@ -650,7 +722,7 @@ func TestHTTPSync_OAuthFolderSecrets(t *testing.T) { l := logger.NewLogger(nil, false) s := NewHTTP(sync.SourceConfig{ - URI: ts.URL + flagsPath, + URI: ts.URL + flagsPath, AuthHeader: "Bearer it_should_be_replaced_by_oauth", OAuth: &sync.OAuthCredentialHandler{ ClientID: clientID, @@ -659,7 +731,7 @@ func TestHTTPSync_OAuthFolderSecrets(t *testing.T) { TokenURL: ts.URL + oauthPath, ReloadDelayS: 0, // we force loading the secret at each req }, - }, l) + }, l, synctesting.NewMockPoller(), 5) d := make(chan sync.DataSync, 2) // when we fire the HTTP call err = s.ReSync(context.Background(), d) diff --git a/core/pkg/sync/internal/bloburi/bloburi.go b/core/pkg/sync/internal/bloburi/bloburi.go new file mode 100644 index 000000000..8f497a34e --- /dev/null +++ b/core/pkg/sync/internal/bloburi/bloburi.go @@ -0,0 +1,32 @@ +// Package bloburi splits and rejoins blob sync URIs (s3://, gs://, azblob://). +package bloburi + +import ( + "regexp" + "strings" +) + +// Split parses "scheme://bucket/key?opt=1" into bucket URL ("scheme://bucket?opt=1") +// and object key ("key"). The query moves to the bucket URL because gocloud blob +// drivers read driver options (e.g. s3blob use_path_style, region) from there. +// schemeRegex must match through the first "/" after the scheme (e.g. "^s3://.+?/"). +func Split(uri string, schemeRegex *regexp.Regexp) (bucket, object string) { + raw, query, hasQuery := strings.Cut(uri, "?") + bucket = schemeRegex.FindString(raw) + object = schemeRegex.ReplaceAllString(raw, "") + if hasQuery && query != "" { + bucket = strings.TrimSuffix(bucket, "/") + "?" + query + } + return bucket, object +} + +// Join is the inverse of Split. The reconstructed URI must match what was +// registered in the store (see flagd#1971). +func Join(bucket, object string) string { + i := strings.Index(bucket, "?") + if i < 0 { + return bucket + object + } + base := strings.TrimSuffix(bucket[:i], "/") + "/" + return base + object + bucket[i:] +} diff --git a/core/pkg/sync/internal/bloburi/bloburi_test.go b/core/pkg/sync/internal/bloburi/bloburi_test.go new file mode 100644 index 000000000..f75bdeaf3 --- /dev/null +++ b/core/pkg/sync/internal/bloburi/bloburi_test.go @@ -0,0 +1,86 @@ +package bloburi + +import ( + "regexp" + "testing" +) + +var schemeRegexes = map[string]*regexp.Regexp{ + "s3": regexp.MustCompile("^s3://.+?/"), + "gs": regexp.MustCompile("^gs://.+?/"), + "azblob": regexp.MustCompile("^azblob://.+?/"), +} + +func TestSplit(t *testing.T) { + tests := map[string]struct { + uri string + scheme string + bucket string + object string + }{ + "s3 simple": { + uri: "s3://my-bucket/flags.json", + scheme: "s3", + bucket: "s3://my-bucket/", + object: "flags.json", + }, + "s3 with query (use_path_style)": { + uri: "s3://my-bucket/example_flags.json?use_path_style=true®ion=garage&endpoint=http://127.0.0.1:3900", + scheme: "s3", + bucket: "s3://my-bucket?use_path_style=true®ion=garage&endpoint=http://127.0.0.1:3900", + object: "example_flags.json", + }, + "gs simple": { + uri: "gs://my-bucket/path/to/object", + scheme: "gs", + bucket: "gs://my-bucket/", + object: "path/to/object", + }, + "azblob simple": { + uri: "azblob://my-bucket/flags.yaml", + scheme: "azblob", + bucket: "azblob://my-bucket/", + object: "flags.yaml", + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + bucket, object := Split(tt.uri, schemeRegexes[tt.scheme]) + if bucket != tt.bucket { + t.Errorf("Split bucket = %q, want %q", bucket, tt.bucket) + } + if object != tt.object { + t.Errorf("Split object = %q, want %q", object, tt.object) + } + }) + } +} + +// TestSplitJoinInverse ensures that Join is the inverse of Split +func TestSplitJoinInverse(t *testing.T) { + uris := []string{ + "s3://b/o", + "s3://b/path/to/object", + "s3://b/o?use_path_style=true", + "s3://b/o?use_path_style=true®ion=garage&endpoint=http://127.0.0.1:3900", + "s3://b/path/to/object?a=1&b=2", + "gs://b/o", + "gs://b/path/to/object", + "azblob://b/o", + "azblob://b/flags.yaml", + } + for _, uri := range uris { + t.Run(uri, func(t *testing.T) { + for _, reg := range schemeRegexes { + if !reg.MatchString(uri) { + continue + } + bucket, object := Split(uri, reg) + if got := Join(bucket, object); got != uri { + t.Errorf("Join(Split(%q)) = %q, want %q (bucket=%q object=%q)", + uri, got, uri, bucket, object) + } + } + }) + } +} diff --git a/core/pkg/sync/internal/polling/poller.go b/core/pkg/sync/internal/polling/poller.go new file mode 100644 index 000000000..aad50732f --- /dev/null +++ b/core/pkg/sync/internal/polling/poller.go @@ -0,0 +1,113 @@ +package polling + +import ( + "context" + "fmt" + "hash/fnv" + "time" + + "github.com/robfig/cron/v3" +) + +// MaxInterval is the largest supported polling interval in seconds (1 day). +// OffsetSchedule uses seconds-since-midnight math, so intervals beyond a day +// would wrap around and produce incorrect fire times. +const MaxInterval uint32 = 86400 + +// OffsetSchedule is a cron.Schedule that fires every `interval` seconds, +// aligned to wall-clock time but shifted by `offset` seconds. +// For example, with interval=30 and offset=7, it fires at :07 and :37 of +// every minute. With offset=0, it is equivalent to the cron expression +// "*/interval * * * *" (using the 6-field format where the first field is seconds). +// +// This allows multiple instances with different offsets (derived from a seed) +// to avoid polling at the same instant (thundering herd), while remaining +// deterministic across restarts. +type OffsetSchedule struct { + Interval uint32 + Offset uint32 +} + +// Next returns the next activation time after t. +func (s OffsetSchedule) Next(t time.Time) time.Time { + if s.Interval == 0 { + return t.Add(time.Second) + } + + // seconds since midnight in the local timezone + hour, min, sec := t.Clock() + now := int64(hour*3600 + min*60 + sec) + interval := int64(s.Interval) + offset := int64(s.Offset) + + // seconds since the last fire + sinceLastFire := (now - offset%interval + interval) % interval + lastFire := now - sinceLastFire + + // the next fire time + nextFire := lastFire + interval + + delta := nextFire - now + // truncate to the start of the current second, then add delta seconds + return t.Truncate(time.Second).Add(time.Duration(delta) * time.Second) +} + +// pollOffset computes a deterministic offset from a seed string. +// Returns 0 if seed is empty. +// fnv32a is a fast, well-distributed non-cryptographic hash; +// we only need even distribution across [0, interval), not collision resistance. +func pollOffset(seed string, interval uint32) uint32 { + if seed == "" || interval == 0 { + return 0 + } + h := fnv.New32a() + h.Write([]byte(seed)) + return h.Sum32() % interval +} + +// Poller schedules a recurring callback. Start blocks until ctx is cancelled. +type Poller interface { + Start(ctx context.Context, callback func()) + // Offset returns the schedule offset in seconds (0 when no seed is configured). + Offset() uint32 +} + +// CronPoller is a Poller backed by a wall-clock-aligned cron schedule with a +// deterministic offset derived from a seed. +type CronPoller struct { + cr *cron.Cron + interval uint32 + offset uint32 +} + +// NewCronPoller creates a CronPoller. If intervalSeed is empty, offset defaults to +// 0 (equivalent to the legacy wall-clock-aligned behavior). +// Returns an error if interval exceeds MaxInterval. +func NewCronPoller(interval uint32, intervalSeed string) (*CronPoller, error) { + if interval > MaxInterval { + return nil, fmt.Errorf("polling interval %ds exceeds maximum %ds", interval, MaxInterval) + } + return &CronPoller{ + cr: cron.New(), + interval: interval, + offset: pollOffset(intervalSeed, interval), + }, nil +} + +// Offset returns the computed schedule offset in seconds. +func (p *CronPoller) Offset() uint32 { + return p.offset +} + +// Start schedules the callback and blocks until ctx is cancelled. +func (p *CronPoller) Start(ctx context.Context, callback func()) { + schedule := OffsetSchedule{ + Interval: p.interval, + Offset: p.offset, + } + p.cr.Schedule(schedule, cron.FuncJob(callback)) + p.cr.Start() + + <-ctx.Done() + <-p.cr.Stop().Done() +} diff --git a/core/pkg/sync/internal/polling/poller_test.go b/core/pkg/sync/internal/polling/poller_test.go new file mode 100644 index 000000000..ee384b8ad --- /dev/null +++ b/core/pkg/sync/internal/polling/poller_test.go @@ -0,0 +1,196 @@ +package polling + +import ( + "testing" + "time" +) + +// helper to build a time on a fixed date (2025-01-15) in UTC +func at(h, m, s int) time.Time { + return time.Date(2025, 1, 15, h, m, s, 0, time.UTC) +} + +// helper for times that cross the day boundary (day offset from Jan 15) +func atDay(dayOffset, h, m, s int) time.Time { + return time.Date(2025, 1, 15+dayOffset, h, m, s, 0, time.UTC) +} + +func atNano(h, m, s, ns int) time.Time { + return time.Date(2025, 1, 15, h, m, s, ns, time.UTC) +} + +// pick an arbitrary time and verify that that next poll is as expected +// ie: if interval is 30s, offset is 0, and wall-clock time is 1h:06m:17s, the next poll is 1h:06m:30s +func TestOffsetSchedule_Next(t *testing.T) { + tests := []struct { + name string + interval uint32 + offset uint32 + now time.Time + expected time.Time + }{ + // zero offset + {"zero offset, mid-interval", 30, 0, at(14, 3, 17), at(14, 3, 30)}, + {"zero offset, on boundary", 30, 0, at(14, 3, 30), at(14, 4, 0)}, + {"zero offset, on :00 boundary", 30, 0, at(14, 3, 0), at(14, 3, 30)}, + + // with offset + {"offset=7, mid-interval", 30, 7, at(14, 3, 17), at(14, 3, 37)}, + {"offset=7, on boundary", 30, 7, at(14, 3, 37), at(14, 4, 7)}, + {"offset=7, just before boundary", 30, 7, at(14, 3, 5), at(14, 3, 7)}, + + // small interval + {"interval=5, mid-interval", 5, 0, at(14, 3, 17), at(14, 3, 20)}, + + // sub-second truncation + {"sub-second truncated", 30, 0, atNano(14, 3, 17, 500000000), at(14, 3, 30)}, + + // large offset relative to interval + {"offset=29, interval=30", 30, 29, at(14, 3, 58), at(14, 3, 59)}, + {"offset=29, on boundary", 30, 29, at(14, 3, 59), at(14, 4, 29)}, + + // large interval (2 minutes) + {"interval=120, offset=10", 120, 10, at(14, 3, 17), at(14, 4, 10)}, + + // boundary crossings + {"hour boundary", 30, 0, at(14, 59, 45), at(15, 0, 0)}, + {"day boundary", 30, 0, at(23, 59, 45), atDay(1, 0, 0, 0)}, + + // zero interval falls back to 1s + {"zero interval", 0, 0, at(14, 3, 17), at(14, 3, 18)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := OffsetSchedule{Interval: tt.interval, Offset: tt.offset} + got := s.Next(tt.now) + if !got.Equal(tt.expected) { + t.Errorf("expected %v, got %v", tt.expected, got) + } + }) + } + + // large interval: verify int64 math prevents uint32 overflow + t.Run("max uint32 interval does not overflow", func(t *testing.T) { + now := at(0, 0, 1) + s := OffsetSchedule{Interval: 1<<32 - 1, Offset: 0} + got := s.Next(now) + if !got.After(now) { + t.Errorf("expected time after %v, got %v", now, got) + } + }) +} + +func TestPollOffset(t *testing.T) { + tests := []struct { + name string + seed string + interval uint32 + wantZero bool + }{ + {"empty seed", "", 30, true}, + {"zero interval", "test", 0, true}, + {"pod-a", "pod-a", 30, false}, + {"pod-b", "pod-b", 30, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + offset := pollOffset(tt.seed, tt.interval) + if tt.wantZero && offset != 0 { + t.Errorf("expected 0, got %d", offset) + } + if !tt.wantZero && offset >= tt.interval { + t.Errorf("offset %d should be less than interval %d", offset, tt.interval) + } + }) + } +} + +func TestPollOffset_Deterministic(t *testing.T) { + a := pollOffset("my-pod", 30) + b := pollOffset("my-pod", 30) + if a != b { + t.Errorf("expected deterministic offset, got %d and %d", a, b) + } +} + +func TestPollOffset_DifferentSeeds(t *testing.T) { + a := pollOffset("pod-alpha", 60) + b := pollOffset("pod-beta", 60) + if a == b { + t.Fatalf("warning: different seeds produced the same offset %d", a) + } +} + +func TestCronPoller_Offset(t *testing.T) { + tests := []struct { + name string + interval uint32 + seed string + wantZero bool + }{ + {"no seed returns 0", 30, "", true}, + {"zero interval returns 0", 0, "my-pod", true}, + {"with seed returns non-zero", 30, "my-pod", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, err := NewCronPoller(tt.interval, tt.seed) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := p.Offset() + if tt.wantZero && got != 0 { + t.Errorf("expected offset 0, got %d", got) + } + if !tt.wantZero && got >= tt.interval { + t.Errorf("offset %d should be less than interval %d", got, tt.interval) + } + }) + } +} + +func TestCronPoller_Offset_MatchesPollOffset(t *testing.T) { + // Offset() should return the same value as pollOffset() for the same inputs + p, err := NewCronPoller(60, "my-pod") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := pollOffset("my-pod", 60) + if p.Offset() != expected { + t.Errorf("expected Offset() = %d, got %d", expected, p.Offset()) + } +} + +func TestCronPoller_Offset_Deterministic(t *testing.T) { + pa, err := NewCronPoller(30, "my-pod") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + pb, err := NewCronPoller(30, "my-pod") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pa.Offset() != pb.Offset() { + t.Errorf("expected deterministic offset, got %d and %d", pa.Offset(), pb.Offset()) + } +} + +func TestNewCronPoller_ExceedsMaxInterval(t *testing.T) { + _, err := NewCronPoller(MaxInterval+1, "my-pod") + if err == nil { + t.Fatal("expected error for interval exceeding MaxInterval") + } +} + +func TestNewCronPoller_AtMaxInterval(t *testing.T) { + p, err := NewCronPoller(MaxInterval, "my-pod") + if err != nil { + t.Fatalf("unexpected error at MaxInterval: %v", err) + } + if p.Offset() >= MaxInterval { + t.Errorf("offset %d should be less than MaxInterval %d", p.Offset(), MaxInterval) + } +} diff --git a/core/pkg/sync/isync.go b/core/pkg/sync/isync.go index b58ce2f7d..dc1e1de52 100644 --- a/core/pkg/sync/isync.go +++ b/core/pkg/sync/isync.go @@ -32,6 +32,12 @@ type DataSync struct { SyncContext *structpb.Struct Source string Selector string + + // When true, the store scopes deletion to only the flagSetIds present in + // this payload rather than wiping all flags for the source. This must be + // explicitly opted-in per source via SourceConfig.IncrementalUpdates. + // EXPERIMENTAL: this option may change or be removed in a future release. + IncrementalUpdates bool } // SourceConfig is configuration option for flagd. This maps to startup parameter sources @@ -39,14 +45,26 @@ type SourceConfig struct { URI string `json:"uri"` Provider string `json:"provider"` - AuthHeader string `json:"authHeader,omitempty"` - CertPath string `json:"certPath,omitempty"` - TLS bool `json:"tls,omitempty"` - ProviderID string `json:"providerID,omitempty"` - Selector string `json:"selector,omitempty"` - Interval uint32 `json:"interval,omitempty"` - MaxMsgSize int `json:"maxMsgSize,omitempty"` - TimeoutS int `json:"timeoutS,omitempty"` + AuthHeader string `json:"authHeader,omitempty"` + CertPath string `json:"certPath,omitempty"` + TLS bool `json:"tls,omitempty"` + ProviderID string `json:"providerID,omitempty"` + Selector string `json:"selector,omitempty"` + Interval uint32 `json:"interval,omitempty"` + IntervalSeed string `json:"intervalSeed,omitempty"` + MaxMsgSize int `json:"maxMsgSize,omitempty"` + TimeoutS int `json:"timeoutS,omitempty"` + + // IncrementalUpdates opts this source into per-flagSetId scoped deletion. + // When false (default), each update replaces all flags for the source. + // When true, only flags matching the flagSetIds in the payload are replaced, + // allowing flags from other flagSetIds to accumulate across updates. + // EXPERIMENTAL: this option may change or be removed in a future release. + // Note: flags from removed or renamed flagSetIds will not be automatically + // cleaned up; a restart or explicit empty update for the old flagSetId is + // required to purge them. + IncrementalUpdates bool `json:"incrementalUpdates,omitempty"` + Headers map[string]string `json:"headers,omitempty"` OAuth *OAuthCredentialHandler `json:"oauth,omitempty"` } diff --git a/core/pkg/sync/kubernetes/kubernetes_sync.go b/core/pkg/sync/kubernetes/kubernetes_sync.go index 21f08420a..e3d7e7962 100644 --- a/core/pkg/sync/kubernetes/kubernetes_sync.go +++ b/core/pkg/sync/kubernetes/kubernetes_sync.go @@ -6,11 +6,11 @@ import ( "fmt" "strings" msync "sync" + "sync/atomic" "time" "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/sync" - "github.com/open-feature/open-feature-operator/apis/core/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -19,6 +19,8 @@ import ( "k8s.io/client-go/dynamic/dynamicinformer" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/tools/cache" + + "github.com/open-feature/open-feature-operator/api/core/v1beta1" ) var ( @@ -27,12 +29,14 @@ var ( featureFlagResource = v1beta1.GroupVersion.WithResource("featureflags") ) +const invalidAPIVersionMsg = "invalid api version %s, expected %s" + type SyncOption func(s *Sync) type Sync struct { URI string - ready bool + ready atomic.Bool namespace string crdName string logger *logger.Logger @@ -83,13 +87,14 @@ func (k *Sync) Init(_ context.Context) error { } func (k *Sync) IsReady() bool { - return k.ready + return k.ready.Load() } func (k *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error { - k.logger.Info(fmt.Sprintf("starting kubernetes sync notifier for resource: %s", k.URI)) + k.logger.Info(fmt.Sprintf("starting sync from %s", k.URI)) // Initial fetch + k.logger.Debug(fmt.Sprintf("initial fetch from %s", k.URI)) fetch, err := k.fetch(ctx) if err != nil { err = fmt.Errorf("error with the initial fetch: %w", err) @@ -99,22 +104,26 @@ func (k *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error { dataSync <- sync.DataSync{FlagData: fetch, Source: k.URI} - notifies := make(chan INotify) + k.logger.Debug(fmt.Sprintf("watching %s for changes", k.URI)) + + // Buffer ensures notifier can publish the initial Ready event even if the watcher + // goroutine has not started reading yet. + notifies := make(chan INotify, 1) var wg msync.WaitGroup - // Start K8s resource notifier + // Start notifier watcher wg.Add(1) go func() { defer wg.Done() - k.notify(ctx, notifies) + k.watcher(ctx, notifies, dataSync) }() - // Start notifier watcher + // Start K8s resource notifier wg.Add(1) go func() { defer wg.Done() - k.watcher(ctx, notifies, dataSync) + k.notify(ctx, notifies) }() wg.Wait() @@ -150,7 +159,7 @@ func (k *Sync) watcher(ctx context.Context, notifies chan INotify, dataSync chan k.logger.Debug("configuration deleted") case DefaultEventTypeReady: k.logger.Debug("notifier ready") - k.ready = true + k.ready.Store(true) } } } @@ -239,7 +248,7 @@ func commonHandler(obj interface{}, object types.NamespacedName, emitEvent Defau } if ffObj.APIVersion != apiVersion { - return fmt.Errorf("invalid api version %s, expected %s", ffObj.APIVersion, apiVersion) + return fmt.Errorf(invalidAPIVersionMsg, ffObj.APIVersion, apiVersion) } if ffObj.Name == object.Name { @@ -261,7 +270,7 @@ func updateFuncHandler(oldObj interface{}, newObj interface{}, object types.Name } if ffOldObj.APIVersion != apiVersion { - return fmt.Errorf("invalid api version %s, expected %s", ffOldObj.APIVersion, apiVersion) + return fmt.Errorf(invalidAPIVersionMsg, ffOldObj.APIVersion, apiVersion) } ffNewObj, err := toFFCfg(newObj) @@ -270,11 +279,10 @@ func updateFuncHandler(oldObj interface{}, newObj interface{}, object types.Name } if ffNewObj.APIVersion != apiVersion { - return fmt.Errorf("invalid api version %s, expected %s", ffNewObj.APIVersion, apiVersion) + return fmt.Errorf(invalidAPIVersionMsg, ffNewObj.APIVersion, apiVersion) } if object.Name == ffNewObj.Name && ffOldObj.ResourceVersion != ffNewObj.ResourceVersion { - // Only update if there is an actual featureFlagSpec change c <- &Notifier{ Event: Event[DefaultEventType]{ EventType: DefaultEventTypeModify, @@ -284,20 +292,34 @@ func updateFuncHandler(oldObj interface{}, newObj interface{}, object types.Name return nil } -// toFFCfg attempts to covert unstructured payload to configurations func toFFCfg(object interface{}) (*v1beta1.FeatureFlag, error) { u, ok := object.(*unstructured.Unstructured) if !ok { - return nil, fmt.Errorf("provided value is not of type *unstructured.Unstructured") + if tombstone, tOk := object.(cache.DeletedFinalStateUnknown); tOk { + u, ok = tombstone.Obj.(*unstructured.Unstructured) + if !ok { + return nil, fmt.Errorf("tombstone object is not of type *unstructured.Unstructured") + } + } else { + return nil, fmt.Errorf("provided value is not of type *unstructured.Unstructured") + } } - var ffObj v1beta1.FeatureFlag - err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &ffObj) - if err != nil { - return nil, fmt.Errorf("unable to convert unstructured to v1beta1.FeatureFlag: %w", err) + ff := &v1beta1.FeatureFlag{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, ff); err != nil { + return nil, fmt.Errorf("unable to convert unstructured to FeatureFlag: %w", err) } - return &ffObj, nil + return ff, nil +} + +// marshallFeatureFlagSpec marshals the FlagSpec of a FeatureFlag to JSON +func marshallFeatureFlagSpec(ff *v1beta1.FeatureFlag) (string, error) { + b, err := json.Marshal(ff.Spec.FlagSpec) + if err != nil { + return "", fmt.Errorf("failed to marshal FlagSpec: %w", err) + } + return string(b), nil } // parseURI parse provided uri in the format of / to namespace, crdName. Results in an error @@ -309,11 +331,3 @@ func parseURI(uri string) (string, string, error) { } return s[0], s[1], nil } - -func marshallFeatureFlagSpec(ff *v1beta1.FeatureFlag) (string, error) { - b, err := json.Marshal(ff.Spec.FlagSpec) - if err != nil { - return "", fmt.Errorf("failed to marshall FlagSpec: %s", err.Error()) - } - return string(b), nil -} diff --git a/core/pkg/sync/kubernetes/kubernetes_sync_test.go b/core/pkg/sync/kubernetes/kubernetes_sync_test.go index 51a553264..9f25600fe 100644 --- a/core/pkg/sync/kubernetes/kubernetes_sync_test.go +++ b/core/pkg/sync/kubernetes/kubernetes_sync_test.go @@ -7,12 +7,13 @@ import ( "fmt" "reflect" "strings" + stdsync "sync" "testing" "time" "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/sync" - "github.com/open-feature/open-feature-operator/apis/core/v1beta1" + "github.com/open-feature/open-feature-operator/api/core/v1beta1" "github.com/stretchr/testify/require" "go.uber.org/zap/zapcore" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -85,9 +86,21 @@ func Test_toFFCfg(t *testing.T) { wantErr bool }{ { - name: "Simple success", - input: toUnstructured(t, validFFCfg), - want: &validFFCfg, + name: "Simple success", + input: toUnstructured(t, validFFCfg), + want: &v1beta1.FeatureFlag{ + TypeMeta: Metadata, + }, + wantErr: false, + }, + { + name: "Tombstone unwraps", + input: cache.DeletedFinalStateUnknown{ + Obj: toUnstructured(t, validFFCfg), + }, + want: &v1beta1.FeatureFlag{ + TypeMeta: Metadata, + }, wantErr: false, }, { @@ -620,13 +633,13 @@ func TestSync_ReSync(t *testing.T) { tests := []struct { name string - k Sync + k *Sync countMsg int async bool }{ { name: "Happy Path", - k: Sync{ + k: &Sync{ URI: fmt.Sprintf("%s/%s", ns, name), dynamicClient: fakeDynamicClient, namespace: ns, @@ -637,7 +650,7 @@ func TestSync_ReSync(t *testing.T) { }, { name: "CRD not found", - k: Sync{ + k: &Sync{ URI: fmt.Sprintf("doesnt%s/exist%s", ns, name), dynamicClient: fakeDynamicClient, namespace: ns, @@ -657,24 +670,36 @@ func TestSync_ReSync(t *testing.T) { t.Errorf("The Sync should not be ready") } dataChannel := make(chan sync.DataSync, tt.countMsg) + ctx, cancel := context.WithCancel(context.Background()) if tt.async { + var wg stdsync.WaitGroup + wg.Add(1) go func() { - if err := tt.k.Sync(context.TODO(), dataChannel); err != nil { - t.Errorf("Unexpected error: %v", e) - } - if err := tt.k.ReSync(context.TODO(), dataChannel); err != nil { - t.Errorf("Unexpected error: %v", e) + defer wg.Done() + if err := tt.k.Sync(ctx, dataChannel); err != nil { + t.Errorf("Unexpected error: %v", err) } }() - i := tt.countMsg - for i > 0 { - d := <-dataChannel - if d.FlagData != payload { - t.Errorf("Expected %v, got %v", payload, d.FlagData) + + if err := tt.k.ReSync(ctx, dataChannel); err != nil { + t.Errorf("Unexpected error: %v", err) + } + + for i := tt.countMsg; i > 0; i-- { + select { + case d := <-dataChannel: + if d.FlagData != payload { + t.Errorf("Expected %v, got %v", payload, d.FlagData) + } + case <-time.After(time.Second): + t.Fatalf("timeout waiting for data") } - i-- } + + cancel() + wg.Wait() } else { + defer cancel() if err := tt.k.Sync(context.TODO(), dataChannel); !strings.Contains(err.Error(), "not found") { t.Errorf("Unexpected error: %v", err) } @@ -689,11 +714,16 @@ func TestSync_ReSync(t *testing.T) { func TestNotify(t *testing.T) { const name = "myFF" const ns = "myNS" - s := runtime.NewScheme() - ff := &unstructured.Unstructured{} - cfg := getCFG(name, ns) - ff.SetUnstructuredContent(cfg) - fc := fake.NewSimpleDynamicClient(s, ff) + // Use scheme.Scheme so the fake client knows the list kind for featureflags. + // Do NOT pre-populate the fake client: client-go v0.33+ uses RealFIFO which + // only delivers events via the watch stream, not via the initial List/Replace. + // Pre-populating causes the object to silently land in the store without + // triggering AddFunc, so later UpdateStatus calls are seen as updates on an + // object the informer has never "added" โ€” leaving the test waiting forever. + if err := v1beta1.AddToScheme(scheme.Scheme); err != nil { + t.Fatalf("failed to add v1beta1 to scheme: %v", err) + } + fc := fake.NewSimpleDynamicClient(scheme.Scheme) l, err := logger.NewZapLogger(zapcore.FatalLevel, "console") if err != nil { t.Errorf("Unexpected error: %v", err) @@ -723,12 +753,11 @@ func TestNotify(t *testing.T) { if msg.GetEvent().EventType != DefaultEventTypeReady { t.Errorf("Expected message %v, got %v", DefaultEventTypeReady, msg) } - // create - cfg["status"] = map[string]interface{}{ - "empty": "", - } + // create: explicitly create the object via the watch stream so AddFunc fires + ff := &unstructured.Unstructured{} + cfg := getCFG(name, ns) ff.SetUnstructuredContent(cfg) - _, err = fc.Resource(featureFlagResource).Namespace(ns).UpdateStatus(context.TODO(), ff, v1.UpdateOptions{}) + _, err = fc.Resource(featureFlagResource).Namespace(ns).Create(context.TODO(), ff, v1.CreateOptions{}) if err != nil { t.Errorf("Unexpected error: %v", err) } @@ -741,7 +770,7 @@ func TestNotify(t *testing.T) { old["resourceVersion"] = "newVersion" cfg["metadata"] = old ff.SetUnstructuredContent(cfg) - _, err = fc.Resource(featureFlagResource).Namespace(ns).UpdateStatus(context.TODO(), ff, v1.UpdateOptions{}) + _, err = fc.Resource(featureFlagResource).Namespace(ns).Update(context.TODO(), ff, v1.UpdateOptions{}) if err != nil { t.Errorf("Unexpected error: %v", err) } @@ -772,7 +801,7 @@ func TestNotify(t *testing.T) { "bump": "1", } ff.SetUnstructuredContent(cfg) - _, err = fc.Resource(featureFlagResource).Namespace(ns).UpdateStatus(context.TODO(), ff, v1.UpdateOptions{}) + _, err = fc.Resource(featureFlagResource).Namespace(ns).Update(context.TODO(), ff, v1.UpdateOptions{}) if err != nil { t.Errorf("Unexpected error: %v", err) } @@ -817,12 +846,16 @@ func getCFG(name, namespace string) map[string]interface{} { "name": name, "namespace": namespace, }, - "spec": map[string]interface{}{}, + "spec": map[string]interface{}{ + "flagSpec": map[string]interface{}{ + "flags": nil, + }, + }, } } // toUnstructured helper to convert an interface to unstructured.Unstructured -func toUnstructured(t *testing.T, obj interface{}) interface{} { +func toUnstructured(t *testing.T, obj interface{}) *unstructured.Unstructured { bytes, err := json.Marshal(obj) if err != nil { t.Errorf("test setup faulure: %s", err.Error()) @@ -851,7 +884,7 @@ func (m *MockInformer) GetStore() cache.Store { return &m.fakeStore } -func TestMeasure(t *testing.T) { +func TestMarshallFeatureFlagSpec(t *testing.T) { res, err := marshallFeatureFlagSpec(&v1beta1.FeatureFlag{ Spec: v1beta1.FeatureFlagSpec{ FlagSpec: v1beta1.FlagSpec{ @@ -866,6 +899,39 @@ func TestMeasure(t *testing.T) { }, }) - require.Nil(t, err) - require.Equal(t, "{\"flags\":{\"flag\":{\"state\":\"\",\"variants\":null,\"defaultVariant\":\"kubernetes\"}}}", res) + require.NoError(t, err) + require.JSONEq(t, `{"flags":{"flag":{"state":"","variants":null,"defaultVariant":"kubernetes"}}}`, res) +} + +func TestMarshallFeatureFlagSpec_metadata(t *testing.T) { + res, err := marshallFeatureFlagSpec(&v1beta1.FeatureFlag{ + Spec: v1beta1.FeatureFlagSpec{ + FlagSpec: v1beta1.FlagSpec{ + Metadata: json.RawMessage(`{"scope":"app-1"}`), + Flags: v1beta1.Flags{ + FlagsMap: map[string]v1beta1.Flag{ + "flag-with-meta": { + State: "ENABLED", + DefaultVariant: "on", + Variants: json.RawMessage(`{"on":true,"off":false}`), + Metadata: json.RawMessage(`{"owner":"team-x"}`), + }, + }, + }, + }, + }, + }) + + require.NoError(t, err) + require.JSONEq(t, `{ + "metadata": {"scope": "app-1"}, + "flags": { + "flag-with-meta": { + "state": "ENABLED", + "variants": {"on": true, "off": false}, + "defaultVariant": "on", + "metadata": {"owner": "team-x"} + } + } + }`, res) } diff --git a/core/pkg/sync/testing/mock_cron.go b/core/pkg/sync/testing/mock_cron.go index 4039208ee..bce56d987 100644 --- a/core/pkg/sync/testing/mock_cron.go +++ b/core/pkg/sync/testing/mock_cron.go @@ -1,74 +1,57 @@ package testing import ( - "reflect" - - "go.uber.org/mock/gomock" + "context" + "sync" ) -// MockCron is a mock of Cron interface. -type MockCron struct { - ctrl *gomock.Controller - recorder *MockCronMockRecorder - cmd func() -} - -// MockCronMockRecorder is the mock recorder for MockCron. -type MockCronMockRecorder struct { - mock *MockCron -} - -// NewMockCron creates a new mock instance. -func NewMockCron(ctrl *gomock.Controller) *MockCron { - mock := &MockCron{ctrl: ctrl} - mock.recorder = &MockCronMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockCron) EXPECT() *MockCronMockRecorder { - return m.recorder -} - -// AddFunc mocks base method. -func (m *MockCron) AddFunc(spec string, cmd func()) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AddFunc", spec, cmd) - ret0, _ := ret[0].(error) - m.cmd = cmd - return ret0 -} - -func (m *MockCron) Tick() { - m.cmd() -} - -// AddFunc indicates an expected call of AddFunc. -func (mr *MockCronMockRecorder) AddFunc(spec, cmd any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddFunc", reflect.TypeOf((*MockCron)(nil).AddFunc), spec, cmd) -} - -// Start mocks base method. -func (m *MockCron) Start() { - m.ctrl.T.Helper() - m.ctrl.Call(m, "Start") -} - -// Start indicates an expected call of Start. -func (mr *MockCronMockRecorder) Start() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockCron)(nil).Start)) -} - -// Stop mocks base method. -func (m *MockCron) Stop() { - m.ctrl.T.Helper() - m.ctrl.Call(m, "Stop") -} - -// Stop indicates an expected call of Stop. -func (mr *MockCronMockRecorder) Stop() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockCron)(nil).Stop)) +// MockPoller is a mock of the polling.Poller interface for testing. +// It captures the callback so tests can trigger it manually via Tick(). +// All fields are synchronized via a ready channel and mutex so that +// Tick() safely blocks until Start() has been called. +type MockPoller struct { + mu sync.Mutex + callback func() + ready chan struct{} +} + +// NewMockPoller creates a new MockPoller. +func NewMockPoller() *MockPoller { + return &MockPoller{ + ready: make(chan struct{}), + } +} + +// Start captures the callback without blocking (unlike the real CronPoller). +func (m *MockPoller) Start(_ context.Context, callback func()) { + m.mu.Lock() + m.callback = callback + m.mu.Unlock() + close(m.ready) +} + +// Tick blocks until Start has been called, then invokes the captured callback. +func (m *MockPoller) Tick() { + <-m.ready + m.mu.Lock() + cb := m.callback + m.mu.Unlock() + if cb != nil { + cb() + } +} + +// Started returns whether Start was called (non-blocking). +func (m *MockPoller) Started() bool { + select { + case <-m.ready: + return true + default: + return false + } +} + +// Offset returns 0 (no offset in tests by default). +func (m *MockPoller) Offset() uint32 { + return 0 } diff --git a/core/pkg/telemetry/builder.go b/core/pkg/telemetry/builder.go index 55baf5a51..3c8d30856 100644 --- a/core/pkg/telemetry/builder.go +++ b/core/pkg/telemetry/builder.go @@ -2,20 +2,15 @@ package telemetry import ( "context" - "crypto/tls" - "crypto/x509" "fmt" "os" "time" "connectrpc.com/connect" "connectrpc.com/otelconnect" - "github.com/open-feature/flagd/core/pkg/certreloader" "github.com/open-feature/flagd/core/pkg/logger" + "go.opentelemetry.io/contrib/exporters/autoexport" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/exporters/prometheus" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/metric" @@ -23,9 +18,6 @@ import ( "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.34.0" "go.uber.org/zap" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" ) const ( @@ -72,24 +64,33 @@ func BuildMetricsRecorder( } // BuildTraceProvider build and register the trace provider and propagator for the caller runtime. This method -// attempt to register a global TracerProvider backed by batch SpanProcessor.Config. CollectorTarget can be used to -// provide the grpc collector target. Providing empty target results in skipping provider & propagator registration. -// This results in tracers having NoopTracerProvider and propagator having No-Op TextMapPropagator performing no action +// uses autoexport to automatically handle OTEL environment variables for trace exporters. +// Providing empty collector target results in using environment variables or falling back to noop. func BuildTraceProvider(ctx context.Context, logger *logger.Logger, svc string, svcVersion string, cfg Config) error { - if cfg.CollectorConfig.Target == "" { - logger.Debug("skipping trace provider setup as collector target is not set." + - " Traces will use NoopTracerProvider provider and propagator will use no-Op TextMapPropagator") - return nil + // For backwards compatibility: set environment variables from flagd configuration + // before calling autoexport if they are provided via flags + if cfg.CollectorConfig.Target != "" { + setEnvIfNotSet("OTEL_TRACES_EXPORTER", "otlp") + setEnvIfNotSet("OTEL_EXPORTER_OTLP_ENDPOINT", cfg.CollectorConfig.Target) + setEnvIfNotSet("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc") } - exporter, err := buildOtlpExporter(ctx, cfg.CollectorConfig) + // Use autoexport to handle OTEL environment variables + exporter, err := autoexport.NewSpanExporter(ctx) if err != nil { - return err + return fmt.Errorf("failed to create span exporter: %w", err) + } + + // Skip if noop exporter (when no configuration is provided) + if autoexport.IsNoneSpanExporter(exporter) { + logger.Debug("skipping trace provider setup as no exporter is configured." + + " Traces will use NoopTracerProvider provider and propagator will use no-Op TextMapPropagator") + return nil } res, err := buildResourceFor(ctx, svc, svcVersion) if err != nil { - return err + return fmt.Errorf("failed to build resource: %w", err) } provider := trace.NewTracerProvider( @@ -103,124 +104,45 @@ func BuildTraceProvider(ctx context.Context, logger *logger.Logger, svc string, } // BuildConnectOptions is a helper to build connect options based on telemetry configurations -func BuildConnectOptions(cfg Config) ([]connect.HandlerOption, error) { +func BuildConnectOptions(_ Config) ([]connect.HandlerOption, error) { options := []connect.HandlerOption{} - // add interceptor if configuration is available for collector - if cfg.CollectorConfig.Target != "" { - interceptor, err := otelconnect.NewInterceptor(otelconnect.WithTrustRemote()) - if err != nil { - return nil, fmt.Errorf("error creating interceptor, %w", err) - } - - options = append(options, connect.WithInterceptors(interceptor)) + // Always add interceptor - autoexport will handle whether traces are enabled + interceptor, err := otelconnect.NewInterceptor( + otelconnect.WithTrustRemote(), + // this option prevents a cardinality explosion in OTel due to remote peer and ephemeral port attributes + otelconnect.WithoutServerPeerAttributes(), + ) + if err != nil { + return nil, fmt.Errorf("error creating interceptor, %w", err) } - return options, nil -} - -func buildTransportCredentials(_ context.Context, cfg CollectorConfig) (credentials.TransportCredentials, error) { - creds := insecure.NewCredentials() - if cfg.KeyPath != "" || cfg.CertPath != "" || cfg.CAPath != "" { - capool := x509.NewCertPool() - if cfg.CAPath != "" { - ca, err := os.ReadFile(cfg.CAPath) - if err != nil { - return nil, fmt.Errorf("can't read ca file from %s", cfg.CAPath) - } - if !capool.AppendCertsFromPEM(ca) { - return nil, fmt.Errorf("can't add CA '%s' to pool", cfg.CAPath) - } - } - - reloader, err := certreloader.NewCertReloader(certreloader.Config{ - KeyPath: cfg.KeyPath, - CertPath: cfg.CertPath, - ReloadInterval: cfg.ReloadInterval, - }) - if err != nil { - return nil, fmt.Errorf("failed to create certreloader: %w", err) - } - - tlsConfig := &tls.Config{ - RootCAs: capool, - MinVersion: tls.VersionTLS12, - GetCertificate: func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) { - certs, err := reloader.GetCertificate() - if err != nil { - return nil, fmt.Errorf("failed to reload certs: %w", err) - } - return certs, nil - }, - } - - creds = credentials.NewTLS(tlsConfig) - } + options = append(options, connect.WithInterceptors(interceptor)) - return creds, nil + return options, nil } // buildMetricReader builds a metric reader based on provided configurations +// Uses autoexport to automatically handle OTEL environment variables func buildMetricReader(ctx context.Context, cfg Config) (metric.Reader, error) { - if cfg.MetricsExporter == "" { - return buildDefaultMetricReader() - } - - // Handle metric reader override - if cfg.MetricsExporter != metricsExporterOtel { - return nil, fmt.Errorf("provided metrics operator %s is not supported. currently only support %s", - cfg.MetricsExporter, metricsExporterOtel) + // For backwards compatibility: set environment variables from flagd configuration + // before calling autoexport if they are provided via flags + if cfg.MetricsExporter == metricsExporterOtel && cfg.CollectorConfig.Target != "" { + // Set OTEL environment variables from configuration if not already set + setEnvIfNotSet("OTEL_METRICS_EXPORTER", "otlp") + setEnvIfNotSet("OTEL_EXPORTER_OTLP_ENDPOINT", cfg.CollectorConfig.Target) + setEnvIfNotSet("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc") } - // Otel override require target configuration - if cfg.CollectorConfig.Target == "" { - return nil, fmt.Errorf("metric exporter is set(%s) without providing otel collector target."+ - " collector target is required for this option", cfg.MetricsExporter) - } - - transportCredentials, err := buildTransportCredentials(ctx, cfg.CollectorConfig) - if err != nil { - return nil, fmt.Errorf("metric export would not build transport credentials: %w", err) - } - - // Non-blocking, insecure grpc connection - conn, err := grpc.NewClient(cfg.CollectorConfig.Target, grpc.WithTransportCredentials(transportCredentials)) - if err != nil { - return nil, fmt.Errorf("error creating client connection: %w", err) - } - - // Otel metric exporter - otelExporter, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithGRPCConn(conn)) - if err != nil { - return nil, fmt.Errorf("error creating otel metric exporter: %w", err) - } - - return metric.NewPeriodicReader(otelExporter), nil -} - -// buildOtlpExporter is a helper to build grpc backed otlp trace exporter -func buildOtlpExporter(ctx context.Context, cfg CollectorConfig) (*otlptrace.Exporter, error) { - transportCredentials, err := buildTransportCredentials(ctx, cfg) - if err != nil { - return nil, fmt.Errorf("metric export would not build transport credentials: %w", err) - } - - // Non-blocking, grpc connection - conn, err := grpc.NewClient(cfg.Target, grpc.WithTransportCredentials(transportCredentials)) - if err != nil { - return nil, fmt.Errorf("error creating client connection: %w", err) - } - - traceClient := otlptracegrpc.NewClient(otlptracegrpc.WithGRPCConn(conn)) - exporter, err := otlptrace.New(ctx, traceClient) - if err != nil { - return nil, fmt.Errorf("error starting otel exporter: %w", err) - } - return exporter, nil + // Use autoexport with Prometheus as fallback for backwards compatibility + return autoexport.NewMetricReader( + ctx, + autoexport.WithFallbackMetricReader(buildDefaultMetricReader), + ) } // buildDefaultMetricReader provides the default metric reader -func buildDefaultMetricReader() (metric.Reader, error) { +func buildDefaultMetricReader(_ context.Context) (metric.Reader, error) { p, err := prometheus.New() if err != nil { return nil, fmt.Errorf("unable to create default metric reader: %w", err) @@ -239,6 +161,7 @@ func buildResourceFor(ctx context.Context, serviceName string, serviceVersion st resource.WithAttributes( semconv.ServiceNameKey.String(serviceName), semconv.ServiceVersionKey.String(serviceVersion)), + resource.WithFromEnv(), ) if err != nil { return nil, fmt.Errorf("unable to create resource identifier: %w", err) @@ -246,6 +169,13 @@ func buildResourceFor(ctx context.Context, serviceName string, serviceVersion st return r, nil } +// setEnvIfNotSet sets an environment variable only if it's not already set +func setEnvIfNotSet(key, value string) { + if os.Getenv(key) == "" { + os.Setenv(key, value) + } +} + // OTelErrorsHandler is a custom error interceptor for OpenTelemetry type otelErrorsHandler struct { logger *logger.Logger diff --git a/core/pkg/telemetry/builder_test.go b/core/pkg/telemetry/builder_test.go index c6253012b..9eb7961e2 100644 --- a/core/pkg/telemetry/builder_test.go +++ b/core/pkg/telemetry/builder_test.go @@ -44,24 +44,7 @@ func TestBuildMetricReader(t *testing.T) { error: false, }, { - name: "Metric exporter overriding require valid overriding parameter", - cfg: Config{ - MetricsExporter: "unsupported", - }, - error: true, - }, - { - name: "Metric exporter overriding require valid configuration combination", - cfg: Config{ - MetricsExporter: metricsExporterOtel, - CollectorConfig: CollectorConfig{ - Target: "", // collector target is unset - }, - }, - error: true, - }, - { - name: "Metric exporter overriding with valid configurations", + name: "Autoexport handles all configurations", cfg: Config{ MetricsExporter: metricsExporterOtel, CollectorConfig: CollectorConfig{ @@ -128,9 +111,9 @@ func TestBuildConnectOptions(t *testing.T) { optionCount int }{ { - name: "No options for empty/default configurations", + name: "Interceptor always added with autoexport", cfg: Config{}, - optionCount: 0, + optionCount: 1, }, { name: "Connect option is set when telemetry target is set", @@ -172,6 +155,23 @@ func TestBuildResourceFor(t *testing.T) { }, "expected resource to contain service version") } +func TestBuildResourceForEnvOverride(t *testing.T) { + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.version=9.9.9,service.name=overridden-svc") + + res, err := buildResourceFor(context.Background(), "defaultSvc", "0.0.1") + require.Nil(t, err, "expected no error, but got: %v", err) + + attributes := res.Attributes() + require.Containsf(t, attributes, attribute.KeyValue{ + Key: semconv.ServiceNameKey, + Value: attribute.StringValue("overridden-svc"), + }, "expected OTEL_RESOURCE_ATTRIBUTES to override the programmatic service name") + require.Containsf(t, attributes, attribute.KeyValue{ + Key: semconv.ServiceVersionKey, + Value: attribute.StringValue("9.9.9"), + }, "expected OTEL_RESOURCE_ATTRIBUTES to override the programmatic service version") +} + func TestErrorIntercepted(t *testing.T) { // register the OTel error handling observedZapCore, observedLogs := observer.New(zap.DebugLevel) diff --git a/core/pkg/telemetry/metrics.go b/core/pkg/telemetry/metrics.go index 27aea5c01..a4725f80a 100644 --- a/core/pkg/telemetry/metrics.go +++ b/core/pkg/telemetry/metrics.go @@ -5,6 +5,7 @@ import ( "time" "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/sdk/instrumentation" @@ -14,7 +15,8 @@ import ( ) const ( - ProviderName = "flagd" + ProviderName = "flagd" + featureFlagPrefix = "feature_flag." FeatureFlagReasonKey = attribute.Key("feature_flag.reason") ExceptionTypeKey = attribute.Key("ExceptionTypeKeyName") @@ -22,8 +24,11 @@ const ( httpRequestDurationMetric = "http.server.request.duration" httpResponseSizeMetric = "http.server.response.body.size" httpActiveRequestsMetric = "http.server.active_requests" - impressionMetric = "feature_flag." + ProviderName + ".impression" - reasonMetric = "feature_flag." + ProviderName + ".result.reason" + impressionMetric = featureFlagPrefix + ProviderName + ".impression" + reasonMetric = featureFlagPrefix + ProviderName + ".result.reason" + + syncActiveStreamsMetric = featureFlagPrefix + ProviderName + ".sync.active_streams" + syncStreamDurationMetric = featureFlagPrefix + ProviderName + ".sync.stream.duration" ) type IMetricsRecorder interface { @@ -34,6 +39,10 @@ type IMetricsRecorder interface { InFlightRequestEnd(ctx context.Context, attrs []attribute.KeyValue) RecordEvaluation(ctx context.Context, err error, reason, variant, key string) Impressions(ctx context.Context, reason, variant, key string) + // gRPC Sync metrics + SyncStreamStart(ctx context.Context, attrs []attribute.KeyValue) + SyncStreamEnd(ctx context.Context, attrs []attribute.KeyValue) + SyncStreamDuration(ctx context.Context, duration time.Duration, attrs []attribute.KeyValue) } type NoopMetricsRecorder struct{} @@ -60,12 +69,27 @@ func (NoopMetricsRecorder) RecordEvaluation(_ context.Context, _ error, _, _, _ func (NoopMetricsRecorder) Impressions(_ context.Context, _, _, _ string) { } +func (NoopMetricsRecorder) SyncStreamStart(_ context.Context, _ []attribute.KeyValue) { + // No-op implementation: intentionally does nothing +} + +func (NoopMetricsRecorder) SyncStreamEnd(_ context.Context, _ []attribute.KeyValue) { + // No-op implementation: intentionally does nothing +} + +func (NoopMetricsRecorder) SyncStreamDuration(_ context.Context, _ time.Duration, _ []attribute.KeyValue) { + // No-op implementation: intentionally does nothing +} + type MetricsRecorder struct { httpRequestDurHistogram metric.Float64Histogram httpResponseSizeHistogram metric.Float64Histogram httpRequestsInflight metric.Int64UpDownCounter impressions metric.Int64Counter reasons metric.Int64Counter + // gRPC Sync metrics + syncActiveStreams metric.Int64UpDownCounter + syncStreamDuration metric.Float64Histogram } func (r MetricsRecorder) HTTPAttributes(svcName, url, method, code, scheme string) []attribute.KeyValue { @@ -122,6 +146,18 @@ func (r MetricsRecorder) Reasons(ctx context.Context, key string, reason string, r.reasons.Add(ctx, 1, metric.WithAttributes(attrs...)) } +func (r MetricsRecorder) SyncStreamStart(ctx context.Context, attrs []attribute.KeyValue) { + r.syncActiveStreams.Add(ctx, 1, metric.WithAttributes(attrs...)) +} + +func (r MetricsRecorder) SyncStreamEnd(ctx context.Context, attrs []attribute.KeyValue) { + r.syncActiveStreams.Add(ctx, -1, metric.WithAttributes(attrs...)) +} + +func (r MetricsRecorder) SyncStreamDuration(ctx context.Context, duration time.Duration, attrs []attribute.KeyValue) { + r.syncStreamDuration.Record(ctx, duration.Seconds(), metric.WithAttributes(attrs...)) +} + func getDurationView(svcName, viewName string, bucket []float64) msdk.View { return msdk.NewView( msdk.Instrument{ @@ -145,21 +181,30 @@ func ExceptionType(val string) attribute.KeyValue { return ExceptionTypeKey.String(val) } -// NewOTelRecorder creates a MetricsRecorder based on the provided metric.Reader. Note that, metric.NewMeterProvider is -// created here but not registered globally as this is the only place we derive a metric.Meter. Consider global provider -// registration if we need more meters +// NewOTelRecorder creates a MetricsRecorder based on the provided metric.Reader. The MeterProvider created here is +// registered globally so that otelgrpc, otelhttp, and otelconnect instrumentation libraries can use it. func NewOTelRecorder(exporter msdk.Reader, resource *resource.Resource, serviceName string) *MetricsRecorder { // create a metric provider with custom bucket size for histograms provider := msdk.NewMeterProvider( msdk.WithReader(exporter), // for the request duration metric we use the default bucket size which are tailored for response time in seconds - msdk.WithView(getDurationView(httpRequestDurationMetric, serviceName, prometheus.DefBuckets)), + msdk.WithView(getDurationView(serviceName, httpRequestDurationMetric, prometheus.DefBuckets)), // for response size we want 8 exponential bucket starting from 100 Bytes - msdk.WithView(getDurationView(httpResponseSizeMetric, serviceName, prometheus.ExponentialBuckets(100, 10, 8))), + msdk.WithView(getDurationView(serviceName, httpResponseSizeMetric, prometheus.ExponentialBuckets(100, 10, 8))), + // for gRPC sync stream duration: 30s, 1min, 2min, 5min, 8min, 10min, 20min, 30min, 1h, 3h + msdk.WithView(getDurationView(serviceName, syncStreamDurationMetric, []float64{30, 60, 120, 300, 480, 600, 1200, 1800, 3600, 10800})), // set entity producing telemetry msdk.WithResource(resource), + // limit metric attribute cardinality to prevent unbounded memory growth from + // high-cardinality attributes (OTel spec recommends 2000, Go SDK defaults to unlimited) + // 2000 is recommended by OTel spec and is a reasonable default for our use case, + // but can be overridden with the OTEL_GO_X_CARDINALITY_LIMIT environment variable + msdk.WithCardinalityLimit(2000), ) + // Set as global MeterProvider so otelgrpc and other instrumentation can use it + otel.SetMeterProvider(provider) + meter := provider.Meter(serviceName) // we can ignore errors from OpenTelemetry since they could occur if we select the wrong aggregator @@ -188,11 +233,26 @@ func NewOTelRecorder(exporter msdk.Reader, resource *resource.Resource, serviceN metric.WithDescription("Measures the number of evaluations for a given reason."), metric.WithUnit("{reason}"), ) + + // gRPC Sync metrics + syncActiveStreams, _ := meter.Int64UpDownCounter( + syncActiveStreamsMetric, + metric.WithDescription("Measures the number of currently active gRPC sync streaming connections."), + metric.WithUnit("{stream}"), + ) + syncStreamDuration, _ := meter.Float64Histogram( + syncStreamDurationMetric, + metric.WithDescription("Measures the duration of gRPC sync streaming connections."), + metric.WithUnit("s"), + ) + return &MetricsRecorder{ httpRequestDurHistogram: hduration, httpResponseSizeHistogram: hsize, httpRequestsInflight: reqCounter, impressions: impressions, reasons: reasons, + syncActiveStreams: syncActiveStreams, + syncStreamDuration: syncStreamDuration, } } diff --git a/core/pkg/telemetry/metrics_test.go b/core/pkg/telemetry/metrics_test.go index 68af7a1e1..a5baa3e79 100644 --- a/core/pkg/telemetry/metrics_test.go +++ b/core/pkg/telemetry/metrics_test.go @@ -1,10 +1,11 @@ package telemetry import ( - "context" "fmt" "testing" + "time" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/sdk/metric" @@ -101,7 +102,7 @@ func TestMetrics(t *testing.T) { semconv.ServiceNameKey.String(svcName), } const n = 5 - type MetricF func(exp metric.Reader) + type MetricF func(t *testing.T, exp metric.Reader) tests := []struct { name string metricFunc MetricF @@ -109,32 +110,32 @@ func TestMetrics(t *testing.T) { }{ { name: "HTTPRequestDuration", - metricFunc: func(exp metric.Reader) { + metricFunc: func(t *testing.T, exp metric.Reader) { rs := resource.NewWithAttributes("testSchema") rec := NewOTelRecorder(exp, rs, svcName) for i := 0; i < n; i++ { - rec.HTTPRequestDuration(context.TODO(), 10, attrs) + rec.HTTPRequestDuration(t.Context(), 10, attrs) } }, metricsLen: 1, }, { name: "HTTPResponseSize", - metricFunc: func(exp metric.Reader) { + metricFunc: func(t *testing.T, exp metric.Reader) { rs := resource.NewWithAttributes("testSchema") rec := NewOTelRecorder(exp, rs, svcName) for i := 0; i < n; i++ { - rec.HTTPResponseSize(context.TODO(), 100, attrs) + rec.HTTPResponseSize(t.Context(), 100, attrs) } }, metricsLen: 1, }, { name: "InFlightRequestStart", - metricFunc: func(exp metric.Reader) { + metricFunc: func(t *testing.T, exp metric.Reader) { rs := resource.NewWithAttributes("testSchema") rec := NewOTelRecorder(exp, rs, svcName) - ctx := context.TODO() + ctx := t.Context() for i := 0; i < n; i++ { rec.InFlightRequestStart(ctx, attrs) rec.InFlightRequestEnd(ctx, attrs) @@ -144,54 +145,78 @@ func TestMetrics(t *testing.T) { }, { name: "Impressions", - metricFunc: func(exp metric.Reader) { + metricFunc: func(t *testing.T, exp metric.Reader) { rs := resource.NewWithAttributes("testSchema") rec := NewOTelRecorder(exp, rs, svcName) for i := 0; i < n; i++ { - rec.Impressions(context.TODO(), "reason", "variant", "key") + rec.Impressions(t.Context(), "reason", "variant", "key") } }, metricsLen: 1, }, { name: "Reasons", - metricFunc: func(exp metric.Reader) { + metricFunc: func(t *testing.T, exp metric.Reader) { rs := resource.NewWithAttributes("testSchema") rec := NewOTelRecorder(exp, rs, svcName) for i := 0; i < n; i++ { - rec.Reasons(context.TODO(), "keyA", "reason", nil) + rec.Reasons(t.Context(), "keyA", "reason", nil) } for i := 0; i < n; i++ { - rec.Reasons(context.TODO(), "keyB", "error", fmt.Errorf("err not found")) + rec.Reasons(t.Context(), "keyB", "error", fmt.Errorf("err not found")) } }, metricsLen: 1, }, { name: "RecordEvaluations", - metricFunc: func(exp metric.Reader) { + metricFunc: func(t *testing.T, exp metric.Reader) { rs := resource.NewWithAttributes("testSchema") rec := NewOTelRecorder(exp, rs, svcName) for i := 0; i < n; i++ { - rec.RecordEvaluation(context.TODO(), nil, "reason", "variant", "key") + rec.RecordEvaluation(t.Context(), nil, "reason", "variant", "key") } for i := 0; i < n; i++ { - rec.RecordEvaluation(context.TODO(), fmt.Errorf("general"), "error", "variant", "key") + rec.RecordEvaluation(t.Context(), fmt.Errorf("general"), "error", "variant", "key") } for i := 0; i < n; i++ { - rec.RecordEvaluation(context.TODO(), fmt.Errorf("not found"), "error", "variant", "key") + rec.RecordEvaluation(t.Context(), fmt.Errorf("not found"), "error", "variant", "key") } }, metricsLen: 2, }, + { + name: "SyncActiveStreams", + metricFunc: func(t *testing.T, exp metric.Reader) { + rs := resource.NewWithAttributes("testSchema") + rec := NewOTelRecorder(exp, rs, svcName) + ctx := t.Context() + for i := 0; i < n; i++ { + rec.SyncStreamStart(ctx, attrs) + rec.SyncStreamEnd(ctx, attrs) + } + }, + metricsLen: 1, + }, + { + name: "SyncStreamDuration", + metricFunc: func(t *testing.T, exp metric.Reader) { + rs := resource.NewWithAttributes("testSchema") + rec := NewOTelRecorder(exp, rs, svcName) + for i := 0; i < n; i++ { + rec.SyncStreamDuration(t.Context(), 100*time.Millisecond, attrs) + } + }, + metricsLen: 1, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { exp := metric.NewManualReader() - tt.metricFunc(exp) + tt.metricFunc(t, exp) var data metricdata.ResourceMetrics - err := exp.Collect(context.TODO(), &data) + err := exp.Collect(t.Context(), &data) if err != nil { t.Errorf("Got %v", err) } @@ -209,33 +234,117 @@ func TestMetrics(t *testing.T) { } // some really simple tests just to make sure all methods are actually implemented and nothing panics -func TestNoopMetricsRecorder_HTTPAttributes(t *testing.T) { +func TestNoopMetricsRecorderHTTPAttributes(t *testing.T) { no := NoopMetricsRecorder{} got := no.HTTPAttributes("", "", "", "", "") require.Empty(t, got) } -func TestNoopMetricsRecorder_HTTPRequestDuration(_ *testing.T) { +func TestNoopMetricsRecorderHTTPRequestDuration(t *testing.T) { + no := NoopMetricsRecorder{} + no.HTTPRequestDuration(t.Context(), 0, nil) +} + +func TestNoopMetricsRecorderInFlightRequestStart(t *testing.T) { + no := NoopMetricsRecorder{} + no.InFlightRequestStart(t.Context(), nil) +} + +func TestNoopMetricsRecorderInFlightRequestEnd(t *testing.T) { + no := NoopMetricsRecorder{} + no.InFlightRequestEnd(t.Context(), nil) +} + +func TestNoopMetricsRecorderRecordEvaluation(t *testing.T) { no := NoopMetricsRecorder{} - no.HTTPRequestDuration(context.TODO(), 0, nil) + no.RecordEvaluation(t.Context(), nil, "", "", "") } -func TestNoopMetricsRecorder_InFlightRequestStart(_ *testing.T) { +func TestNoopMetricsRecorderImpressions(t *testing.T) { no := NoopMetricsRecorder{} - no.InFlightRequestStart(context.TODO(), nil) + no.Impressions(t.Context(), "", "", "") } -func TestNoopMetricsRecorder_InFlightRequestEnd(_ *testing.T) { +func TestNoopMetricsRecorderSyncStreamStart(t *testing.T) { no := NoopMetricsRecorder{} - no.InFlightRequestEnd(context.TODO(), nil) + no.SyncStreamStart(t.Context(), nil) } -func TestNoopMetricsRecorder_RecordEvaluation(_ *testing.T) { +func TestNoopMetricsRecorderSyncStreamEnd(t *testing.T) { no := NoopMetricsRecorder{} - no.RecordEvaluation(context.TODO(), nil, "", "", "") + no.SyncStreamEnd(t.Context(), nil) } -func TestNoopMetricsRecorder_Impressions(_ *testing.T) { +func TestNoopMetricsRecorderSyncStreamDuration(t *testing.T) { no := NoopMetricsRecorder{} - no.Impressions(context.TODO(), "", "", "") + no.SyncStreamDuration(t.Context(), 0, nil) +} + +// testHistogramBuckets is a helper function that tests histogram bucket configuration +func testHistogramBuckets(t *testing.T, metricName string, expectedBounds []float64, recordMetric func(t *testing.T, rec *MetricsRecorder, attrs []attribute.KeyValue), assertMsg string) { + t.Helper() + const testSvcName = "testService" + exp := metric.NewManualReader() + rs := resource.NewWithAttributes("testSchema") + rec := NewOTelRecorder(exp, rs, testSvcName) + + attrs := []attribute.KeyValue{ + semconv.ServiceNameKey.String(testSvcName), + } + recordMetric(t, rec, attrs) + + var data metricdata.ResourceMetrics + err := exp.Collect(t.Context(), &data) + require.NoError(t, err) + + require.Len(t, data.ScopeMetrics, 1) + scopeMetrics := data.ScopeMetrics[0] + require.Equal(t, testSvcName, scopeMetrics.Scope.Name) + + var foundHistogram bool + for _, m := range scopeMetrics.Metrics { + if m.Name == metricName { + histogram, ok := m.Data.(metricdata.Histogram[float64]) + require.True(t, ok, "Expected metric to be a Histogram") + + require.NotEmpty(t, histogram.DataPoints, "Expected at least one data point") + require.Equal(t, expectedBounds, histogram.DataPoints[0].Bounds, assertMsg) + foundHistogram = true + break + } + } + require.Truef(t, foundHistogram, "Expected to find %s histogram", metricName) +} + +func TestHTTPRequestDurationBuckets(t *testing.T) { + testHistogramBuckets(t, + httpRequestDurationMetric, + prometheus.DefBuckets, + func(t *testing.T, rec *MetricsRecorder, attrs []attribute.KeyValue) { + rec.HTTPRequestDuration(t.Context(), 100*time.Millisecond, attrs) + }, + "Expected histogram buckets to match prometheus.DefBuckets", + ) +} + +func TestHTTPResponseSizeBuckets(t *testing.T) { + testHistogramBuckets(t, + httpResponseSizeMetric, + prometheus.ExponentialBuckets(100, 10, 8), + func(t *testing.T, rec *MetricsRecorder, attrs []attribute.KeyValue) { + rec.HTTPResponseSize(t.Context(), 500, attrs) + }, + "Expected histogram buckets to match exponential buckets (100, 10, 8)", + ) +} + +func TestGRPCSyncStreamDurationBuckets(t *testing.T) { + testHistogramBuckets(t, + syncStreamDurationMetric, + []float64{30, 60, 120, 300, 480, 600, 1200, 1800, 3600, 10800}, + func(t *testing.T, rec *MetricsRecorder, attrs []attribute.KeyValue) { + rec.SyncStreamDuration(t.Context(), 100*time.Millisecond, attrs) + }, + "Expected histogram buckets for long-lived sync streams (30s, 1min, 2min, 5min, 8min, 10min, 20min, 30min, 1h, 3h)", + ) } diff --git a/docs/architecture-decisions/disabled-flag-evaluation.md b/docs/architecture-decisions/disabled-flag-evaluation.md new file mode 100644 index 000000000..e4ec8f10f --- /dev/null +++ b/docs/architecture-decisions/disabled-flag-evaluation.md @@ -0,0 +1,106 @@ +--- +# Valid statuses: draft | proposed | rejected | accepted | superseded +status: accepted +author: Parth Suthar (@suthar26) +created: 2026-04-01 +updated: 2026-04-28 +--- +# Treat Disabled Flag Evaluation as Successful with Reason DISABLED + +Today, evaluating a disabled flag in flagd produces an error (`reason=ERROR`, `errorCode=FLAG_DISABLED`). We propose returning a successful evaluation with `reason=DISABLED` and no value, so the calling SDK falls back to the application's code default. A flag that does not exist still produces `FLAG_NOT_FOUND`. This matches how the [OpenFeature specification](https://openfeature.dev/specification/types/#resolution-reason) defines `DISABLED`: a successful evaluation, not a failure. + +## Background + +flagd's current behavior treats `state: DISABLED` as an error and surfaces that error through gRPC, OFREP, and in-process providers. Several issues follow from this. + +The OpenFeature specification lists `DISABLED` as a resolution reason and describes it as *"the resolved value was the result of the flag being disabled in the management system."* Errors are described separately. Treating disabled as an error therefore conflicts with the spec. + +`FLAG_DISABLED` is also not a valid error code anywhere it is used. It is missing from the OpenFeature error code list and from the OFREP `evaluationFailure` schema, which only allows `PARSE_ERROR`, `TARGETING_KEY_MISSING`, `INVALID_CONTEXT`, and `GENERAL`. The OFREP success schema, on the other hand, does allow `reason=DISABLED`. The current behavior violates both specs at once. + +The error path also conflates two different situations. +A missing flag is usually a deployment or configuration mistake that an operator wants to know about. +A disabled flag is an intentional operational state, often used during incident remediation, environment-scoped rollouts, or features that are not yet ready. +Today both surface as `connect.CodeNotFound` on gRPC v1, and OFREP rewrites `FLAG_DISABLED` into `FLAG_NOT_FOUND` in its structured error response, leaving the disabled distinction visible only in a free-text field. +Clients cannot reliably tell the two apart. + +These collapsed error paths hurt observability. Operators who disable a flag deliberately see false error signals in dashboards and alerts; if they suppress those alerts, they lose visibility into flag state altogether. The same problem appears in flag-set-based deployments, where a flag may legitimately be disabled in one set and active in another, and treating that as an exception forces normal operations through error-handling code. + +Related reading: [OpenFeature resolution reasons](https://openfeature.dev/specification/types/#resolution-reason), the [flagd flag definitions reference](https://flagd.dev/reference/flag-definitions/), and the prior [ADR on explicit code defaults](./support-code-default.md), which establishes the field-omission pattern reused below. + +## Requirements + +A disabled flag should evaluate successfully with `reason=DISABLED` on every surface: gRPC v1, gRPC v2, OFREP, and in-process. +The resolved value should follow the same field-omission pattern as the code-default ADR, so the SDK uses the application's code default; only the `reason` differs. +Unknown flag keys must continue to return `FLAG_NOT_FOUND`. +The `DISABLED` reason must not feed into provider or SDK error paths, and bulk evaluation must include disabled flags in the response rather than skipping them. +Telemetry should record these as successful evaluations. +No change to existing flag configuration files is required. + +## Considered options + +1. Successful evaluation with `reason=DISABLED`, value omitted so the SDK falls back to code defaults. +2. Successful evaluation with `reason=DISABLED`, returning the configured `defaultVariant` value. +3. Successful evaluation with `reason=DEFAULT`, treating disabled as a special case of "no targeting matched". +4. Keep the current error behavior and document the spec divergence. + +We propose option 1. Option 2 still lets the management system pick a value, which contradicts the OpenFeature description of `DISABLED` and prevents the SDK from using its real fallback path. Option 3 hides the disabled state from clients and metrics, removing the very signal that motivated the change. Option 4 leaves the OFREP and OpenFeature spec violations in place and keeps the missing-vs-disabled confusion described above. + +## Proposal + +When a flag exists with `state: DISABLED`, the evaluator returns a successful result with no value and no variant, `reason=DISABLED`, and the usual flag and flag-set metadata. +The omission of `value` and `variant` is the same mechanism used in the code-default ADR; the SDK treats omission as a signal to use the application default. +Targeting rules are not evaluated, so reasons that describe targeting outcomes (`STATIC`, `DEFAULT`, `SPLIT`, `TARGETING_MATCH`) never apply to a disabled flag. +`ERROR` continues to mean a real failure such as a parse error or type mismatch. + +The behavior change is uniform across surfaces. +The single-flag and bulk evaluation paths both include disabled flags with `reason=DISABLED` instead of erroring or skipping them. +OFREP returns a success payload rather than an error response shaped like `FLAG_NOT_FOUND`. +On the wire, gRPC and OFREP omit the value and variant fields. +In-process providers already receive `"state": "DISABLED"` in the sync payload, so the change there is in the per-language evaluator: it must treat that state the same way as the core flagd evaluator. +The provider and core changes need to ship together so that integrators see consistent behavior. + +A typical OFREP single-flag response looks like this. The status moves from HTTP 404 (the current `FLAG_NOT_FOUND` rewrite) to HTTP 200, since the evaluation now succeeds. + +```json +{ + "key": "my-feature", + "reason": "DISABLED", + "metadata": { "flagSetId": "my-app" } +} +``` + +File-level changes are out of scope for this ADR and will be tracked in the implementation PRs. + +## Consequences + +The main benefits are spec alignment with both OpenFeature and OFREP, a clear distinction between missing and disabled flags, less noisy error metrics, and visibility into disabled flags in bulk responses. Operators get a clean signal that a flag is intentionally off, and applications can apply their normal default-value logic without going through an error branch. + +The main cost is that this is a breaking change. Clients that switch on `FLAG_DISABLED` in error handling, alerting, or HTTP 404 responses from OFREP single-flag evaluation will need to change. Bulk responses also grow when a flag set contains many disabled flags. The rollout has to be coordinated across the flagd core, language SDKs and providers, and the testbed. + +As a side effect, the existing `FlagDisabledErrorCode` plumbing in the error formatters can be removed once the evaluator no longer produces it. + +## Testing + +Coverage for this change should live in the [flagd testbed](https://github.com/open-feature/flagd-testbed) so every SDK and provider can verify behavior against the same scenarios. We need cases for single-flag and bulk evaluation on gRPC v1, gRPC v2, OFREP, and in-process, including the case where a flag is disabled in one flag set and enabled in another. + +## Versioning and migration + +flagd is pre-1.0, so this ships as a minor-version bump with the breaking change called out in the release notes rather than as a long-running compatibility mode. Operators and client authors should: + +- Replace `FLAG_DISABLED` error handling with checks for a successful evaluation whose reason is `DISABLED`. +- Update OFREP and HTTP clients that branched on a 404 status for disabled single-flag evaluation. +- Audit dashboards, alerts, and log parsers keyed on disabled-flag errors. + +The obsolete error-code paths are removed in the same release. Keeping them around does not preserve any reachable behavior once the evaluator stops producing the error. + +## Open questions + +- Should bulk evaluation expose an option to omit disabled flags, for clients that prefer smaller payloads over visibility? + +## More information + +- [OpenFeature resolution reasons](https://openfeature.dev/specification/types/#resolution-reason) +- [OpenFeature error codes](https://openfeature.dev/specification/types/#error-code) +- [flagd flag definitions](https://flagd.dev/reference/flag-definitions/) +- [ADR: Support explicit code default values](./support-code-default.md) +- [flagd testbed](https://github.com/open-feature/flagd-testbed) diff --git a/docs/architecture-decisions/fractional-non-string-rand-units.md b/docs/architecture-decisions/fractional-non-string-rand-units.md new file mode 100644 index 000000000..02598e9b2 --- /dev/null +++ b/docs/architecture-decisions/fractional-non-string-rand-units.md @@ -0,0 +1,189 @@ +--- +# Valid statuses: draft | proposed | rejected | accepted | superseded +status: accepted +author: Maks Osowski (@cupofcat) +created: 2025-08-21 +updated: 2025-12-03 +--- + +# Harden Hashing Consistency And Add Support For Non-string Attributes in Fractional Evaluation + +This proposal aims to enhance the `fractional` operator to: + +1. Explicitly ensure hashes are consistent across all providers and platforms. +2. Support non-string values as the hashing input (i.e., the randomization unit). + +Currently, all inputs are coerced to strings before hashing, which, in some rare cases, can lead to inconsistent bucketing across different provider implementations (e.g. Java provider running on a non UTF-8 platform). With this change, the targeting attributes of various types will be supported and will always be explicitly encoded in a consistent, language- and platform-independent manner in every provider + +This change will be backward-compatible in terms of flags schema but will be a breaking behavioral change for 100% of the users due to rebucketing. + +```json +"fractional": [ + { + // This will now work for non-string types + "var": "my-non-string-var" + }, + ["a", 50], + ["b", 50] +] +``` + +## Background + +The `fractional` operator in flagd determines bucket allocation (e.g., for percentage rollouts) by hashing an input value. Currently, there are two primary methods for providing this input: + +1. **Implicitly:** Providing a string-type `targetingKey` in the evaluation context, which is used if the `fractional` block only contains the variant distribution. +2. **Explicitly:** Providing an expression as the first element of the `fractional` array. Today, this expression *must* evaluate to a string (standard recommendation is to use the `"cat"` operator with `$flagd.flagKey` and `"var"`); that string will be used as hashing input, usually via murmur's `StringSum32` method. + +The requirement that the input evaluates to a string has two main drawbacks: + +* **Inconsistent Hashing:** Different providers (Go, PHP, Java) may encode the same string into bytes differently (e.g., UTF-8 vs UTF-16). Since hashing functions like MurmurHash3 operate on bytes, this leads to different hash results and thus different bucket assignments for the same logical input across platforms. +* **Unnecessary Coercion:** If a user wishes to bucket based on a numeric ID (e.g., `userId: 12345`), they must first explicitly cast it to a string (`"12345"`) within the flag definition using an operator like `"cat"`. + +This proposal seeks to resolve these issues by allowing `fractional` to operate directly on the byte representation of non-string inputs and to explicitly encode values to bytes with deterministic encoders. + +## Requirements + +### 1. Users must be able to use both string and non-string variables (e.g., integers, booleans) as the primary input for `fractional` evaluation + +### 2. Same "value" (e.g. 57.2, "some text", true, etc) should result in the same bucket assignment no matter the language of the provider and platform used + +Please note: + +* some languages (e.g. Python) don't necessarily have standard types by default (e.g. int32 vs int64). +* [OpenFeature spec 312](https://openfeature.dev/specification/sections/evaluation-context/#requirement-312) dictates that evaluation context needs to support `boolean` | `string` | `number` | `structure` | `datetime` types. +* JSON supports 6 fundamental types: `boolean` | `string` | `number` | `object` | `array` | `null` + +As such, the encodings for the following types as first argument (either as literals or results of evaluation) will be standardized: + +1. boolean +2. string +3. integer (any integer number, Python style) +4. float (any floating point number, Python style) +5. object (structure / map) +6. datetime +7. null + +**array / sequence** will be explicitly not supported as the first argument in fractional so it's possible to distinguish between hashing input and variant bucket. Nevertheless, it can be a part of object type and its encoding needs to be standardized as well. + +**null** will be explicitly not supported as the first argument, as multiple provider implementations return `null` when an error occurs during evaluation, and JSON Logic returns `null` for a missing key in the evaluation context. Rejecting `null` prevents silent errors in common use cases. + +## Non-requirements + +* This change does not need to be backward-compatible. +* Support advanced features like salting non-string types in JSON directly (that will be a separate ADR). +* Bucketing improvements (that will be a separate ADR). + +## Considered Options + +1. **Proposed:** *Type-Aware Hashing:* Extend the current behavior to support non-string types as first arguments to `fractional`. +2. *New Operator:* Introduce a new operator, such as `"bytesVar"`, to explicitly signal that the variable's raw bytes should be hashed. +3. *Operator Overloading:* Reuse an existing operator (e.g., `"merge"`) or structure (e.g., providing a list) to imply byte-based hashing. + +Option 1 was chosen for its ergonomics and zero-impact on existing schemas. Option 2 adds unnecessary complexity to the flag definition language, and Option 3 creates confusing and non-obvious semantics. + +## Proposal + +We will modify the evaluation logic for the `fractional` operator. + +When inspecting the first element of the `fractional` array: + +1. If the first element in `fractional` evaluates to `null`, we report an error and return `nil`. +2. If the first element in `fractional` evaluates to a non-array type then deterministically encode it to a well defined byte array and hash the bytes. +3. Otherwise, if `targetingKey` is a string, build a 2-elements array of `flagKey` and `targetingKey`, deterministically encode that and hash (**NOTE:** This is different than string concatenation used today). +4. Otherwise, if `targetingKey` is non-string, report an error and return `nil` (as this breaks the [OpenFeature spec](https://openfeature.dev/specification/glossary/#targeting-key)). +5. Otherwise, if `targetingKey` is missing, report an error and return `nil` + +```json +// Will use the new logic +"fractional": [ + { + "var": "my-non-string-var" + }, + ["a", 50], ... +] + +// Will use new logic +"fractional": [ + { + "cat": [{"var" : "$flagd.flagKey"}, {"var" : "some-var"}] + }, + ["a", 50], ... +] + +// Will use targetingKey +"fractional": [ + ["a", 50], ... +] + +// Will use targetingKey +"fractional": [ + { + "merge": [{"var" : "evaluates-to-some-variant-name"}, {"var" : "evaluates-to-some-int"}] + }, + ["a", 50], ... +] +``` + +### Deterministic and consistent byte encodings + +To meet requirement (2) [RFC 8949 Concise Binary Object Representation (CBOR)](https://www.rfc-editor.org/rfc/rfc8949.html) will be used to decide on byte encodings. + +* `boolean` is major type 7 +* `string` is major type 3 +* `integer`: + * `unsigned integer` is major type 0 + * `negative integer` is major type 1 +* `float` is major type 7 +* `map` (object, structure, dict) is major type 5 +* `array` (list, sequence) is major type 4 + +**NOTE: As JSONLogic doesnโ€™t have any datetime type, currently we donโ€™t leverage CBOR Tag 1. Any datetime type used within provider implementation and passed to the fractional operator causes undefined behavior. If a user wants to manage datetime, they can do it by leveraging POSIX epoch encoded as integer value, or as ISO 8601 standard encoded as string.** + +**ATTENTION: When encoding strings, CBOR appends the size of the encoding in first bytes. As such, even though the actual encoding of the string is still UTF-8, the resulting byte array will differ from raw UTF-8 encoding. As such, after this change, all hashes will change, which will result in rebucketing.** + +However, to reach full cross-language consistency we need to fulfill those additional requirements: + +* **Number Normalization (Integer vs. Float):** +JSON parsers natively lack strict differentiation between integers and floats (e.g., `1` vs `1.0`). To align with CBOR's distinct major types (Type 0/1 for integers, Type 7 for floats) and Section 6.2 of RFC 8949, all providers must implement a normalization step prior to encoding. +To prevent overflow errors in strongly-typed languages (e.g. Go) and inconsistent BigInt tagging in languages with arbitrary-precision integers (e.g. Python), the number normalization is restricted to the range $[-2^{63}, 2^{64}-1]$ (covering both signed and unsigned 64-bit integers): + +1. If a numeric value has no fractional part (e.g., `val == math.Trunc(val)` in Go, or `val.is_integer()` in Python), the provider must attempt to cast it to a signed (if <0) or unsigned (if >=0) integer before encoding. +2. If a numeric value has fractional part, or if it falls outside the range $[-2^{63}, 2^{64}-1]$ (e.g., `1.0e+176`), it **must not** be normalized to an integer. It must be encoded as a float (Major Type 7). + +**NOTE: Both -0.0 and 0.0 float values should be mapped to unsigned integer value 0.** + +**NOTE: As NaN and +/- infinity are not supported by JSON, operations on them are undefined behavior, even in languages that may support them. Using those values in live applications is discouraged.** + +* **CBOR Deterministic Encoding:** + +It is required to use [4.2.1. Core Deterministic Encoding Requirements](https://www.rfc-editor.org/rfc/rfc8949.html#section-4.2.1) (which includes Preferred Serialization), to ensure: + +1. **Map Key Ordering**: Implementations must strictly adhere to the requirement that keys in maps (objects/structures) must be sorted using bytewise lexicographic order of their deterministic encodings. +2. **Preferred Serialization (Numbers)**: CBOR mandates using the shortest possible encoding. Providers must ensure consistency, especially between integer and float representations, and across different precisions. For example, if a value fits within a 32-bit float, it must be used instead of a 64-bit float, regardless of the native type in the provider's language. + +**NOTE: Since flag configurations are parsed from JSON, the maps (objects, structures, dicts) always have strings as keys. Thatโ€™s why often there is no difference between Core Deterministic Encoding defined in [rfc8949 Section 4.2](https://www.rfc-editor.org/rfc/rfc8949.html#name-deterministically-encoded-c) and Canonical Encoding defined in [rfc7049 Section 3.9](https://www.rfc-editor.org/rfc/rfc7049#section-3.9). In some languages, due to the absence of libraries that can handle the updated standard, it is possible to use the older one. In such cases please add code comments explaining the implementation choice.** + +### API changes + +There are **no** changes to the flagd JSON schema. The change is purely semantic, affecting the evaluation logic within providers. + +### Consequences + +* Good, because any variable can be used for hashing. +* Good, because it avoids unnecessary casting. +* Bad, because all of the users will experience rebucketing. + +### Timeline + +Prior to flagd 1.0 launch. + +## More Information + +Today, flagd recommends salting the variable with flagKey directly in the `fractional` logic, using the `"cat"` operator. This will not be possible for non-string types. Advanced features like that will be considered in a separate ADR. + +Salting of the string types will continue to be possible using the `"cat"` operator as it is built directly into JSON Logic. + +### Testing considerations + +As part of implementation of this ADR, the current Gherkin suite will need to be updated to ensure more in-depth testing of consistency (e.g. by looking at the distribution of buckets for many samples), as well as support for many new types. diff --git a/docs/architecture-decisions/fractional.md b/docs/architecture-decisions/fractional.md index caaf8b5e4..a453b033d 100644 --- a/docs/architecture-decisions/fractional.md +++ b/docs/architecture-decisions/fractional.md @@ -2,7 +2,7 @@ status: draft author: @toddbaert created: 2025-06-06 -updated: 2025-06-06 +updated: 2026-03-13 --- # Fractional Operator @@ -111,6 +111,21 @@ Note that in this example, we've also specified a custom bucketing value. } ``` +**Dynamic weights** are also supported: the weight argument in each variant array can be a JSONLogic expression that evaluates to a numeric value, not only a hard-coded integer. +This enables use cases such as time-based progressive rollouts, where the weight changes dynamically based on the evaluation context (e.g., `$flagd.timestamp`). +Negative weight values (which can result from dynamic expressions) must be clamped to 0. + +```jsonc +// Time-based progressive rollout using dynamic weights: +// the "on" weight grows as time advances, "off" weight shrinks. +{ + "fractional": [ + ["on", { "-": [{ "var": "$flagd.timestamp" }, 1740000000] }], + ["off", { "-": [1800000000, { "var": "$flagd.timestamp" }] }] + ] +} +``` + ### Consequences - Good, because Murmur3 is fast, has good avalanche properties, and we don't need "cryptographic" randomness @@ -118,4 +133,3 @@ Note that in this example, we've also specified a custom bucketing value. - Good, because our bucketing algorithm is relatively stable when new variants are added - Bad, because we only support string bucketing values - Bad, because we don't have bucket resolution finer than 1:99 -- Bad, because we don't support JSONLogic expressions within bucket definitions diff --git a/docs/architecture-decisions/high-precision-fractional-bucketing.md b/docs/architecture-decisions/high-precision-fractional-bucketing.md new file mode 100644 index 000000000..724168818 --- /dev/null +++ b/docs/architecture-decisions/high-precision-fractional-bucketing.md @@ -0,0 +1,187 @@ +--- +# Valid statuses: draft | proposed | rejected | accepted | superseded +status: draft +author: Michael Beemer +created: 2025-09-10 +updated: 2026-01-29 +--- + +# High-Precision Fractional Bucketing for Sub-Percent Traffic Allocation + +This ADR proposes enhancing the fractional operation to support high-precision traffic allocation down to 0.001% granularity by increasing the internal bucket count from 100 to 100,000 while maintaining the existing weight-based API. + +## Background + +The current fractional operation in flagd uses a 100-bucket system that maps hash values to percentages in the range [0, 100]. +This approach works well for most use cases but has significant limitations in high-throughput environments where precise sub-percent traffic allocation is required. + +Currently, the smallest allocation possible is 1%, which is insufficient for: + +- Gradual rollouts in ultra-high-traffic systems where 1% could represent millions of users +- A/B testing scenarios requiring precise control over small experimental groups +- Canary deployments where operators need to start with very small traffic percentages (e.g., 0.1% or 0.01%) + +The current implementation in `fractional.go` calculates bucket assignment using: + +```go +bucket := hashRatio * 100 // in range [0, 100] +``` + +This limits granularity to 1% increments, making it impossible to achieve the precision required for sophisticated traffic management strategies. + +## Requirements + +- Support traffic allocation precision down to 0.001% (3 decimal places) +- Maintain backwards compatibility with existing weight-based API +- Preserve deterministic bucketing behavior (same hash input always produces same bucket) +- Ensure consistent bucket assignment across different programming languages +- Support weight values up to a reasonable maximum that works across multiple languages +- Maintain current performance characteristics +- Prevent users from being moved between buckets when only distribution percentages change +- Guarantee that any variant with weight > 0 receives some traffic allocation +- Handle edge cases gracefully without silent failures +- Validate weight configurations and provide clear error messages for invalid inputs + +## Considered Options + +- **Option 1: 10,000 buckets (0.01% precision)** - 1 in every 10,000 users, better but still not sufficient for many high-throughput use cases +- **Option 2: 100,000 buckets (0.001% precision)** - 1 in every 100,000 users, meets most high-precision needs +- **Option 3: 1,000,000 buckets (0.0001% precision)** - 1 in every 1,000,000 users, likely overkill and could impact performance +- **Option 4 (Favored): Max 32-bit signed integer buckets** - Use `math.MaxInt32` (2,147,483,647) as the maximum allowed weight sum. This naturally sidesteps minimum allocation guarantees and excess bucket handling + +## Proposal: Max Int32 Weight Sum (Favored) + +> **Amendment (2026-01-29):** This alternative is now favored over the original 100,000-bucket proposal. + +### Rationale + +After experimentation comparing static vs dynamic bucket sizes, and considering implementation complexity, a simpler approach emerged: use the maximum 32-bit signed integer value (`math.MaxInt32` = 2,147,483,647) as the maximum allowed weight sum. + +This value ensures cross-language compatibility. +The bucket calculation requires multiplying a 32-bit hash by the total weight, producing a 64-bit intermediate product. +The max product (`MaxUint32 ร— MaxInt32` = 9.22 ร— 10ยนโธ) fits within Java's signed `long` with ~6 billion headroom. Java is the limiting factor โ€” using `MaxUint32` for the weight sum would overflow `long`. +JavaScript's `Number` type cannot safely represent the max product, so `BigInt` is required. See [Cross-Language Implementation Notes](#cross-language-implementation-notes) for details. + +### Constraints + +- The sum of all variant weights must not exceed `math.MaxInt32` (2,147,483,647) +- Weights must be defined as integers + +### Advantages + +Since the total weight sum cannot exceed `math.MaxInt32`, any variant with a weight of at least 1 is guaranteed at least 1 bucket. This **naturally sidesteps** the need for: + +- **Minimum Allocation Guarantee** (as described above): A weight of 1 out of any valid total will always yield at least 1 bucketโ€”no special handling required +- **Excess Bucket Management**: Without minimum allocation adjustments, bucket totals don't exceed the bucket count + +### Simplified Implementation + +This implementation is designed to be compatible with the "Harden Hashing" ADR, accepting a pre-computed hash value rather than performing string hashing internally. This decouples fractional bucketing from the hashing strategy. + +```go +const maxWeightSum = math.MaxInt32 // 2,147,483,647 + +// distributeValue accepts the hash calculated by the "Harden Hashing" ADR logic. +// It relies purely on integer math, avoiding floating-point precision issues. +// Note: hashValue is uint32 (full 32-bit hash range), while weights are int32 +// (max sum of MaxInt32 for cross-language compatibility). +func distributeValue(hashValue uint32, feDistribution *fractionalEvaluationDistribution) string { + // 0. Validation: Handle empty distribution + if feDistribution.totalWeight == 0 { + return "" + } + + // 1. Use the hash provided 32-bit hash + + // 2. Projection: Map 32-bit hash to [0, totalWeight) + // We cast to uint64 to ensure the multiplication does not overflow. + // Shifting right by 32 bits is mathematically equivalent to dividing by 2^32. + // This logic is safe across major languages because it relies on fundamental + // binary operations. + bucket := (uint64(hashValue) * uint64(feDistribution.totalWeight)) >> 32 + + // 3. Selection: Find which variant range the bucket falls into + var rangeEnd uint64 = 0 + for _, variant := range feDistribution.weightedVariants { + rangeEnd += uint64(variant.weight) // this would be a Java long, or JS BigInt - needs to handle max product: 9.223372030 ร— 10^18 (9,223,372,030,412,324,865) + if bucket < rangeEnd { + return variant.variant + } + } + + // Unreachable given strict validation of weights (integers, sum <= MaxInt32) + return "" +} +``` + +> **Note:** This implementation uses pure integer arithmetic to avoid floating-point precision issues entirely. +> The expression `(uint64(hashValue) * uint64(totalWeight)) >> 32` is mathematically equivalent to `(hashValue / 2^32) * totalWeight`, but performed in integer space. The Go code uses `uint64`, but each language uses its own 64-bit type (e.g., Java uses `long`). +> The `MaxInt32` weight constraint ensures the intermediate product fits within Java's more limited signed `long` range, while Go's `uint64` handles it with additional headroom. +> The right-shift by 32 bits provides exact division by 2^32. This approach is portable across all major languages since it relies only on fundamental binary operations. + +### Cross-Language Implementation Notes + +MurmurHash3-32 always produces a 32-bit value, but languages differ in how they represent it. The algorithm requires: + +1. Treating the hash as an **unsigned** 32-bit integer +2. Performing the multiplication in a 64-bit integer type +3. The `MaxInt32` weight constraint ensures the product fits in Java's signed `long` (the most restrictive common 64-bit type) + +| Language | Hash Type | Conversion to Unsigned | 64-bit Multiply | Right-Shift | +| -------------- | -------------- | --------------------------------- | --------------- | ------------------- | +| **Go** | `uint32` | None needed | `uint64(hash)` | `>> 32` | +| **Java** | `int` (signed) | `hash & 0xFFFFFFFFL` | Use `long` | `>>> 32` (unsigned) | +| **JavaScript** | `Number` | `BigInt(hash)` | Use `BigInt` | `>> 32n` | +| **Python** | `int` | None needed (arbitrary precision) | Native | `>> 32` | +| **C/C++** | `uint32_t` | None needed | `(uint64_t)` | `>> 32` | +| **C#/.NET** | `uint` | None needed | `(ulong)` | `>> 32` | + +**Java example:** + +```java +int hash = murmur3_32(value); // signed int +long hashUnsigned = hash & 0xFFFFFFFFL; // treat as unsigned +long bucket = (hashUnsigned * totalWeight) >>> 32; // unsigned right-shift +``` + +**JavaScript example:** + +```javascript +const hash = murmur3_32(value); // Number +const bucket = (BigInt(hash) * BigInt(totalWeight)) >> 32n; +``` + +### API changes + +No API changes are required. The existing fractional operation syntax remains unchanged: + +```yaml +# Constraint: The sum of all variant weights must not exceed math.MaxInt32 (2,147,483,647). +# Constraint: Weights must be defined as Integers (can be enforced by JSON schema). +"fractional": [ + { "cat": [{ "var": "$flagd.flagKey" }, { "var": "email" }] }, + ["red", 50], + ["blue", 30], + ["green", 20] +] +``` + +### Benefits Over Original Proposal + +- **Simpler**: No minimum allocation guarantee logic needed +- **No minimum allocation guarantee needed**: With smaller fixed bucket counts, a configuration like `["variant-a", 1], ["variant-b", 1000000]` could round variant-a to 0 buckets (0% traffic). Special handling was needed to guarantee at least 1 bucket. With the integer math approach, any weight โ‰ฅ1 naturally gets proportional traffic. +- **No excess bucket handling**: With fixed bucket counts (100, 10,000, 100,000), minimum allocation adjustments could cause the total allocated buckets to exceed the bucket count, requiring complex logic to redistribute the excess. With integer math, allocations naturally sum to the total weight. +- **Same validation**: Weight sum validation against `math.MaxInt32` remains unchanged +- **Backwards compatible**: Existing configurations continue to work +- **Effectively infinite precision**: Precision limited only by the total weight sum (up to ~0.00000005%) +- **~25-35% less user reassignment**: Experimental testing showed reduced "thrashing" compared to purely dynamic bucket sizes when configurations change + +### Consequences + +- Good, because implementation is significantly simpler +- Good, because it eliminates surprising edge-case behaviors (minimum allocation, excess handling) +- Good, because validation logic remains the same +- Good, because it provides effectively unlimited precision for practical use cases +- Good, because experimental testing showed less user reassignment than dynamic alternatives +- Bad, because it represents a behavioral breaking change for existing configurations (just the bucket assignment, same as original proposal) +- Neutral, performance is comparableโ€”division by large 32-bit values is not meaningfully slower diff --git a/docs/architecture-decisions/rollout-operator.md b/docs/architecture-decisions/rollout-operator.md new file mode 100644 index 000000000..1e6f7bad6 --- /dev/null +++ b/docs/architecture-decisions/rollout-operator.md @@ -0,0 +1,311 @@ +--- +status: rejected +author: @toddbaert +created: 2026-02-06 +updated: 2026-03-13 +--- + +# Rollout Operator + +**Status: Rejected**: After discussion, this proposal was rejected in favor of enhancing the existing `fractional` operator to accept JSONLogic expressions as weight arguments (see the [Alternative Proposal](#alternative-proposal-enhanced-fractional-with-dynamic-weights) section below and the [Fractional Operator ADR](fractional.md)). +The rollout/rollback operators are largely syntactic sugar over `fractional` with dynamic weights, and the additional operator surface area across all language SDKs is not justified at this time. +The enhanced `fractional` approach has been proven to support both progressive rollouts and FILO rollbacks using only existing primitives. +A dedicated operator may be reconsidered in the future if strong user need emerges, perhaps "compiled" to the fractional alternative described. + +## Background + +Progressive rollouts are a fundamental feature flag use case: gradually shifting traffic from one variant to another over time. +While stepped progression can be approximated in flagd by manually updating `fractional` weights on a schedule, or building a ruleset with multiple discrete timestamp checks each with different fractional distributions, true linear progression, where the percentage changes continuously over time, requires a time-aware operator. +The proposed rollout operator provides this. + +The rollout operator complements (but does not make obsolete) the existing `fractional` operator by featuring a time dimension. +Where `fractional` distributes traffic across variants at a point in time, `rollout` transitions between any two variants over a time window, _including nested JSONLogic like `fractional` splits or conditional rules_. + +## Requirements + +- **Time-based**: traffic distribution must change automatically based on the current timestamp +- **Deterministic**: same user must get consistent results at a given point in time (no re-bucketing mid-request) +- **Composable**: must support nested JSONLogic (e.g., rollout to a `fractional` distribution) +- **Consistent hashing**: must use the same hashing strategy as `fractional` (MurmurHash3-32) +- **Cross-language portable**: must use only integer arithmetic (no floating-point operations) +- **JSONLogic conventions**: must follow established patterns for custom operators + +## Proposal + +### Operator Syntax + +Three forms are supported, following JSONLogic array conventions: + +```jsonc +// shorthand: roll from defaultVariant to "new" +{"rollout": [1704067200, 1706745600, "new"]} + +// longhand: explicit from and to - from "old" to "new" +{"rollout": [1704067200, 1706745600, "old", "new"]} + +// with custom bucketBy +{"rollout": [{"var": "email"}, 1704067200, 1706745600, "old", "new"]} +``` + +Parameters: + +- `bucketBy` (optional): JSONLogic expression for bucketing value; defaults to `flagKey + targetingKey`, consistent with existing `fractional` +- `startTime`: Unix timestamp (seconds) when rollout begins (0% on `to`). Must be less than `endTime`. +- `endTime`: Unix timestamp (seconds) when rollout completes (100% on `to`). Must be greater than `startTime`. +- `from`: Starting variant or expression (omit for shorthand to use `defaultVariant`) +- `to`: Target variant or expression + +**Timestamp validation**: To prevent accidental use of millisecond timestamps (which would schedule rollouts thousands of years in the future), the JSON Schema should enforce reasonable bounds on `startTime` and `endTime` (e.g., `minimum: 0`, `maximum: 3000000000`, approximately year 2065). + +### Hashing Consistency + +The rollout operator uses the same hashing strategy as `fractional` with one exception: + +- MurmurHash3 (32-bit) +- Same default bucketing value: `flagKey + targetingKey` +- Same `bucketBy` expression support +- After the bucketing value is retrieved, before hashing, the UTF-8 byte representation of `rollout` is appended to the bucketing value + - This injects entropy to ensure users in fractional rules nested within a `rollout` don't bucket identically (we want to ensure users early in a rollout don't always end up in the first fractional bucket). + - The `rollback` operator also appends `rollout` (not `rollback`) so that bucket assignments match the original rollout; this is essential for the pivot-time gate to correctly identify which users were transitioned. + +### Integer-Only Arithmetic + +Per the [High-Precision Fractional Bucketing ADR](high-precision-fractional-bucketing.md), we avoid floating-point operations entirely. + +Implementations must validate that `endTime > startTime` (strict inequality) at parse time, rejecting the configuration otherwise. +This also rejects `startTime == endTime` (duration = 0), which would be a degenerate case; an "instant rollout" is better expressed as a direct variant assignment. +Additionally, `elapsed` must be clamped to `[0, duration]` to prevent overflow from negative values or times beyond the window: + +```go +duration := endTime - startTime // validated > 0 at parse time +elapsed := currentTime - startTime + +// before startTime: everyone gets "from"; after endTime โ†’ everyone gets "to" +if elapsed <= 0 { + return from +} +if elapsed >= duration { + return to +} + +// Maps hash to [0, duration) range using integer math only +bucket := (uint64(hashValue) * uint64(duration)) >> 32 + +if bucket < uint64(elapsed) { + return to +} +return from +``` + +This is mathematically equivalent to `(hash/2^32) < (elapsed/duration)` but uses only: + +- 64-bit multiplication +- 32-bit right shift +- Integer comparison + +These operations are portable across all languages without floating-point precision concerns. + +### Nested JSONLogic Support + +Variants can be JSONLogic expressions, enabling composition: + +```jsonc +// Rollout to a fractional split +{ + "rollout": [ + 1704067200, 1706745600, + "old", + {"fractional": [["a", 50], ["b", 50]]} + ] +} + +// Conditional logic within rollout +{ + "rollout": [ + 1704067200, 1706745600, + "old", + {"if": [{"==": [{"var": "tier"}, "premium"]}, "premium-new", "basic-new"]} + ] +} +``` + +### Rollback Operator + +The `rollback` operator enables graceful reversal of a rollout, transitioning users back in **FILO order**: first adopters are last to revert, and users who never transitioned never see the new variant. + +This requires a **pivot time** (the moment the rollback was initiated) which encodes how far the original rollout had progressed. The pivot time gives the operator enough "memory" to gate out never-transitioned users and reverse the rest in order, without storing any state. The rollback uses the same time window as the original rollout; the rollback completes at `endTime`. + +```jsonc +// Rollback: same start/end as rollout, plus pivotTime +{"rollback": [1704067200, 1704068200, 1704067700, "new", "old"]} +``` + +Parameters: + +- `bucketBy` (optional): Same as `rollout`. +- `startTime`: `startTime` from the original rollout. +- `endTime`: Controls when the rollback completes. Using the original rollout's `endTime` compresses the rollback into the remaining window; setting `endTime` to `pivotTime + (pivotTime - startTime)` rolls back at the same rate as the rollout progressed, etc. +- `pivotTime`: Unix timestamp when the rollback was initiated. Must be between `startTime` and `endTime`. +- `from`: The variant users are currently on (the rollout's `to`). +- `to`: The variant users revert to (the rollout's `from`). + +**Implementation**: + +```go +duration := endTime - startTime +elapsedAtPivot := pivotTime - startTime +rollbackDuration := endTime - pivotTime +bucket := (uint64(hashValue) * uint64(duration)) >> 32 + +// Gate: user never transitioned during rollout โ†’ always gets "to" +if bucket >= uint64(elapsedAtPivot) { + return to +} + +// Rollback progress +rollbackElapsed := currentTime - pivotTime +if rollbackElapsed <= 0 { return from } +if rollbackElapsed >= rollbackDuration { return to } + +// Shrinking threshold: highest-bucket users (last adopted) revert first +remaining := rollbackDuration - rollbackElapsed +if bucket * uint64(rollbackDuration) < uint64(elapsedAtPivot) * uint64(remaining) { + return from // still on rolled-out variant +} +return to // reverted +``` + +All operations are integer-only, consistent with the `rollout` operator. + +**Example**: Rollout `[0, 1000, "old", "new"]` pivoted at t=500 (rollback completes at t=1000): + +- **Alice** (adoption time t=200): adopted "new" at t=200. Reverts to "old" at t=800. First in, last out. +- **Bob** (adoption time t=400): adopted "new" at t=400. Reverts to "old" at t=600. +- **Carol** (adoption time t=600): would have adopted at t=600, but pivot was t=500. **Never sees "new".** +- **Fred** (adoption time t=700): would have adopted at t=700, but pivot was t=500. **Never sees "new".** + +Without the pivot-time gate, Carol and Fred would temporarily be _exposed_ to "new" during rollback before being reverted, exactly the wrong behavior during an incident. The gate prevents this: any user whose bucket exceeds the elapsed time at pivot is immediately returned to "old" without ever seeing "new". + +Nested operators (like `fractional`) are **not affected** โ€” the rollback uses the same hash, so fractional bucket assignments remain stable. + +### Future-proofing + +Later, we may want to support additional non-linear rollouts. +This can be done with an additional, optional, configuration parameter before the times params (similar to custom bucketing). + +```jsonc +{"rollout": [{"var": "email"}, "linear|exponential", 1704067200, 1706745600, "old", "new"]} +``` + +```jsonc +{"rollout": [{"var": "email"}, { some-json-logic-lambda }, 1704067200, 1706745600, "old", "new"]} +``` + +**Implementation of non-linear rollouts is out of the scope of this proposal.** + +### Alternative Proposal: Enhanced `fractional` with Dynamic Weights + +An alternative to a dedicated operator was proposed: use `fractional` with JSONLogic expressions as weights, combined with `$flagd.timestamp`, to achieve time-based progression without any new operator: + +```jsonc +{ + "fractional": [ + { "var": "targetingKey" }, + ["on", { "-": [{ "var": "$flagd.timestamp" }, 1740000000] }], + ["off", { "-": [1800000000, { "var": "$flagd.timestamp" }] }] + ] +} +``` + +As time advances, the weight of `"on"` grows and `"off"` shrinks, producing a progressive rollout using only existing primitives. +This requires allowing the `fractional` weight argument to be a JSONLogic expression (currently it must be a hard-coded integer), as well as clamping negative weights to 0, in addition to support for non-string/nested variants ([#1877](https://github.com/open-feature/flagd/pull/1877)) and high-precision bucketing. + +This approach is elegant and avoids a new operator. It achieves both forward rollout and FILO rollback using only existing JSONLogic primitives. The two approaches differ in the following ways: + +1. **FILO rollback.** With `fractional`, naively swapping the weight expressions to reverse a rollout produces FIFO ordering: early adopters revert first, not last. FILO rollback _is_ achievable by reflecting time around the pivot point. Given a rollout over `[Ts, Te]` pivoted at `Tp`, define `R = 2Tp - Ts` and use: + + ```jsonc + { + "fractional": [ + { "var": "targetingKey" }, + ["new", { "-": [R, { "var": "$flagd.timestamp" }] }], + ["old", { "-": [{ "+": [{ "var": "$flagd.timestamp" }, Te] }, 2Tp] }] + ] + } + ``` + + Where `R`, `Te`, `Ts`, and `Tp` are precomputed constants. Note that `R + Ts = (2Tp - Ts) + Ts = 2Tp`, so the `"old"` weight simplifies to `(t + Te) - 2Tp`. The `"new"` weight shrinks from `Tp - Ts` to 0, and the total weight is always `Te - Ts` (the rollout duration), naturally gating out never-transitioned users and reverting the rest in FILO order. + The rollback completes at `t = R = 2Tp - Ts`, not at the original `Te`; it mirrors the rollout at the same rate, so the rollback takes as long to complete as the rollout had progressed. For example, if the rollout ran for 300s before pivoting, the rollback also takes 300s. + +2. **Hash decorrelation.** The `rollout` operator automatically appends `"rollout"` (or some other salt) to the bucketing value before hashing, ensuring that a user's position in the rollout timeline does not correlate with their bucket in a nested `fractional`. +With the pure-fractional approach, the outer and inner `fractional` share the same hash, so early-rollout users systematically land in the first inner bucket. Users can work around this by manually adding a salt via `cat`, but that is non-obvious. + +3. **Operator surface area.** The `fractional` approach requires no new operators; only that `fractional` accept JSONLogic expressions as weight arguments (currently hard-coded integers). The dedicated operators are more readable but add new definition surface area that must be implemented across all language SDKs. + +#### Direct Comparison: Rollout with 50% Rollback + +To make the tradeoffs clear, here is both approaches implementing the same scenario: a linear rollout from `"off"` to `"on"` over `[1740000000, 1800000000]`, followed by a "first in, last out" rollback initiated at the 50% mark (`pivotTime = 1770000000`). + +**Using `rollout` / `rollback` operators:** + +Rollout: + +```jsonc +{"rollout": [1740000000, 1800000000, "off", "on"]} +``` + +Rollback (initiated at 50%): + +```jsonc +{"rollback": [1740000000, 1800000000, 1770000000, "on", "off"]} +``` + +**Using `fractional` with dynamic weights:** + +Rollout: + +```jsonc +{ + "fractional": [ + ["on", { "-": [{ "var": "$flagd.timestamp" }, 1740000000] }], + ["off", { "-": [1800000000, { "var": "$flagd.timestamp" }] }] + ] +} +``` + +Rollback (FILO, initiated at 50%; `R = 2 ร— 1770000000 โˆ’ 1740000000 = 1800000000`, `2Tp = 2 ร— 1770000000 = 3540000000`): + +```jsonc +{ + "fractional": [ + ["on", { "-": [1800000000, { "var": "$flagd.timestamp" }] }], + ["off", { "-": [{ "+": [{ "var": "$flagd.timestamp" }, 1800000000] }, 3540000000] }] + ] +} +``` + +Note: the `"off"` weight simplifies to `t โˆ’ 1740000000` (i.e., `t โˆ’ Ts`), which mirrors the original rollout's `"on"` weight. The total weight is always `1800000000 โˆ’ 1740000000 = 60000000` (the rollout duration), ensuring bucket assignments are preserved at the pivot instant. + +**Summary:** + +| | `rollout` / `rollback` | `fractional` with dynamic weights | +| ------------------------ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | +| **Rollout definition** | Single operator with explicit, non-nested parameters | Nested arithmetic expressions over `$flagd.timestamp` | +| **Rollback definition** | Single operator, same as `rollout`, but adds `pivotTime` | New set of weight expressions, requires precomputing `R = 2Tp โˆ’ Ts` | +| **Readability** | Intent is self-describing: time window, pivot, and direction are visible | User must reconstruct rollout semantics from arithmetic weight expressions | +| **Hash decorrelation** | Automatic (appends `"rollout"` salt before hashing) | Manual, requires adding a salt via `cat` to avoid correlated bucketing in nested `fractional` | +| **New operator surface** | Yes, `rollout` and `rollback` must be implemented in all SDKs | No, only requires `fractional` to accept JSONLogic weight expressions | +| **FILO correctness** | Built-in via `pivotTime` parameter | Achievable but non-obvious; naive weight-swap produces FIFO | + +### Consequences of Adding Rollout + +- Good, because this enables functionality present in many other systems +- Good, because time-based rollouts are declarative and require no external automation +- Good, because hashing is consistent with `fractional` +- Good, because integer-only math ensures cross-language portability +- Good, because nested JSONLogic enables complex rollout scenarios +- Good, because timestamp usage, array parameter style, and shorthand are consistent with other operators +- Good, because `rollback` enables graceful reversal without subjecting users to unnecessary thrashing. +- Bad, because it's more definition surface area to understand +- Bad, because additional timed mechanisms may represent changes in behavior ("time-bombs") that can be difficult to trace +- Bad, because consistently testing a time-sensitive operator might be somewhat challenging diff --git a/docs/architecture-decisions/semantic-versioning-policy.md b/docs/architecture-decisions/semantic-versioning-policy.md new file mode 100644 index 000000000..73cec26c9 --- /dev/null +++ b/docs/architecture-decisions/semantic-versioning-policy.md @@ -0,0 +1,183 @@ +--- +# Valid statuses: draft | proposed | rejected | accepted | superseded +status: accepted +author: Maks Osowski (@cupofcat) +created: 2026-01-22 +--- + +# flagd Ecosystem Semantic Versioning Policy + +## Introduction + +This document outlines the versioning policy for the flagd ecosystem, including the flagd binary, and language-specific providers. The goal is to establish a predictable, stable, and transparent contract between the components and their consumers and ensure that users can confidently adopt updates while allowing the ecosystem to evolve. + +The flagd ecosystem consists of distinct components that maintain their own versioning tracks but adhere to a unified policy. The scope for this policy is flagd binaries, and providers. Various API surfaces (e.g. Evaluation API, Sync API, OFREP) are out of scope. The compatibility between components and the [Flag Definition schema](https://flagd.dev/reference/schema/?h=schema) is in scope, as the schema acts as a fundamental feature-contract bundled with these components. + +## Legend and definitions + +**X.Y.Z** refers to the semantic version of a release component (**X** is the major version, **Y** is the minor version, **Z** is the patch version). + +### Major versions (X.) + +For example: 1.3.4 โ†’ 2.0.0 + +flagd community is not anticipating new major version releases (2.0.0) of any of the components. These are reserved for fundamental paradigm shifts or massive architectural changes. For the foreseeable future the ecosystem aims to maintain stability within the v1.x.y series. + +### Minor versions (Y.) + +For example: 1.3.4 โ†’ 1.4.0 + +Minor versions of components include new features and may include breaking changes or introduce incompatibilities. Breaking changes to existing functionality will only be released if adhering to the Deprecation Policy. + +### Patch versions (Z.) + +For example: 1.3.4 โ†’ 1.3.5 + +Patch releases are intended for critical bug fixes to the latest minor version, such as addressing security vulnerabilities, fixes to problems affecting a large number of users, and severe problems with no workaround. + +They should not contain miscellaneous feature additions or improvements, and especially no incompatibilities should be introduced between patch versions of the same minor version. + +Dependencies, such as JSON Logic, should also not be changed unless absolutely necessary, and also just to fix critical bugs (so, at most patch version changes, not new major nor minor versions). + +## Release Versioning + +The flagd ecosystem follows a lightweight, **feature-driven release process**. Releases are driven by feature readiness and stability needs rather than a rigid calendar schedule. This ensures that features reach users quickly without imposing unnecessary process overhead on maintainers. + +### Branching Strategy + +* **Main Branch (`main`):** The single source of truth. The main branch is expected to be in a stable, deployable state at all times. +* **Tags:** Releases are strictly defined by Git tags (e.g., `v1.2.0`) created directly on the main branch. +* **Maintenance Branches (Exception Only):** Long-lived release branches (e.g., `release-1.2`) are **not** created by default. They are only utilized if a critical patch is required for an older version while the main branch has already progressed with breaking changes. + +### Release Cadence and Process + +#### Minor Releases (X.Y.0) + +* **Trigger:** Released when a significant set of new features, improvements, or non-breaking changes have been merged to `main` and validated. +* **Cadence:** Ad-hoc. There are no mandatory waiting periods or fixed release windows. However, the set of changes going into each release is **planned ahead** of the release and the criteria for โ€œreadyโ€ are set ahead of time so all the languages can stay aligned. +* **Process:** A tag is pushed to the main branch, triggering the release pipeline to build artifacts and publish notes. + +#### Patch Releases (X.Y.Z) + +* **Trigger:** Released to fix critical bugs, security vulnerabilities, or regressions found in the current minor version. +* **Strategy (Roll-Forward):** The preferred method is to merge the fix to `main` and immediately cut the next patch release (e.g., `v1.2.1`). This minimizes branch management overhead. +* **Strategy (Backporting):** Backporting via a temporary branch is an exception, reserved only for critical fixes required for a specific version where rolling forward is not an option (e.g., the main branch contains breaking changes for the next minor version). + +#### Pre-Releases (Alpha/Beta/RC) + +* **Trigger:** **Optional**. Pre-releases are only utilized when a release contains significant architectural changes or high-risk features that require wider community testing before broad adoption. +* **Format:** `X.Y.Z-rc.N` (e.g., `v1.5.0-rc.1`). +* **Process:** Tagged directly from the main branch. If issues are found, fixes are merged to main, and a subsequent RC is tagged. There are no mandatory alpha/beta cycles to streamline the process. + +### Artifact Integrity + +Released artifacts (binaries, container images) must be immutable. Hashes (e.g., SHA-256) of all released artifacts must be published with the release notes. A specific version tag must always resolve to the same artifact hash. + +## Upgrades and SLOs + +We expect users to stay reasonably up-to-date with the versions of flagd components they use in production, but understand that it may take time to upgrade, especially for production-critical components. + +We expect users to be running approximately the latest patch release of a given minor release; we often include critical bug fixes in patch releases, and so encourage users to upgrade as soon as possible. + +We expect to โ€œsupportโ€ 3 minor releases at a time. "Support" means we expect users to be running that version in production, and we strive to port fixes back into the supported versions. +For example, when v1.3 comes out, v1.0 will no longer be โ€œsupportedโ€ but v1.1 would be expected to contain critical bug fixes discovered when v1.3 is the latest version. +Basically, that means that the reasonable response to the question "my v1.0 flagd Go provider isn't working," is, "you should probably upgrade it, (and probably should have some time ago)". + +Being an OSS project, we do **NOT** offer any SLA on resolving issues. + +We have a โ€œbest-effort SLOโ€ of: + +* addressing CVEs within 14 days of disclosure +* addressing severe bugs within 31 days of reporting + +## Component Skew + +### flagd Providers and APIs + +Providers communicate with the Sync and Evaluation API endpoints. Over time the API messages might evolve within the same major version to support new functionalities in backward compatible manners. To ensure system stability, we define the following policy. + +**API Compatibility**: Providers expect the API endpoints they are configured with to support the version of the provider. Providers themselves are not expected to support multiple versions of the API. Providers to not give any guarantees of forward or backward compatibility. Itโ€™s entirely the responsibility of the API owners to not break the clients that use their APIs. + +### flagd Components and the Flag Definition Schema + +The [Flag Definition Schema](https://flagd.dev/reference/schema/?h=schema) represents a **feature-contract** for the ecosystem. Each released version of the flagd binary and every provider bundles a schema at a particular version. This helps ensure deterministic support for all the features and targeting operators utilized in a definition file. + +**Version Alignment and Validation**: + +* **New Features**: When new features (e.g., a "regex" operator in targeting) are added to the schema, support for them is rolled out via **minor** version releases of the flagd binary and providers. +* **Validation**: If a user attempts to load a flag definition that uses features not present in the provider's or flagd binary's bundled schema version, the schema will fail to validate. The component will output specific error messages indicating the incompatibility, ensuring users know which version upgrade is required to support their configuration. +* A **patch** version upgrade of a flagd binary or provider will **NOT** change the bundled schema version to ensure stability. + +### flagd Providers and OpenFeature SDKs + +Each flagd provider implementation is bound to the OpenFeature SDK of its respective language. To ensure stability, we define the following policies: + +**Compatibility Declaration**: Each provider release **MUST** declare the [OpenFeature spec version](https://github.com/open-feature/spec/releases) itโ€™s compatible with. + +**Version Dependency**: + +* A **patch** version upgrade of a flagd provider will **NOT** change the spec version +* A **minor** version upgrade of a flagd provider **may** **increase** the spec version + +## Cross-Provider Alignment + +While released independently, providers strive for feature parity. + +**Minor Version Alignment**: We aim to align minor versions across providers to represent a consistent feature set and behavior (e.g., Python provider 1.1.x and Java provider 1.1.x should support the same Flag Definition schema version and offer similar configuration options). + +**Patch Divergence**: Patch versions are released independently as needed for language-specific fixes and do not require alignment. + +## Deprecation Policy + +To evolve the ecosystem without immediate breaking changes, we employ a strict deprecation process for the flagd binary and providers. + +**Announcement**: Features/Behavior must be marked and announced as deprecated in a **minor** release. + +**Duration**: Deprecated features must be supported for at least **3 minor releases** or **12 months**, whichever is longer (e.g. a feature deprecated in 1.0 is expected to be supported in 1.0, 1.1, and 1.2, and may be removed in 1.3 if 12 months have passed since the 1.0 release. If 12 months have not passed since the 1.0 release, the feature will continue to be supported in 1.3+ until the release that is released 12 months after the 1.0 release.) + +**Visibility**: Runtime warnings should be emitted when deprecated features are used. + +**Removal**: After the deprecation period, the feature may be removed in a subsequent **minor** release (marked as a breaking change) + +## Breaking Changes + +### For flagd binary (daemon) and providers + +* **Configuration**: Changes to the names, types, or default values of environmental variables, CLI flags (`flagd start` options), or provider constructor options (e.g., `FLAGD_CACHE`). + +* **Observability**: + * Changes to metric names or types that move, remove, or rename existing parts of the schemas (additions, e.g. of labels, are fine). + * Changes to the mapping of evaluation details to OpenTelemetry feature-flag event records. + * Please note that the following are considered **not** breaking: + * Removing or changing existing logging at any level (ERROR, WARNING, INFO, etc) + +* **Runtime Behavior**: + * Changes to startup behavior (e.g., fail-open vs. fail-close). + * Changes to retry-ability, idempotency, or backoff behavior. + * **Flag Definition Schema Validation**: Upgrading the bundled schema or validation logic in a way that drops support for previously valid operators, properties, or structures (i.e., backwards-incompatible schema changes that cause existing flag definition files to fail validation). + +* **Licensing**: Changes to the license texts of the artifacts. + +### Additionally, for flagd providers only + +* Changes to existing public interfaces (signatures, return types, or behavior for the same input/state) that break the existing client of those interfaces (note that, for example, adding a new optional configuration option to an existing interface is **not** breaking). +* Changing the minimum supported language runtime or compiler version. +* Changing the supported OpenFeature spec version + +### Additionally, for flagd binary (daemon) only + +* Changes to which API versions are exposed by default. +* Changes to the supported URI patterns for flag sources (e.g., `file:`, `kubernetes:`, `s3:`). +* Changes to the merge strategy or precedence when using multiple flag sources. +* Implementing documented future default flips (e.g., the plan to default `--disable-sync-metadata` to true is a breaking change). + +## Policy Rollout Checklist (Ahead of 1.0.0) + +Before graduating core components to 1.0.0 and finalizing this policy: + +* [ ] Battle-testing 1.0.0-rc.1 candidates extensively. +* [ ] Extraction and independent versioning of the `flagd-core` Go library. +* [ ] Implementation of CI checks to validate SemVer compliance (e.g., detecting accidental breaking changes in public APIs). +* [ ] Designation of release stewards for each component. +* [ ] Publication of the initial compatibility matrix. +* [ ] Establish the release notes process (where to publish, what format) for minor releases diff --git a/docs/architecture-decisions/static-and-dynamic-context.md b/docs/architecture-decisions/static-and-dynamic-context.md new file mode 100644 index 000000000..59109dcd5 --- /dev/null +++ b/docs/architecture-decisions/static-and-dynamic-context.md @@ -0,0 +1,63 @@ +--- +# Valid statuses: draft | proposed | rejected | accepted | superseded +status: accepted +author: @leakonvalinka +created: 2026-01-21 +updated: 2026-01-21 +--- + +# Static and Dynamic Context Enrichment in flagd + +This document retrospectively records the introduction of static context enrichment (`--context-value` or `-X`) and dynamic context enrichment (`--context-from-header` or `-H`) for flag evaluations in the flagd daemon. +It explains the purpose and basic use cases for both options and defines the merge priority if the same context item can be found in more than one provided context source. + +## Background + +Initially, evaluation context could only be provided in the request body. This required clients to include server-wide attributes (region, environment) with every request, even when these values never change during a flagd instance's lifetime. + +Additionally, useful context often exists in HTTP headers (set by API gateways or auth proxies) but couldn't be used for targeting without client-side extraction and forwarding. + +## Requirements + +* support HTTP header mapping in OFREP requests +* support HTTP header mapping in evaluation service v2 via connect +* clearly defined priority list for merging context values from multiple sources + +## Proposal + +### Allow definition of static context values at startup: `--context-value` or `-X` + +On startup, static context data can be provided in the form of concrete key-value pairs, which is then used as context in every evaluation. +This is aimed at attributes that do not change during a flagd instanceโ€™s lifetime (such as the server region or cloud provider), reducing effort on the client-side. +For example, `flagd start -X region=europe ...` adds a `region` attribute with value `europe` as a context to every evaluation. + +### Allow definition of header to context attribute mapping at startup: `--context-from-header` or `-H` + +On startup, specific request headers can be configured so that, for each incoming request, their values are automatically extracted and included as context attributes for the evaluation. +This is targeted at dynamic values that likely vary per request (like the email of the user making the request). +For example, `flagd start -H X-User-Email:userEmail ...` tells flagd to extract the value of the `X-User-Email` header from each incoming request and include it as the `userEmail` attribute in the evaluation context, if provided. + +### Merging + +In case the same context key can be found in more than one context source, this priority list defines which value takes precedence (from highest to lowest): + + 1. Dynamic context from request headers (`-H`) + 1. Static context from startup options (`-X`) + 1. Context provided in the evaluation request body + +This priority allows operators to enforce infrastructure-level context while still accepting client-provided values for other attributes. + +### Consequences + +* Good, because static context values reduce effort on the client-side for attributes that do not change often. +* Good, because this allows a more targeted way of providing context depending on whether the attribute is static or dynamic. + +### Timeline + +* Static context enrichment: The issue was created in October 2024, the final PR was merged in December 2024. +* Dynamic context enrichment: The issue was created in March 2025, the final PR was merged in June 2025. + +## More Information + +* [Static context enrichment Issue](https://github.com/open-feature/flagd/issues/1435) +* [Dynamic context enrichment Issue](https://github.com/open-feature/flagd/issues/1583) diff --git a/docs/architecture-decisions/support-code-default.md b/docs/architecture-decisions/support-code-default.md index b409fdf18..8518b86eb 100644 --- a/docs/architecture-decisions/support-code-default.md +++ b/docs/architecture-decisions/support-code-default.md @@ -1,8 +1,8 @@ --- status: accepted -author: @beeme1mr +author: Michael Beemer `@beeme1mr` created: 2025-06-06 -updated: 2025-06-20 +updated: 2025-08-08 --- # Support Explicit Code Default Values in flagd Configuration @@ -49,14 +49,17 @@ Related discussions and context can be found in the [OpenFeature specification]( We propose implementing **Option 1: Allow `null` as Default Variant**, potentially combined with **Option 2: Make Default Variant Optional** for maximum flexibility. -The implementation leverages field presence in evaluation responses across all protocols (in-process, RPC, and OFREP). When a flag configuration has `defaultVariant: null`, the evaluation response omits the value field entirely, which serves as a programmatic signal to the client to use its code-defined default value. +The implementation leverages field presence in evaluation responses across all protocols. +When a flag configuration has `defaultVariant: null`, the evaluation response omits the value field entirely and uses the "DEFAULT" reason code, which serves as a programmatic signal to the client to use its code-defined default value. This approach offers several key advantages: -1. **No Protocol Changes**: RPC and OFREP protocols remain unchanged -2. **Clear Semantics**: Omitted value field = "use your code default" -3. **Backward Compatible**: Existing clients and servers continue to work -4. **Universal Pattern**: Works consistently across all evaluation modes +1. **Semantically Correct**: Uses "DEFAULT" reason code which accurately represents the evaluation outcome +2. **Success Responses**: Treats code default usage as successful evaluation, not an error +3. **Clear Semantics**: Omitted value field = "use your code default" +4. **Backward Compatible**: Existing clients and servers continue to work +5. **Universal Pattern**: Works consistently across all evaluation modes +6. **Accurate Telemetry**: Metrics correctly reflect successful evaluations rather than false errors The absence of a value field provides an unambiguous signal that distinguishes between "the server evaluated to null/false/empty" (value field present) and "the server delegates to your code default" (value field absent). @@ -77,22 +80,57 @@ The absence of a value field provides an unambiguous signal that distinguishes b ``` 2. **Evaluation Behavior**: + - When flag has `defaultVariant: null` and targeting returns no match - - Server responds with reason set to reason "ERROR" and error code "FLAG_NOT_FOUND" - - Client detects this reason value field and uses its code-defined default - - This same pattern works across all evaluation modes + - Server responds with reason "DEFAULT" and omits value and variant fields + - Client detects the omitted fields and uses its code-defined default + - This pattern works consistently across all evaluation modes + +3. **Protobuf Schema Changes**: + + - Update response message definitions to use `optional` fields for `value` and `variant` + - This enables proper field presence detection for code default signaling + + Example protobuf changes: + + ```protobuf + message ResolveBooleanResponse { + // The response value, will be unset when deferring to code defaults + optional bool value = 1; + + // The reason for the given return value + string reason = 2; + + // The variant name, will be unset when deferring to code defaults + optional string variant = 3; + + // Metadata for this evaluation + google.protobuf.Struct metadata = 4; + } + ``` + +4. **Provider Implementation**: + + **RPC Providers**: -3. **Provider Implementation**: - - No changes to existing providers + - Providers must be updated to check field presence rather than just reading field values + + **In-Process Providers**: + + - Check if both the resolved variant (from targeting evaluation) AND the configured default variant are null/undefined + - When both conditions are true, use the application's code default value with reason "DEFAULT" and no variant ### Design Rationale -**Using "ERROR" reason**: We intentionally reuse the existing "ERROR" reason code rather than introducing a new one (like "CODE_DEFAULT"). This retains the current behavior of an disabled flag and allows for progressive enablement of a flag without unexpected variations in flag evaluation behavior. +**Using "DEFAULT" reason with omitted value fields**: We use the "DEFAULT" reason code to accurately represent that a default value is being used, combined with omitting the value and variant fields to signal code default deferral. This approach leverages recent OFREP improvements and requires updating protobuf definitions to use `optional` fields for proper field presence detection. Advantages of this approach: -- The "ERROR" reason is already used for cases where the flag is not found or misconfigured, so it aligns with the intent of using code defaults. -- This approach avoids introducing new reason codes that would require additional handling in providers and clients. +- **Accurate Semantics**: "DEFAULT" reason correctly represents the evaluation outcome +- **Proper Telemetry**: Evaluations are recorded as successful rather than errors +- **Clear Field Presence**: Optional fields provide unambiguous signaling across all protocols +- **Standards Aligned**: Leverages accepted patterns for optional values +- **Backward Compatible**: Existing clients continue to work while new clients can detect code defaults ### API changes @@ -118,15 +156,14 @@ flags: #### Single flag evaluation response -A single flag evaluation returns a `404` status code. +A single flag evaluation returns a `200` status code: ```json { "key": "my-feature", - "errorCode": "FLAG_NOT_FOUND", - // Optional error details - "errorDetails": "Targeting not matched, using code default", + "reason": "DEFAULT", "metadata": {} + // Note: No value field - indicates code default usage } ``` @@ -135,7 +172,12 @@ A single flag evaluation returns a `404` status code. ```json { "flags": [ - // Flag is omitted from bulk response + { + "key": "my-feature", + "reason": "DEFAULT", + "metadata": {} + // Note: No value field - indicates code default usage + } ] } ``` @@ -144,9 +186,9 @@ A single flag evaluation returns a `404` status code. ```protobuf { - "reason": "ERROR", - "errorCode": "FLAG_NOT_FOUND", + "reason": "DEFAULT", "metadata": {} + // Note: value and variant fields omitted to indicate code default } ``` @@ -157,20 +199,31 @@ A single flag evaluation returns a `404` status code. - Good, because it aligns flagd more closely with OpenFeature specification principles - Good, because it supports gradual flag rollout patterns more naturally - Good, because it provides the ability to delegate to whatever is defined in code -- Good, because it requires no changes to existing RPC or protocol signatures -- Good, because it uses established patterns (field presence) for clear semantics -- Good, because it maintains full backward compatibility +- Good, because it uses the "DEFAULT" reason code which accurately represents the evaluation outcome +- Good, because it treats code default usage as successful evaluation with proper telemetry +- Good, because telemetry can distinguish between configured defaults (variant present) and code defaults (variant absent) +- Good, because it uses a simple field presence pattern that works across all protocols +- Good, because it maintains backward compatibility for existing flag configurations +- Bad, because it requires protobuf schema changes to use `optional` fields - Bad, because it requires updates across multiple components (flagd, providers, testbed) - Bad, because it introduces a new concept that users need to understand -- Neutral, because existing configurations continue to work unchange +- Bad, because it creates a breaking change for older clients evaluating flags configured with `defaultVariant: null` (they would receive zero values instead of using code defaults) +- Bad, because providers must be updated to handle field presence detection +- Neutral, because existing configurations continue to work unchanged ### Implementation Plan -1. Update flagd-schemas with new JSON schema supporting null default variants -2. Update flagd-testbed with comprehensive test cases for all evaluation modes -3. Implement core logic in flagd to handle null defaults and omit value/variant fields -4. Update OpenFeature providers with the latest schema and test harness to ensure they handle the new behavior correctly -5. Documentation updates, migration guides, and playground examples to demonstrate the new configuration options +1. Update flagd protobuf schemas to use `optional` fields for `value` and `variant` in response messages +2. Update flagd-schemas with new JSON schema supporting null default variants +3. Update flagd-testbed with comprehensive test cases for all evaluation modes +4. Implement core logic in flagd to handle null defaults by conditionally omitting fields in responses +5. Update OpenFeature providers to check field presence rather than just reading field values +6. Regenerate protobuf client libraries for all supported languages with new optional field support +7. Release updated clients before configuring any flags with `defaultVariant: null` to avoid zero-value issues with older clients +8. Update provider documentation with field presence detection patterns for each language +9. Add backward compatibility testing to ensure existing clients continue to work +10. Update CI/CD pipelines to validate protobuf schema changes and field presence behavior +11. Documentation updates, migration guides, and playground examples to demonstrate the new configuration options ### Testing Considerations @@ -178,9 +231,12 @@ To ensure correct implementation across all components: 1. **Provider Tests**: Each component (flagd, providers) must have unit tests verifying the handling of `null` as a default variant 2. **Integration Tests**: End-to-end tests across different language combinations (e.g., Go flagd with Java provider) -3. **OFREP Tests**: Verify JSON responses correctly omits flags with a `null` default variant -4. **Backward Compatibility Tests**: Ensure old providers handle new responses gracefully -5. **Consistency Tests**: Verify identical behavior across in-process, RPC, and OFREP modes +3. **Schema Tests**: Verify protobuf schemas correctly define `optional` fields and generate appropriate client code +4. **Field Presence Tests**: Verify that providers can correctly detect field presence vs. absence across all languages +5. **OFREP Tests**: Verify JSON responses correctly omit value fields for code default scenarios +6. **RPC Tests**: Verify protobuf responses correctly omit optional fields for code default scenarios +7. **Backward Compatibility Tests**: Ensure old providers handle new responses gracefully +8. **Consistency Tests**: Verify consistent field presence behavior across all evaluation modes ### Open questions @@ -190,18 +246,27 @@ To ensure correct implementation across all components: - Yes, we'll support both `null` and absent fields to maximize flexibility. An absent `defaultVariant` will be the equivalent of `null`. - What migration path should we recommend for users currently using workarounds? - Update the flag configurations to use `defaultVariant: null` and remove any misconfigured rulesets that force code defaults. +- How should we handle the breaking change for older clients evaluating `defaultVariant: null` flags? + - Older clients built without optional protobuf fields will receive zero values (false, 0, "") instead of using code defaults. This requires coordinated rollout: (1) Update and deploy all clients with new protobuf definitions, (2) Only then configure flags with `defaultVariant: null`. Alternatively, maintain separate flag configurations during transition period. - Should this feature be gated behind a configuration flag during initial rollout? - We'll avoid public facing documentation until the feature is fully implemented and tested. - How do we ensure consistent behavior across all provider implementations? - Gherkin tests will be added to the flagd testbed to ensure all providers handle the new behavior consistently. -- Should providers validate that the reason is "DEFAULT" when value is omitted, or accept any omitted value as delegation? - - Providers should accept any omitted value as delegation. -- How do we handle edge cases where network protocols might strip empty fields? - - It would behaving as expected, as the absence of fields is the intended signal. +- How do OFREP providers detect and handle responses with omitted value fields? + - Providers should check for the absence of the `value` field in successful responses and treat it as a signal to use code defaults. +- Should we maintain backward compatibility for providers that don't yet support omitted value fields? + - Yes, older providers will continue to work but may not benefit from the code default deferral feature until updated. - When the client uses its code default after receiving a delegation response, what variant should be reported in telemetry/analytics? - - The variant will be omitted, indicating that the code default was used. -- Should we add explicit proto comments documenting the field omission behavior? - - Leave this to the implementers, but it would be beneficial to add comments in the proto files to clarify this behavior for future maintainers. + - No variant will be reported since the variant is unknown when using code defaults. The absence of a variant in telemetry indicates that a code default was used. +- Should we add explicit documentation about the field omission behavior? + - Yes, clear documentation should explain how omitted value fields signal code default deferral for implementers. + +## Revision History + +| Date | Author | Change Summary | +| ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2025-06-06 | Michael Beemer | Initial ADR creation with error-based approach | +| 2025-08-08 | Michael Beemer | **Major revision**: Changed from error-based (`FLAG_NOT_FOUND`) to success-based approach (`DEFAULT` reason) following OFREP improvements that enable optional value fields | ## More Information @@ -209,3 +274,4 @@ To ensure correct implementation across all components: - [flagd Flag Definitions Reference](https://flagd.dev/reference/flag-definitions/) - [flagd JSON Schema Repository](https://github.com/open-feature/flagd-schemas) - [flagd Testbed](https://github.com/open-feature/flagd-testbed) +- [OFREP ADR: Optional value field for code default deferral](https://github.com/open-feature/protocol/blob/main/service/adrs/0006-optional-value-for-code-defaults.md) diff --git a/docs/architecture-decisions/supported-languages.md b/docs/architecture-decisions/supported-languages.md new file mode 100644 index 000000000..e42808f74 --- /dev/null +++ b/docs/architecture-decisions/supported-languages.md @@ -0,0 +1,135 @@ +--- +# Valid statuses: draft | proposed | rejected | accepted | superseded +status: accepted +author: Maks Osowski (@cupofcat) +created: 2026-01-27 +--- + +# flagd Ecosystem Language Support Policy + +## Introduction + +Reference: flagd Semantic Versioning Policy (TODO) + +`flagd` providers are implemented in a wide range of programming languages. However, the maturity, feature completeness, and maintenance capacity for these languages vary. + +This document outlines the **Tiered Support Model** for flagd providers. Its goal is to set clear expectations for adopters regarding stability, feature parity, and security response times, while providing a clear path for community contributions to graduate to official support. + +**Source of Truth**: The Markdown file version of this policy located in `flagd` repository is the single source of truth. The flagd website and other documentation must reflect the content of this file. + +## Governance and Ownership + +To ensure long-term sustainability, we define specific roles and responsibilities regarding provider maintenance. + +### Roles + +* **Core Maintainers:** The maintainers of the flagd core project. They are responsible for vetting new language proposals, approving Tier promotions, and enforcing this policy. +* **Maintainers of the provider (language):** Individuals or organizations specifically responsible for a language provider (e.g., the "Java Provider Owner"). +* **Community Contributors:** Developers who submit PRs but do not hold long-term maintenance responsibilities. + +## Support Tiers and SLAs + +Language providers are categorized into three tiers. The classification determines the level of support users can expect. + +### Tier 1: Core Supported + +**Definition:** Tier 1 providers are "production-ready" and are the primary focus of the ecosystem. + +**Criteria:** + +* **Conformance** + * Must pass 100% of the OpenFeature spec conformance test suite. + * Must pass 100% of the Gherkin provider e2e scenarios suite. +* **Ownership:** Minimum of **2 dedicated provider (language) maintainers**. +* **Documentation:** Complete API references and usage examples in the repo. +* **Service Level Objectives (SLOs):** Follow the โ€œbest-effort SLOโ€ defined in the Semantic Versioning Policy +* **Releases:** + * All the tier 1 languages follow a **release train** โ€“ the Core Maintainers of flagd set a new release date and the list of features that are targeted for that release. The Maintainers of each language are responsible for ensuring that all the targeted features are implemented and tested before the release. + * If some language(s) will not make it the Core Maintainers can make one of the two choices: + * cut the feature from all the languages for that release + * postpone the release date for all the languages + * include the feature in some subset of languages as experimental, undocumented feature + * The main goal is to ensure feature parity per release for all tier 1 languages +* **Language and library support:** + * The latest versions of the providers support the current LTS version of the language, if one exists + * The latest version of the providers do not rely on deprecated language features or libraries + +### Tier 2: Community Supported + +**Definition:** Tier 2 providers are stable and usable but may lack advanced features or strict SLAs. They rely on the community for ongoing maintenance. + +**Criteria:** + +* **Conformance** + * Must pass 100% of the OpenFeature spec conformance test suite. + * Must pass 100% of the Gherkin provider e2e scenarios suite. +* **Ownership:** Minimum of **1 dedicated provider (language) maintainer**. +* **Documentation:** Might have some gaps +* **Service Level Objectives (SLOs):** None +* **Releases:** + * The numbering of the releases should match the numbering of the tier 1 releases (meaning, that the feature set is the same as in tier 1\) + * However, the tier 2 languages do NOT need to โ€œcatchโ€ the release train, they can lag behind the tier 1 +* **Language and library support:** Best effort + +### Tier 3: Experimental / Incubation + +**Definition:** Tier 3 includes new providers under active development, proof-of-concept implementations, or providers for niche languages with low usage. + +**Criteria:** + +* Work in progress or lacking a dedicated owner. +* May have incomplete API surfaces. +* **Use at your own risk.** No guarantees of API stability or backward compatibility. +* These providers may be archived if inactive for more than 6 months. + +## Features definitions + +* Core feature โ€“ a feature that must be present and fully supported in all the providers starting at a specific release (same for all languages) +* Experimental feature โ€“ a feature that can be present but undocumented only in the subset of languages and is not tied to any specific release train +* Language-specific feature โ€“ a feature that makes sense only in a specific language or a subset of languages; it does not need to follow the release train + +When a contributor wants to contribute a feature to a specific language but it lacks sponsorship or traction to be implemented in all languages it can be released as experimental until itโ€™s implemented across the board. If the contributor believes their feature should become a core feature they need to build traction and get buy-in / sponsorship from Core Maintainers. + +## Lifecycle Management + +The status of a language provider is not permanent. It reflects the current reality of the code and community. + +### Adding New Languages + +We welcome new language providers, but require a structured approach to prevent ecosystem fragmentation. + +1. **Proposal:** Open an issue in the `flagd` repository proposing the new language. +2. **Sponsorship:** A Core Maintainer must sponsor the addition. +3. **Incubation:** The repository is created (or transferred) and starts at **Tier 3**. +4. **Development:** The provider must reach a baseline of functionality before being advertised in official docs. + +### Promotion (Graduation) + +A provider may be promoted (e.g., Tier 2 โ†’ Tier 1\) upon request. + +* **Procedure:** The Provider Owner submits a "Promotion Request" issue. +* **Vetting:** Core Maintainers audit the codebase, check conformance tests, and verify the adherence to Tier requirements +* **Approval:** Requires a majority vote from the Core Maintainers. + +### Demotion and Deprecation + +A provider may be demoted (e.g., Tier 1 โ†’ Tier 2\) or deprecated. + +* **Demotion Triggers:** + * Loss of maintainers (dropping below the required count). + * Release lagging behind the latest +* **Deprecation (Sunset):** + * If a Tier 2 or 3 provider has no activity or owner for **6 months**, it will be marked as **Deprecated**. + * Deprecated repos will be archived (read-only) after an additional **3 month** grace period if no new owner steps forward. + +## Provider Development Best Practices + +To ensure a cohesive ecosystem, all providers should adhere to these development guidelines: + +* **Idiomatic Design:** APIs should feel natural to the language (e.g., use `Context` in Go, `Async/Await` in JS/C\#), rather than forcing a direct port of the Java or Go logic. +* **Minimal Dependencies:** Avoid heavy dependencies. The provider should be lightweight. +* **Generated Code:** Use `buf` or `protoc` for generating gRPC stubs from the official flagd schemas. Do not manually write protocol buffers code. +* **CI/CD:** All providers must have a GitHub Actions pipeline that runs: + * Linters/Formatters. + * Unit Tests. + * The standard flagd integration/conformance tests. diff --git a/docs/assets/cheat-sheet-flags-payments.json b/docs/assets/cheat-sheet-flags-payments.json new file mode 100644 index 000000000..6edb1922e --- /dev/null +++ b/docs/assets/cheat-sheet-flags-payments.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "payment-provider": { + "state": "ENABLED", + "variants": { + "slash": "slash", + "billbuddy": "billbuddy", + "cube": "cube" + }, + "defaultVariant": "slash" + }, + "max-transaction-amount": { + "state": "ENABLED", + "variants": { + "standard": 1000, + "elevated": 5000, + "unlimited": 999999 + }, + "defaultVariant": "standard", + "targeting": { + "if": [ + { "===": [{ "var": "account-verified" }, true] }, + "elevated", + "standard" + ] + } + }, + "enable-crypto-payments": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "off", + "targeting": { + "if": [ + { "in": [{ "var": "country" }, ["us", "ca", "uk", "de"]] }, + "on", + "off" + ] + } + } + }, + "metadata": { + "flagSetId": "payment-flags" + } +} diff --git a/docs/assets/cheat-sheet-flags.json b/docs/assets/cheat-sheet-flags.json new file mode 100644 index 000000000..86576219d --- /dev/null +++ b/docs/assets/cheat-sheet-flags.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "simple-boolean": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on" + }, + "simple-string": { + "state": "ENABLED", + "variants": { + "greeting": "Hello, World!", + "farewell": "Goodbye, World!" + }, + "defaultVariant": "greeting" + }, + "simple-number": { + "state": "ENABLED", + "variants": { + "low": 10, + "medium": 50, + "high": 100 + }, + "defaultVariant": "medium" + }, + "simple-object": { + "state": "ENABLED", + "variants": { + "config-a": { + "theme": "light", + "maxItems": 10 + }, + "config-b": { + "theme": "dark", + "maxItems": 25 + } + }, + "defaultVariant": "config-a" + }, + "code-default-flag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": null + }, + "code-default-flag-omitted": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + } + }, + "user-tier-flag": { + "state": "ENABLED", + "variants": { + "basic": "basic-features", + "premium": "premium-features", + "enterprise": "enterprise-features" + }, + "defaultVariant": "basic", + "targeting": { + "if": [ + { "===": [{ "var": "tier" }, "enterprise"] }, + "enterprise", + { "if": [ + { "===": [{ "var": "tier" }, "premium"] }, + "premium", + null + ]} + ] + } + }, + "email-based-feature": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "off", + "targeting": { + "if": [ + { "ends_with": [{ "var": "email" }, "@example.com"] }, + "on", + null + ] + } + }, + "region-config": { + "state": "ENABLED", + "variants": { + "us": { "currency": "USD", "dateFormat": "MM/DD/YYYY" }, + "eu": { "currency": "EUR", "dateFormat": "DD/MM/YYYY" }, + "default": { "currency": "USD", "dateFormat": "YYYY-MM-DD" } + }, + "defaultVariant": "default", + "targeting": { + "if": [ + { "in": [{ "var": "region" }, ["us", "usa", "united-states"]] }, + "us", + { "if": [ + { "in": [{ "var": "region" }, ["eu", "europe", "uk", "de", "fr"]] }, + "eu", + null + ]} + ] + } + } + }, + "metadata": { + "flagSetId": "app-flags" + } +} diff --git a/docs/assets/extra.css b/docs/assets/extra.css index ab632f44b..ed69f3204 100644 --- a/docs/assets/extra.css +++ b/docs/assets/extra.css @@ -30,4 +30,22 @@ button:disabled { .output.visible { opacity: 1; +} + +/* custom css arrow */ +.playground-back { + display: inline-flex; + align-items: center; + gap: 0.25em; + font-size: 0.85rem; + text-decoration: none; +} +.playground-back::before { + content: ''; + display: inline-block; + width: 0.45em; + height: 0.45em; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg); } \ No newline at end of file diff --git a/docs/concepts/metadata.md b/docs/concepts/metadata.md new file mode 100644 index 000000000..f7837163a --- /dev/null +++ b/docs/concepts/metadata.md @@ -0,0 +1,215 @@ +# Metadata + +Metadata in flagd provides contextual information about flags and flag sets. +It enables rich observability, logical separation, and debugging capabilities. + +## Overview + +Flagd supports metadata at two levels: + +- **Flag Set-Level Metadata**: Applied to entire flag configurations +- **Flag-Level Metadata**: Applied to individual flags + +## Metadata Inheritance + +Flagd uses a hierarchical metadata system where flags inherit metadata from their containing flag set, with the ability to override specific values at the flag level. + +### Flag Set-Level Metadata + +The most common pattern is defining metadata at the configuration level, where all flags inherit it: + +```json +{ + "metadata": { + "flagSetId": "payment-service", + "team": "payments", + "version": "v1.2.0", + "environment": "production" + }, + "flags": { + "checkout-flow": { + "state": "ENABLED", + "variants": {"on": true, "off": false}, + "defaultVariant": "on" + // Inherits all set-level metadata + }, + "payment-gateway": { + "state": "DISABLED", + "variants": {"on": true, "off": false}, + "defaultVariant": "off" + // Also inherits all set-level metadata + } + } +} +``` + +### Flag-Level Overrides + +Individual flags can override inherited metadata or add flag-specific metadata: + +```json +{ + "metadata": { + "flagSetId": "payment-service", + "team": "payments", + "version": "v1.2.0" + }, + "flags": { + "standard-feature": { + "state": "ENABLED", + "variants": {"on": true, "off": false}, + "defaultVariant": "on" + // Inherits: flagSetId="payment-service", team="payments", version="v1.2.0" + }, + "experimental-feature": { + "state": "DISABLED", + "variants": {"on": true, "off": false}, + "defaultVariant": "off", + "metadata": { + // Still inherits: flagSetId="payment-service", version="v1.2.0" + "team": "marketing", // Override: different flag set + "owner": "Tom", // Addition: flag-specific metadata + "experimental": true // Addition: flag-specific metadata + } + } + } +} +``` + +### Inheritance Behavior + +1. **Default Inheritance**: Flags inherit all set-level metadata +2. **Selective Override**: Flag-level metadata overrides specific inherited values +3. **Additive Enhancement**: Flag-level metadata can add new keys not present at set level +4. **Preserved Inheritance**: Non-overridden set-level metadata remains inherited + +## Metadata Reflection + +Metadata reflection provides transparency by echoing selector and configuration information back in API responses. This enables debugging, auditing, and verification of flag targeting. + +### Selector Reflection + +When making requests with selectors, flagd "reflects" the parsed selector information in the "top-level" `metadata` field: + +**Request:** + +```bash +curl -H "Flagd-Selector: flagSetId=payment-service" \ + http://localhost:8014/ofrep/v1/evaluate/flags +``` + +**Response includes reflected metadata:** + +```json +{ + "flags": { + "checkout-flow": { + "key": "checkout-flow", + "value": true, + "variant": "on", + "metadata": { + "flagSetId": "payment-service", + "team": "payments" + } + } + }, + "metadata": { + "flagSetId": "payment-service" // Reflected from selector + } +} +``` + +### Configuration Reflection + +Flag evaluation responses include the complete merged metadata for each flag: + +```json +{ + "key": "experimental-feature", + "value": false, + "variant": "off", + "metadata": { + "flagSetId": "experiments", // Overridden at flag level + "owner": "research-team", // Added at flag level + "experimental": true, // Added at flag level + "team": "payments", // Inherited from set level + "version": "v1.2.0" // Inherited from set level + } +} +``` + +## Common Metadata Fields + +### Standard Fields + +Some metadata fields are defined in the flag-definition schema for common use-cases: + +- **`flagSetId`**: Logical grouping identifier for selectors +- **`version`**: Configuration or flag version + +### Custom Fields + +You can define any custom metadata fields relevant to your use case: + +```json +{ + "metadata": { + "flagSetId": "user-service", + "version": "v34", + "costCenter": "engineering", + "compliance": "pci-dss", + "lastReviewed": "2024-01-15", + "approver": "team-lead" + } +} +``` + +## Retrieving Metadata in the OpenFeature SDK + +Flag metadata is available in evaluation details returned by flag evaluations. + +### Go + +```go +details, err := client.BooleanValueDetails(ctx, "new-checkout-flow", false, evalCtx) + +// Access metadata from evaluation details +metadata := details.FlagMetadata +flagSetId := metadata["flagSetId"] +team := metadata["team"] +``` + +### Java + +```java +FlagEvaluationDetails details = client.getBooleanDetails( + "new-checkout-flow", false, new ImmutableContext()); + +// Access metadata from evaluation details +ImmutableMetadata metadata = details.getFlagMetadata(); +String flagSetId = metadata.getString("flagSetId"); +String team = metadata.getString("team"); +``` + +### JavaScript + +```javascript +const details = await client.getBooleanDetails('new-checkout-flow', false, {}); + +// Access metadata from evaluation details +const metadata = details.flagMetadata; +const flagSetId = metadata.flagSetId; +const team = metadata.team; +``` + +## Use Cases + +**Debugging**: Metadata reflection shows which selectors were used and how inheritance resolved, making it easier to troubleshoot flag targeting issues. + +**Governance**: Track team ownership, compliance requirements, and approval workflows through custom metadata fields. + +**Environment Management**: Use metadata for version tracking, environment identification, and change management across deployments. + +**Multi-Tenancy**: Isolate tenants through flag sets and maintain tenant-specific configurations and governance. + +**Observability**: Metadata attributes can be used in telemetry spans and metrics, providing operational visibility into flag usage patterns and configuration context. diff --git a/docs/concepts/selectors.md b/docs/concepts/selectors.md new file mode 100644 index 000000000..87e43120f --- /dev/null +++ b/docs/concepts/selectors.md @@ -0,0 +1,199 @@ +# Selectors + +Selectors are query expressions that allow you to filter flag configurations from flagd. +They enable providers to sync or evaluate only specific subsets of flags instead of all flags, making flagd more efficient and flexible for complex deployments, and supporting basic multi-tenancy. + +## Overview + +In flagd, **selectors** provide a way to query flags based on different criteria. This is particularly powerful because flagd decouples **flag sources** from **flag sets**, allowing for more granular control over which flags are synchronized and evaluated. + +### Key Concepts + +- **Flag Source**: Where flag configuration data comes from (file, HTTP endpoint, gRPC service, etc.) +- **Flag Set**: A logical grouping of flags identified by a `flagSetId` +- **Selector**: A query expression that filters flags by source, flag set, or other criteria +- **Flag Set Metadata**: The selector information is "reflected" back in response metadata for transparency + +See the [cheat sheet](../reference/cheat-sheet.md#using-the-selector-header) for practical examples of using selectors. + +!!! tip + + The `flagSetId` + `key` combination represents the unique identifier for a flag. + Be sure not to create duplicates, or unexpected behavior may result. + See [Array-Based Flag Definitions](#array-based-flag-definitions) for how this enables flags with the same key to coexist in different flag sets. + +## Source vs Flag Set Decoupling + +### Before: Tight Coupling + +Historically, each source provided exactly one flag set, and providers had to target specific sources: + +```yaml +# Old approach - targeting a specific source +selector: "my-flag-source.json" +``` + +### After: Flexible Flag Sets + +Now, sources and flag sets are decoupled. A single source can contain multiple flag sets, and flag sets can span multiple sources: + +```yaml +# New approach - targeting a logical flag set +selector: "flagSetId=project-42" +``` + +### Array-Based Flag Definitions + +Flags can be defined as an array instead of an object, with each flag specifying its `key` explicitly: + +```json +{ + "flags": [ + { + "key": "checkout-flow", + "state": "ENABLED", + "variants": {"on": true, "off": false}, + "defaultVariant": "on", + "metadata": { "flagSetId": "payment-service" } + }, + { + "key": "checkout-flow", + "state": "DISABLED", + "variants": {"on": true, "off": false}, + "defaultVariant": "off", + "metadata": { "flagSetId": "user-service" } + } + ] +} +``` + +This format is useful for systems that generate large flag configurations programmatically. It also allows flags with the same key to coexist when they belong to different flag sets, since the `flagSetId` + `key` combination represents the unique identifier for a flag. + +## Flag Set Configuration + +Flag sets are typically configured at the top level of a flag configuration, with all flags in that configuration inheriting the same `flagSetId`. This is the recommended approach for most use cases. + +### Set-Level Configuration + +The most common pattern is to set the `flagSetId` at the configuration level, where all flags inherit it: + +```json +{ + "metadata": { + "flagSetId": "payment-service", + "version": "v1.2.0" + }, + "flags": { + "new-checkout-flow": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on" + }, + "bill-buddy-integration": { + "state": "DISABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "off" + } + } +} +``` + +In this example, both `new-checkout-flow` and `bill-buddy-integration` flags belong to the `payment-service` flag set. + +### Flag-Level Configuration + +Alternatively, the `flagSetId` can be defined on flag level: + +```json +{ + "metadata": { + "version": "v1.2.0" + }, + "flags": { + "new-checkout-flow": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on", + "metadata": { + "flagSetId": "webshop", + "version": "v1.2.0" + } + }, + "bill-buddy-integration": { + "state": "DISABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "off", + "metadata": { + "flagSetId": "payment-service", + "version": "v1.2.0" + }, + } + } +} +``` + +In this example the two flags `new-checkout-flow` and `bill-buddy-integration` flags belong to different flag sets. + +### Metadata Integration + +Selectors work closely with flagd's metadata system. For advanced patterns like flag-level overrides of `flagSetId` or complex metadata inheritance, see the [Metadata concepts](metadata.md) section. + +## Metadata Reflection + +When you make a request with a selector, flagd "reflects" the selector information back in the response metadata for transparency and debugging. For complete details on metadata selector reflection, inheritance, and configuration patterns, see the [Metadata concepts](metadata.md) section. + +## Use Cases + +### Multi-Tenant Applications + +```yaml +# Tenant A's flags +selector: "flagSetId=tenant-a" + +# Tenant B's flags +selector: "flagSetId=tenant-b" +``` + +### Component Separation + +```yaml +# Web service +selector: "flagSetId=payment-service" +# Web application +selector: "flagSetId=webshop" +``` + +### Environment Separation + +```yaml +# Development environment +selector: "flagSetId=dev-features" + +# Production environment +selector: "flagSetId=prod-features" +``` + +### Legacy Source-Based Selection + +```yaml +# Still supported for backward compatibility +selector: "source=legacy-config.json" +``` + +## Best Practices + +1. **Use Flag Sets for Logical Grouping**: Prefer `flagSetId` over `source` for new deployments +2. **Plan Your Flag Set Strategy**: Design flag sets around logical boundaries (teams, features, environments) +3. **Leverage Metadata**: Use metadata for debugging and auditing +4. **Document Your Schema**: Clearly document your flag set naming conventions for your team +5. **Do Not Duplicate Flags Across Sources**: Make sure that flags with the same key and flagSetId do not exist in multiple sources (relative priority of flags in such configurations is not defined). + +## Migration Considerations + +The selector enhancement maintains full backward compatibility. See the [migration guide](../guides/migrating-to-flag-sets.md) for detailed guidance on transitioning from source-based to flag-set-based selection patterns. diff --git a/docs/concepts/syncs.md b/docs/concepts/syncs.md index 94edf41b0..3d80456c5 100644 --- a/docs/concepts/syncs.md +++ b/docs/concepts/syncs.md @@ -2,6 +2,7 @@ Syncs are a core part of flagd; they are the abstraction that enables different sources for feature flag definitions. flagd can connect to one or more sync sources. +See the [cheat sheet](../reference/cheat-sheet.md#running-flagd) for quick startup examples with different sync sources. ## Available syncs diff --git a/docs/faq.md b/docs/faq.md index 3227b7c12..036da18f7 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -30,6 +30,7 @@ flagd is sub-project of OpenFeature and aims to be fully [OpenFeature-compliant] You can run flagd as a standalone application, accessible over HTTP or gRPC, or you can embed it into your application. Please see [architecture](./architecture.md) and [installation](./installation.md) for more information. +For quick command examples, see the [cheat sheet](./reference/cheat-sheet.md). --- diff --git a/docs/guides/migrating-to-flag-sets.md b/docs/guides/migrating-to-flag-sets.md new file mode 100644 index 000000000..90ec10a67 --- /dev/null +++ b/docs/guides/migrating-to-flag-sets.md @@ -0,0 +1,150 @@ +# Migrating to Flag Sets + +This guide helps you transition from source-based selector patterns to flag-set-based patterns, taking advantage of flagd's enhanced selector capabilities while maintaining backward compatibility. + +## Understanding the Change + +### Before: Source-Based Selection + +In the traditional approach, providers targeted specific sources: + +```yaml +# Provider configuration targeting a source file +selector: "config/my-flags.json" +``` + +This created tight coupling between providers and sources: + +- Providers had to know which source contained their flags +- Moving flags between sources required provider reconfiguration +- One source could only serve one logical set of flags + +### After: Flag Set-Based Selection + +With flag sets, providers target logical groupings of flags: + +```yaml +# Provider configuration targeting a flag set +selector: "flagSetId=my-application" +``` + +This provides flexibility: + +- Providers are decoupled from sources +- Sources can contain multiple flag sets +- Flag sets can span multiple sources +- No breaking changes - old selectors still work + +## Migration Process + +### Step 1: Add Flag Set IDs to Configurations + +Add `flagSetId` to your flag configurations at the set level: + +**Before:** + +```json +{ + "flags": { + "feature-a": { + "state": "ENABLED", + "variants": {"on": true, "off": false}, + "defaultVariant": "on" + } + } +} +``` + +**After:** + +```json +{ + "metadata": { + "flagSetId": "my-application" + }, + "flags": { + "feature-a": { + "state": "ENABLED", + "variants": {"on": true, "off": false}, + "defaultVariant": "on" + } + } +} +``` + +### Step 2: Update Provider Configurations + +Change provider selectors from source-based to flag set-based: + +```java +// Before +new FlagdProvider(FlagdOptions.builder() + .selector("config/app-flags.json").build()); + +// After +new FlagdProvider(FlagdOptions.builder() + .selector("flagSetId=my-application").build()); +``` + +### Step 3: Verify Migration + +Test that selectors work correctly and check metadata reflection: + +```bash +curl -H "Flagd-Selector: flagSetId=my-application" \ + http://localhost:8014/ofrep/v1/evaluate/flags +``` + +## Flag Set Organization Patterns + +**By Application/Service:** + +```yaml +flagSetId: "user-service" # All user-related flags +flagSetId: "payment-service" # All payment-related flags +``` + +**By Environment:** + +```yaml +flagSetId: "development" # Dev-specific flags +flagSetId: "production" # Production flags +``` + +**By Team:** + +```yaml +flagSetId: "frontend-team" # Frontend features +flagSetId: "backend-team" # Backend features +``` + +Choose the pattern that best matches your deployment and organizational structure. + +## Common Issues + +**No flags returned**: Check that `flagSetId` in selector matches flag configuration exactly + +**Wrong flags returned**: Look for flag-level `flagSetId` overrides or header/body selector conflicts + +**Selector ignored**: Verify selector syntax is correct (`flagSetId=value`, not `flagSetId:value`) + +## Best Practices + +- **Group logically**: Organize flags by service, environment, or team +- **Name consistently**: Use clear, descriptive flag set names +- **Test first**: Validate migration in non-production environments +- **Use metadata reflection**: Check reflected metadata for debugging + +## FAQ + +**Q: Do I have to migrate?** +A: No, source-based selectors continue to work. Migration is optional but recommended. + +**Q: Can flag sets span multiple sources?** +A: Yes, multiple sources can contribute flags to the same flag set. + +## Additional Resources + +- [Selector Concepts](../concepts/selectors.md) - Understanding selectors and flag sets +- [Selector Syntax Reference](../reference/selector-syntax.md) - Complete syntax documentation +- [ADR: Decouple Flag Source and Set](../architecture-decisions/decouple-flag-source-and-set.md) - Technical decision rationale diff --git a/docs/installation.md b/docs/installation.md index a0f5ca4df..55741c694 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -26,6 +26,8 @@ If you're interested in a full-featured solution for using flagd in Kubernetes, For more information, see [OpenFeature Operator](./reference/openfeature-operator/overview.md). +You can also choose to run a Kubernetes service in front of a deployment with multiple flagd pods connecting to the same data source. However, if doing so, be aware that synchronization is not instant. The service may return different values after a change until all pods have synchronized with the data source. This synchronization delay is typically brief. + --- ## Binary @@ -43,12 +45,6 @@ For more information, see [OpenFeature Operator](./reference/openfeature-operato A systemd wrapper is available [here](https://github.com/open-feature/flagd/blob/main/systemd/flagd.service). -### Homebrew - -```shell -brew install flagd -``` - ## Summary Once flagd is installed, you can start using it within your application. diff --git a/docs/playground/index.md b/docs/playground/index.md index 899d1f942..78d787c2e 100644 --- a/docs/playground/index.md +++ b/docs/playground/index.md @@ -1,3 +1,9 @@ +--- +hide: + - navigation + - toc +--- + B{Is an array containing exactly three items?}; B -- Yes --> C{Is targetingRule at index 0 a semantic version string?}; -B -- No --> D[Return false]; +B -- No --> D[Return null]; C -- Yes --> E{Is targetingRule at index 1 a supported operator?}; C -- No --> D; E -- Yes --> F{Is targetingRule at index 2 a semantic version string?}; diff --git a/docs/reference/specifications/custom-operations/string-comparison-operation-spec.md b/docs/reference/specifications/custom-operations/string-comparison-operation-spec.md index 8d57b3b71..a753ad31e 100644 --- a/docs/reference/specifications/custom-operations/string-comparison-operation-spec.md +++ b/docs/reference/specifications/custom-operations/string-comparison-operation-spec.md @@ -32,7 +32,7 @@ The following flow chart depicts the logic of this evaluator: flowchart TD A[Parse targetingRule] --> B{Is an array containing exactly two items?}; B -- Yes --> C{Is targetingRule at index 0 a string?}; -B -- No --> D[Return false]; +B -- No --> D[Return null]; C -- Yes --> E{Is targetingRule at index 1 a string?}; C -- No --> D; E -- No --> D; diff --git a/docs/reference/specifications/protos.md b/docs/reference/specifications/protos.md index 9840f2306..4cf1bae25 100644 --- a/docs/reference/specifications/protos.md +++ b/docs/reference/specifications/protos.md @@ -321,7 +321,9 @@ FetchAllFlagsRequest is the request to fetch all flags. Clients send this reques | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | | provider_id | [string](#string) | | Optional: A unique identifier for clients initiating the request. The server implementations may utilize this identifier to uniquely identify, validate(ex:- enforce authentication/authorization) and filter flag configurations that it can expose to this request. This field is intended to be optional. However server implementations may enforce it. ex:- provider_id: flagd-weatherapp-sidecar | -| selector | [string](#string) | | Optional: A selector for the flag configuration request. The server implementation may utilize this to select flag configurations from a collection, select the source of the flag or combine this to any desired underlying filtering mechanism. ex:- selector: 'source=database,app=weatherapp' | +| selector | [string](#string) | | **Deprecated.** Optional: A selector for the flag configuration request. The server implementation may utilize this to select flag configurations from a collection, select the source of the flag or combine this to any desired underlying filtering mechanism. ex:- selector: 'source=database,app=weatherapp' + +Deprecated: Use the 'Flagd-Selector' header instead. Remember to reserve field number 2 if this is removed; | @@ -379,7 +381,9 @@ Implementations of Flagd providers and Flagd itself send this request, acting as | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | | provider_id | [string](#string) | | Optional: A unique identifier for flagd(grpc client) initiating the request. The server implementations may utilize this identifier to uniquely identify, validate(ex:- enforce authentication/authorization) and filter flag configurations that it can expose to this request. This field is intended to be optional. However server implementations may enforce it. ex:- provider_id: flagd-weatherapp-sidecar | -| selector | [string](#string) | | Optional: A selector for the flag configuration request. The server implementation may utilize this to select flag configurations from a collection, select the source of the flag or combine this to any desired underlying filtering mechanism. ex:- selector: 'source=database,app=weatherapp' | +| selector | [string](#string) | | **Deprecated.** Optional: A selector for the flag configuration request. The server implementation may utilize this to select flag configurations from a collection, select the source of the flag or combine this to any desired underlying filtering mechanism. ex:- selector: 'source=database,app=weatherapp' + +Deprecated: Use the 'Flagd-Selector' header instead. Remember to reserve field number 2 if this is removed; | diff --git a/docs/reference/specifications/providers.md b/docs/reference/specifications/providers.md index 101570ea7..0490fcb4a 100644 --- a/docs/reference/specifications/providers.md +++ b/docs/reference/specifications/providers.md @@ -42,7 +42,7 @@ The lifecycle is summarized below: - for RPC providers, flags resolved with `reason=STATIC` are [cached](#flag-evaluation-caching) - if flags change the associated stream (event or sync) indicates flags have changed, flush cache, or update `flag set` rules respectively and emit `PROVIDER_CONFIGURATION_CHANGED` - if stream disconnects: - - [reconnect](#stream-reconnection) with exponential backoff offered by GRPC. + - [reconnect](#stream-reconnection) with automatic gRPC retry policy and explicit application-level backoff (see [stream reconnection](#stream-reconnection)). - if disconnected time <= `retryGracePeriod` - emit `PROVIDER_STALE` - RPC mode resolves `STALE` from cache where possible @@ -64,18 +64,21 @@ stateDiagram-v2 NOT_READY --> ERROR: initialize READY --> ERROR: disconnected, disconnected period == 0 READY --> STALE: disconnected, disconnect period < retry grace period + READY --> NOT_READY: shutdown STALE --> ERROR: disconnect period >= retry grace period + STALE --> NOT_READY: shutdown ERROR --> READY: reconnected - ERROR --> [*]: shutdown + ERROR --> NOT_READY: shutdown + ERROR --> [*]: Error code == PROVIDER_FATAL - note right of STALE + note left of STALE stream disconnected, attempting to reconnect, resolve from cache* resolve from flag set rules** STALE emitted end note - note right of READY + note left of READY stream connected, evaluation cache active*, flag set rules stored**, @@ -84,7 +87,7 @@ stateDiagram-v2 CHANGE emitted with stream messages end note - note right of ERROR + note left of ERROR stream disconnected, attempting to reconnect, evaluation cache purged*, ERROR emitted @@ -101,25 +104,53 @@ stateDiagram-v2 ### Stream Reconnection -When either stream (sync or event) disconnects, whether due to the associated deadline being exceeded, network error or any other cause, the provider attempts to re-establish the stream immediately, and then retries with an exponential back-off. -We always rely on the [integrated functionality of GRPC for reconnection](https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md) and utilize [Wait-for-Ready](https://grpc.io/docs/guides/wait-for-ready/) to re-establish the stream. -We are configuring the underlying reconnection mechanism whenever we can, based on our configuration. (not all GRPC implementations support this) +When either stream (sync or event) fails or completes, whether due to the associated deadline being exceeded, network error or any other cause, the provider attempts to re-establish the stream. +Both the event and sync streams will forever attempt to be re-established in cases of reconnection (no status codes are considered fatal after the initial connection, see: [fatal status codes](#fatal-status-codes)). +This is distinct from the [gRPC retry-policy](#grpc-retry-policy), which automatically retries *all RPCs* (streams or otherwise) a limited number of times to make the provider resilient to transient errors. +It's also distinct from the [gRPC layer 4 reconnection mechanism](https://grpc.github.io/grpc/core/md_doc_connection-backoff.html) which only reconnects the TCP connection, but not any streams. +When the stream is reconnecting, providers transition to the [STALE](https://openfeature.dev/docs/reference/concepts/events/#provider_stale) state, and after `retryGracePeriod`, transition to the ERROR state, emitting the respective events during these transitions. -| language/property | min connect timeout | max backoff | initial backoff | jitter | multiplier | -|-------------------|-----------------------------------|--------------------------|--------------------------|--------|------------| -| GRPC property | grpc.initial_reconnect_backoff_ms | max_reconnect_backoff_ms | min_reconnect_backoff_ms | 0.2 | 1.6 | -| Flagd property | deadlineMs | retryBackoffMaxMs | retryBackoffMs | 0.2 | 1.6 | -| --- | --- | --- | --- | --- | --- | -| default [^1] | โœ… | โœ… | โœ… | 0.2 | 1.6 | -| js | โœ… | โœ… | โŒ | 0.2 | 1.6 | -| java | โŒ | โŒ | โŒ | 0.2 | 1.6 | +Due to the fact that neither the gRPC retry policy nor the L4 reconnection mechanism prevent tight loops when stream errors are returned immediately at the application layer (for example, by an intervening L7 proxy), providers must apply an explicit application-level delay of `retryBackoffMaxMs` before re-establishing the stream after an error or completion. -[^1] : C++, Python, Ruby, Objective-C, PHP, C#, js(deprecated) +## gRPC Retry Policy -When disconnected, if the time since disconnection is less than `retryGracePeriod`, the provider emits `STALE` when it disconnects. -While the provider is in state `STALE` the provider resolves values from its cache or stored flag set rules, depending on its resolver mode. -When the time since the last disconnect first exceeds `retryGracePeriod`, the provider emits `ERROR`. -The provider attempts to reconnect indefinitely, with a maximum interval of `retryBackoffMaxMs`. +flagd leverages gRPC built-in retry mechanism for all RPCs. +In short, the retry policy attempts to retry all RPCs which return `UNAVAILABLE` or `UNKNOWN` status codes 3 times, with a 1s, 2s, 4s, backoff respectively. +No other status codes are retried. +The flagd gRPC retry policy is specified below: + +```json +{ + "methodConfig": [ + { + "name": [ + { + "service": "flagd.evaluation.v1.Service" + }, + { + "service": "flagd.sync.v1.FlagSyncService" + } + ], + "retryPolicy": { + "MaxAttempts": 4, + "InitialBackoff": "1s", + "MaxBackoff": $FLAGD_RETRY_BACKOFF_MAX_MS, // from provider options + "BackoffMultiplier": 2.0, + "RetryableStatusCodes": [ + "UNAVAILABLE", + "UNKNOWN" + ] + } + } + ] +} +``` + +## Fatal Status Codes + +Providers accept an option for defining fatal gRPC status codes which, when received in the RPC or sync streams during initialization, transition the provider to the PROVIDER_FATAL state. +This configuration is useful for situations wherein these codes indicate to a client that their configuration is invalid and must be changed (i.e., the error is non-transient). +Examples for this include status codes such as `UNAUTHENTICATED` or `PERMISSION_DENIED`. ## RPC Resolver @@ -210,6 +241,11 @@ In addition to the built-in evaluators provided by JsonLogic, the following cust - [Semantic version evaluation](../../reference/custom-operations/semver-operation.md) - [StartsWith/EndsWith evaluation](../../reference/custom-operations/string-comparison-operation.md) +### Shared Evaluator Resolution + +Before evaluating a flag's targeting rules, providers resolve any `$ref` references by replacing them with the corresponding entry from the `$evaluators` object defined in the flag set. +Nested references (`$ref` within a `$ref`) are not supported; each shared evaluator must be self-contained. + ### Targeting Key Similar to the flagd daemon, in-process providers map the [targeting-key](https://openfeature.dev/specification/glossary#targeting-key) into a top level property of the context used in rules, with the key `"targetingKey"`. @@ -262,28 +298,29 @@ precedence. Below are the supported configuration parameters (note that not all apply to both resolver modes): -| Option name | Environment variable name | Explanation | Type & Values | Default | Compatible resolver | -| --------------------- | ------------------------------ | ---------------------------------------------------------------------- | ---------------------------- | ----------------------------- | ----------------------- | -| resolver | FLAGD_RESOLVER | mode of operation | String - `rpc`, `in-process` | rpc | rpc & in-process | -| host | FLAGD_HOST | remote host | String | localhost | rpc & in-process | -| port | FLAGD_PORT | remote port | int | 8013 (rpc), 8015 (in-process) | rpc & in-process | -| targetUri | FLAGD_TARGET_URI | alternative to host/port, supporting custom name resolution | string | null | rpc & in-process | -| tls | FLAGD_TLS | connection encryption | boolean | false | rpc & in-process | -| socketPath | FLAGD_SOCKET_PATH | alternative to host port, unix socket | String | null | rpc & in-process | -| certPath | FLAGD_SERVER_CERT_PATH | tls cert path | String | null | rpc & in-process | -| deadlineMs | FLAGD_DEADLINE_MS | deadline for unary calls, and timeout for initialization | int | 500 | rpc & in-process & file | -| streamDeadlineMs | FLAGD_STREAM_DEADLINE_MS | deadline for streaming calls, useful as an application-layer keepalive | int | 600000 | rpc & in-process | -| retryBackoffMs | FLAGD_RETRY_BACKOFF_MS | initial backoff for stream retry | int | 1000 | rpc & in-process | -| retryBackoffMaxMs | FLAGD_RETRY_BACKOFF_MAX_MS | maximum backoff for stream retry | int | 120000 | rpc & in-process | -| retryGracePeriod | FLAGD_RETRY_GRACE_PERIOD | period in seconds before provider moves from STALE to ERROR state | int | 5 | rpc & in-process & file | -| keepAliveTime | FLAGD_KEEP_ALIVE_TIME_MS | http 2 keepalive | long | 0 | rpc & in-process | -| cache | FLAGD_CACHE | enable cache of static flags | String - `lru`, `disabled` | lru | rpc | -| maxCacheSize | FLAGD_MAX_CACHE_SIZE | max size of static flag cache | int | 1000 | rpc | -| selector | FLAGD_SOURCE_SELECTOR | selects a single sync source to retrieve flags from only that source | string | null | in-process | -| providerId | FLAGD_PROVIDER_ID | A unique identifier for flagd(grpc client) initiating the request. | string | null | in-process | -| offlineFlagSourcePath | FLAGD_OFFLINE_FLAG_SOURCE_PATH | offline, file-based flag definitions, overrides host/port/targetUri | string | null | file | -| offlinePollIntervalMs | FLAGD_OFFLINE_POLL_MS | poll interval for reading offlineFlagSourcePath | int | 5000 | file | -| contextEnricher | - | sync-metadata to evaluation context mapping function | function | identity function | in-process | +| Option name | Environment variable name | Explanation | Type & Values | Default | Compatible resolver | +| --------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------- | ---------------------------- | ----------------------------- | ----------------------- | +| resolver | FLAGD_RESOLVER | mode of operation | string - `rpc`, `in-process` | rpc | rpc & in-process | +| host | FLAGD_HOST | remote host | string | localhost | rpc & in-process | +| port | FLAGD_PORT | remote port | int | 8013 (rpc), 8015 (in-process) | rpc & in-process | +| targetUri | FLAGD_TARGET_URI | alternative to host/port, supporting custom name resolution | string | null | rpc & in-process | +| tls | FLAGD_TLS | connection encryption | boolean | false | rpc & in-process | +| socketPath | FLAGD_SOCKET_PATH | alternative to host port, unix socket | string | null | rpc & in-process | +| certPath | FLAGD_SERVER_CERT_PATH | tls cert path | string | null | rpc & in-process | +| deadlineMs | FLAGD_DEADLINE_MS | deadline for unary calls, and timeout for initialization | int | 500 | rpc & in-process & file | +| streamDeadlineMs | FLAGD_STREAM_DEADLINE_MS | deadline for streaming calls, useful as an application-layer keepalive | int | 600000 | rpc & in-process | +| retryBackoffMs | FLAGD_RETRY_BACKOFF_MS | initial backoff for stream retry | int | 1000 | rpc & in-process | +| retryBackoffMaxMs | FLAGD_RETRY_BACKOFF_MAX_MS | maximum backoff for stream retry | int | 12000 | rpc & in-process | +| retryGracePeriod | FLAGD_RETRY_GRACE_PERIOD | period in seconds before provider moves from STALE to ERROR state | int | 5 | rpc & in-process & file | +| keepAliveTime | FLAGD_KEEP_ALIVE_TIME_MS | http 2 keepalive | long | 0 | rpc & in-process | +| selector | FLAGD_SOURCE_SELECTOR | expression to filter flags (e.g., `flagSetId=my-app`, `source=config.json`) | string | null | rpc & in-process | +| cache | FLAGD_CACHE | enable cache of static flags | string - `lru`, `disabled` | lru | rpc | +| maxCacheSize | FLAGD_MAX_CACHE_SIZE | max size of static flag cache | int | 1000 | rpc | +| providerId | FLAGD_PROVIDER_ID | A unique identifier for flagd(grpc client) initiating the request. | string | null | in-process | +| offlineFlagSourcePath | FLAGD_OFFLINE_FLAG_SOURCE_PATH | offline, file-based flag definitions, overrides host/port/targetUri | string | null | file | +| offlinePollIntervalMs | FLAGD_OFFLINE_POLL_MS | poll interval for reading offlineFlagSourcePath | int | 5000 | file | +| contextEnricher | - | sync-metadata to evaluation context mapping function | function | identity function | in-process | +| fatalStatusCodes | FLAGD_FATAL_STATUS_CODES | a list of gRPC status codes, which will cause streams to give up and put the provider in a PROVIDER_FATAL state | array | [] | rpc & in-process | ### Custom Name Resolution @@ -298,8 +335,103 @@ envoy://localhost:9211/flagd-sync.service The custom name resolver provider in this case will use the endpoint name i.e. `flagd-sync.service` as [authority](https://github.com/grpc/grpc-java/blob/master/examples/src/main/java/io/grpc/examples/nameresolve/ExampleNameResolver.java#L55-L61) and connect to `localhost:9211`. -### Metadata +### Selector Configuration + +Providers support selector configuration to filter which flags are synchronized or evaluated. This enables more granular control in multi-tenant or multi-environment deployments. + +#### Selector Syntax + +Providers accept selector expressions using the following syntax: + +- **Flag Set Selection**: `flagSetId=` - Target flags belonging to a specific flag set +- **Source Selection**: `source=` - Target flags from a specific source (legacy) +- **Backward Compatibility**: `` - Treated as source selection + +#### Selector Precedence + +When selectors are provided in multiple locations, the following precedence applies: + +1. **Request Header**: `Flagd-Selector` header (RPC and OFREP requests) +2. **Provider Configuration**: `selector` option in provider constructor + +#### Usage Examples + +**Flag Set-Based Selection (Recommended):** + +```javascript +const provider = new FlagdProvider({ + host: 'localhost', + port: 8013, + selector: 'flagSetId=user-service' +}); +``` + +**Source-Based Selection (Legacy):** + +```javascript +const provider = new FlagdProvider({ + host: 'localhost', + port: 8013, + selector: 'source=config/app-flags.json' +}); +``` + +**Header-Based Selection:** + +```bash +# gRPC request with selector header +grpcurl -H "Flagd-Selector: flagSetId=payment-service" \ + localhost:8013 flagd.evaluation.v1.Service/ResolveBoolean + +# OFREP request with selector header +curl -H "Flagd-Selector: flagSetId=frontend-features" \ + http://localhost:8014/ofrep/v1/evaluate/flags/my-flag +``` + +### Metadata and Metadata Reflection + +#### Flag Metadata When a flag is resolved, the returned [metadata](./flag-definitions.md#metadata) is a merged representation of the metadata defined on the flag set, and on the flag, with the flag metadata taking priority. Flag metadata is returned on a "best effort" basis when flags are resolved: disabled, missing or erroneous flags return the metadata of the associated flag set whenever possible. This is particularly important for debugging purposes and error metrics. + +#### Selector Metadata "Reflection" + +Flagd "reflects" selector information back in response metadata, providing transparency about query execution. This helps with debugging selector expressions and understanding which flags were actually queried. + +**Example - gRPC Response:** + +```protobuf +// Request with selector header: "Flagd-Selector: flagSetId=payment-service" +message ResolveBooleanResponse { + bool value = 1; + string reason = 2; + string variant = 3; + google.protobuf.Struct metadata = 4; // Contains reflected selector info +} +``` + +**Example - OFREP Response:** + +```json +{ + "value": true, + "reason": "TARGETING_MATCH", + "variant": "on", + "metadata": { + "flagSetId": "payment-service", + "team": "payments", + "version": "1.2.0" + } +} +``` + +#### Debugging with Metadata + +Use reflected metadata to: + +- **Verify Selector Parsing**: Confirm your selector was interpreted correctly +- **Debug Empty Results**: Check if selectors are filtering flags as expected +- **Audit Access Patterns**: Log selector metadata for compliance and monitoring +- **Troubleshoot Configuration**: Identify selector precedence issues diff --git a/docs/reference/sync-configuration.md b/docs/reference/sync-configuration.md index 8cf679efd..54cef03c1 100644 --- a/docs/reference/sync-configuration.md +++ b/docs/reference/sync-configuration.md @@ -47,17 +47,20 @@ The flagd accepts a string argument, which should be a JSON representation of an Alternatively, these configurations can be passed to flagd via config file, specified using the `--config` flag. -| Field | Type | Note | -| ----------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| uri | required `string` | Flag configuration source of the sync | -| provider | required `string` | Provider type - `file`, `fsnotify`, `fileinfo`, `kubernetes`, `http`, `grpc`, `gcs` or `azblob` | -| authHeader | optional `string` | Used for http sync; set this to include the complete `Authorization` header value for any authentication scheme (e.g., "Bearer token_here", "Basic base64_credentials", etc.). | -| interval | optional `uint32` | Used for http, gcs and azblob syncs; requests will be made at this interval. Defaults to 5 seconds. | -| tls | optional `boolean` | Enable/Disable secure TLS connectivity. Currently used only by gRPC sync. Default (ex: if unset) is false, which will use an insecure connection | -| providerID | optional `string` | Value binds to grpc connection's providerID field. gRPC server implementations may use this to identify connecting flagd instance | -| selector | optional `string` | Value binds to grpc connection's selector field. gRPC server implementations may use this to filter flag configurations | -| certPath | optional `string` | Used for grpcs sync when TLS certificate is needed. If not provided, system certificates will be used for TLS connection | -| maxMsgSize | optional `int` | Used for gRPC sync to set max receive message size (in bytes) e.g. 5242880 for 5MB. If not provided, the default is [4MB](https://pkg.go.dev/google.golang.org#grpc#MaxCallRecvMsgSize) | +| Field | Type | Note | +| ------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| uri | required `string` | Flag configuration source of the sync | +| provider | required `string` | Provider type - `file`, `fsnotify`, `fileinfo`, `kubernetes`, `http`, `grpc`, `gcs`, `azblob` or `s3` | +| authHeader | optional `string` | Used for http sync; set this to include the complete `Authorization` header value for any authentication scheme (e.g., "Bearer token_here", "Basic base64_credentials", etc.). | +| interval | optional `uint32` | Used for http, gcs, azblob and s3 syncs; requests will be made at this interval. Defaults to 5 seconds. Maximum is 86400 (1 day). | +| intervalSeed | optional `string` | Used for http, gcs, azblob and s3 syncs; deterministic seed for poll schedule offset. Different seeds cause instances to poll at different wall-clock offsets within the interval, avoiding thundering herd. Instances with the same seed poll at the same offset. If unset, offset defaults to 0. In multi-instance deployments, setting this to a unique value per instance (e.g., the Kubernetes pod name via `fieldRef: metadata.name`) is recommended to avoid synchronized polling. | +| tls | optional `boolean` | Enable/Disable secure TLS connectivity. Currently used only by gRPC sync. Default (ex: if unset) is false, which will use an insecure connection | +| providerID | optional `string` | Value binds to grpc connection's providerID field. gRPC server implementations may use this to identify connecting flagd instance | +| selector | optional `string` | Selector expression to filter flag configurations. Supports `source=` and `flagSetId=` syntax. See [selector syntax](selector-syntax.md) for details. | +| certPath | optional `string` | Used for grpcs sync when TLS certificate is needed. If not provided, system certificates will be used for TLS connection | +| maxMsgSize | optional `int` | Used for gRPC sync to set max receive message size (in bytes) e.g. 5242880 for 5MB. If not provided, the default is [4MB](https://pkg.go.dev/google.golang.org#grpc#MaxCallRecvMsgSize) | +| incrementalUpdates | optional `boolean` | **Experimental.** Used for gRPC sync. When true, each update only replaces flags matching the flagSetIds in the payload, allowing flags from other flagSetIds to accumulate. When false (default), each update replaces all flags for the source. See [caveats](#incremental-updates-experimental) below. | +| headers | optional `map[string]string` | Custom key-value pairs sent as HTTP headers or gRPC metadata with outbound sync requests. | The `uri` field values **do not** follow the [URI patterns](#uri-patterns). The provider type is instead derived from the `provider` field. Only exception is the remote provider where `http(s)://` is expected by default. Incorrect @@ -85,6 +88,7 @@ Sync providers: - `grpc`(envoy) - envoy://localhost:9211/test.service - `gcs` - gs://my-bucket/my-flags.json - `azblob` - azblob://my-container/my-flags.json +- `s3` - s3://my-bucket/my-flags.json Startup command: @@ -100,9 +104,10 @@ Startup command: {"uri":"grpc-source:8080","provider":"grpc"}, {"uri":"my-flag-source:8080","provider":"grpc", "maxMsgSize": 5242880}, {"uri":"envoy://localhost:9211/test.service", "provider":"grpc"}, - {"uri":"my-flag-source:8080","provider":"grpc", "certPath": "/certs/ca.cert", "tls": true, "providerID": "flagd-weatherapp-sidecar", "selector": "source=database,app=weatherapp"}, + {"uri":"my-flag-source:8080","provider":"grpc", "certPath": "/certs/ca.cert", "tls": true, "providerID": "flagd-weatherapp-sidecar", "selector": "flagSetId=weatherapp"}, {"uri":"gs://my-bucket/my-flag.json","provider":"gcs"}, - {"uri":"azblob://my-container/my-flag.json","provider":"azblob"}]' + {"uri":"azblob://my-container/my-flag.json","provider":"azblob"}, + {"uri":"s3://my-bucket/my-flag.json","provider":"s3"}]' ``` Configuration file, @@ -132,11 +137,13 @@ sources: certPath: /certs/ca.cert tls: true providerID: flagd-weatherapp-sidecar - selector: "source=database,app=weatherapp" + selector: "flagSetId=weatherapp" - uri: gs://my-bucket/my-flag.json provider: gcs - uri: azblob://my-container/my-flags.json provider: azblob + - uri: s3://my-bucket/my-flags.json + provider: s3 ``` ### HTTP Configuration @@ -144,15 +151,28 @@ sources: The HTTP Configuration also supports OAuth that allows to securely fetch feature flag configurations from an HTTP endpoint that requires OAuth-based authentication. -#### CLI-based OAuth Configuration +### Custom Headers -To enable OAuth, you need to update your Flagd configuration setting the `oauth` object which contains parameters to configure +Custom headers can be set per-source in the JSON configuration using the `headers` field. +Headers are sent as HTTP request headers for HTTP sync and as gRPC metadata for gRPC sync. -.... +This is useful when the sync backend sits behind infrastructure that inspects request headers. For example, an API gateway, ingress proxy, or service mesh that performs routing, tenant isolation, or access control based on headers it expects to see on every request. -#### File-based OAuth Configuration +For HTTP sync: + +```json +[{"uri":"http://my-flags.com/flags","provider":"http","headers":{"X-Custom":"value","X-Tenant-ID":"tenant1"}}] +``` + +For gRPC sync: + +```json +[{"uri":"my-flags.com:8080","provider":"grpc","selector": "flagSetId=flags", "headers":{"X-Custom":"value","X-Tenant-ID":"tenant1"}}] +``` + +#### CLI-based OAuth Configuration -the `clientID`, `clientSecret`, and the `tokenURL` for the OAuth Server. +To enable OAuth, you need to update your Flagd configuration by setting the `oauth` object. This object contains parameters to configure the `clientID`, `clientSecret`, and the `tokenURL` for the OAuth Server. ```sh ./bin/flagd start @@ -168,10 +188,11 @@ the `clientID`, `clientSecret`, and the `tokenURL` for the OAuth Server. }}]' ``` -Secrets can also be managed from the file system. This can be handy when, for example, deploying Flagd in Kubernetes. In this case, the client id and secret -will be read from the files `client-id` and `client-secret`, respectively. If the `folder` attribute is set, client id and secret on top level will be ignored. -To support rotating the secrets without restarting flagd, the additional parameter `ReloadDelayS` can be used to force -the reload of the secrets from the filesystem every `ReloadDelayS` seconds. +#### File-based OAuth Configuration + +Secrets can also be managed from the file system. This can be handy when, for example, deploying Flagd in Kubernetes. If the `folder` attribute is set, any `clientID` and `clientSecret` values provided directly within the `oauth` object are ignored. +In this case, the client id and secret will be read from the files `client-id` and `client-secret`, respectively. +To support rotating the secrets without restarting flagd, the additional parameter `ReloadDelayS` can be used to force the reload of the secrets from the filesystem every `ReloadDelayS` seconds. ```sh ./bin/flagd start @@ -186,3 +207,44 @@ the reload of the secrets from the filesystem every `ReloadDelayS` seconds. "tokenURL": "http://localhost:8180/sso/oauth2/token" }}]' ``` + +## Selector Configuration + +Selectors allow you to filter flag configurations from sync sources. Add the `selector` field to source configurations: + +```yaml +sources: + - uri: grpc://flag-server:8080 + provider: grpc + selector: "flagSetId=payment-service" # Flag set selection + - uri: grpc://flag-server:8080 + provider: grpc + selector: "source=legacy-flags" # Source selection (legacy) +``` + +### Selector Precedence + +1. **Request Headers**: `Flagd-Selector` header (highest priority) +2. **Request Body**: `selector` field in request +3. **Configuration**: `selector` field in source configuration (lowest priority) + +For complete selector syntax, patterns, and examples, see the [Selectors concepts](../concepts/selectors.md) and [Selector Syntax Reference](selector-syntax.md). + +## Incremental Updates (Experimental) + +By default, each sync update from a source is treated as a **full snapshot**: all existing flags for that source are removed and replaced with the flags in the update. + +When `incrementalUpdates` is set to `true` on a gRPC source, updates are instead scoped by `flagSetId`. Only flags belonging to the flagSetId(s) present in the update payload are replaced; flags from other flagSetIds are preserved. This is useful when a single gRPC sync backend streams per-flagSetId (e.g., per-project) updates rather than full snapshots. + +```yaml +sources: + - uri: grpc://my-multi-project-backend:8080 + provider: grpc + incrementalUpdates: true +``` + +### Caveats + +- **Orphaned flags**: if a flagSetId is renamed, removed, or stops being sent by the upstream, its flags will persist in the store until flagd is restarted. There is no automatic garbage collection. To manually clean up a specific flagSetId, the upstream must send an empty update with that flagSetId (i.e., `{"metadata": {"flagSetId": "old-id"}, "flags": []}`). +- **This option is per-source, not per-message**: once enabled, all updates from that source use flagSetId-scoped deletion. +- **This feature is experimental** and may change or be removed in a future release. diff --git a/docs/schema/v0/targeting.json b/docs/schema/v0/targeting.json index 4409c3e37..f8dd7544f 100644 --- a/docs/schema/v0/targeting.json +++ b/docs/schema/v0/targeting.json @@ -35,7 +35,7 @@ "type": "string" }, { - "description": "When returned from rules, strings are used to as keys to retrieve the associated value from the \"variants\" object. Be sure that the returned string is present as a key in the variants!.", + "description": "When returned from rules, the behavior of arrays is not defined.", "type": "array" } ] @@ -461,18 +461,26 @@ "maxItems": 2, "items": [ { - "description": "If this bucket is randomly selected, this string is used to as a key to retrieve the associated value from the \"variants\" object.", - "type": "string" + "description": "If this bucket is randomly selected, this JSONLogic will be evaluated, and the result will be used as the variant key to return from the variants map.", + "$ref": "#/definitions/args" }, { - "description": "Weighted distribution for this variant key.", - "type": "number" + "description": "Weighted distribution for this variant key. Must be a non-negative integer. Can be a JSONLogic expression that evaluates to a number (e.g. for time-based progressive rollouts); computed negative weights are clamped to 0 at evaluation time. The total weight sum across all variants must not exceed 2,147,483,647.", + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "#/definitions/anyRule" + } + ] } ] }, "fractionalOp": { "type": "array", - "minItems": 3, + "minItems": 1, "$comment": "there seems to be a bug here, where ajv gives a warning (not an error) because maxItems doesn't equal the number of entries in items, though this is valid in this case", "items": [ { @@ -492,7 +500,7 @@ }, "fractionalShorthandOp": { "type": "array", - "minItems": 2, + "minItems": 1, "items": { "$ref": "#/definitions/fractionalWeightArg" } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 6537d81f1..48a283256 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -24,7 +24,7 @@ Why is my `int` response a `string`? Command: ```sh -curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveInt" -d '{"flagKey":"myIntFlag","context":{}}' -H "Content-Type: application/json" +curl -X POST "localhost:8013/flagd.evaluation.v2.Service/ResolveInt" -d '{"flagKey":"myIntFlag","context":{}}' -H "Content-Type: application/json" ``` Result: @@ -40,7 +40,7 @@ If a number value is required, and none of the provided SDK's can be used, then Command: ```sh -curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveFloat" -d '{"flagKey":"myIntFlag","context":{}}' -H "Content-Type: application/json" +curl -X POST "localhost:8013/flagd.evaluation.v2.Service/ResolveFloat" -d '{"flagKey":"myIntFlag","context":{}}' -H "Content-Type: application/json" ``` Result: @@ -71,3 +71,82 @@ You may need to explicitly allow HTTP2 or gRPC in your platform if you're using !!! note HTTP2 _is not_ strictly for the flag [evaluation gRPC service](./reference/specifications/protos.md#schemav1schemaproto), which is exposed both as a gRPC service and a RESTful HTTP/1.1 service, thanks to the [connect protocol](https://connectrpc.com/docs/protocol/). + +--- + +## Selector Issues + +### No Flags Returned with Selector + +**Problem**: Provider returns no flags when using a selector. + +**Debugging Steps:** + +- Verify `flagSetId` in selector matches flag configuration exactly +- Check selector syntax: `flagSetId=my-app` (not `flagSetId:my-app`) +- Test without selector to confirm flags exist + +### Wrong Flags Returned + +**Problem**: Selector returns unexpected flags. + +**Debugging Steps:** + +- Check for flag-level `flagSetId` overrides in individual flags +- Verify header precedence: `Flagd-Selector` header overrides request body +- Use metadata reflection to see what selector was actually applied + +### Selector Ignored + +**Problem**: Selector appears to be ignored, all flags returned. + +**Debugging Steps:** + +- Verify selector syntax is correct (`key=value` format) +- Check if provider configuration has a selector that overrides requests +- Ensure selector value is not empty (`flagSetId=` returns all flags without flagSetId) + +**Debug with metadata reflection:** + +```bash +curl -H "Flagd-Selector: flagSetId=my-app" \ + http://localhost:8014/ofrep/v1/evaluate/flags +# Check response metadata to see parsed selector +``` + +--- + +## Variant/value not included in response + +When you see that `value` and `variant` fields are missing from flag evaluation responses, it indicates that flagd is delegating to the code-defined default value. This is the expected behavior when `defaultVariant` is set to `null` or omitted. + +### Configured Default + +When a flag has an explicit `defaultVariant` configured: + +```json +{ + "value": false, + "reason": "DEFAULT", + "variant": "off", + "metadata": {} +} +``` + +This means the configured default variant was used because no targeting rule matched. + +### Code Default + +When a flag has `defaultVariant: null` or no `defaultVariant` is defined: + +```json +{ + "reason": "DEFAULT", + "metadata": {} + // Note: value and variant fields are omitted +} +``` + +This indicates that flagd is delegating to the code-defined default value. The absence of `value` and `variant` fields signals to the client SDK to use its code default. + +For more information about code defaults, see [Code Defaults](./concepts/feature-flagging.md#code-defaults). diff --git a/flagd-proxy/CHANGELOG.md b/flagd-proxy/CHANGELOG.md index 6c625a31c..c73fc1301 100644 --- a/flagd-proxy/CHANGELOG.md +++ b/flagd-proxy/CHANGELOG.md @@ -1,5 +1,86 @@ # Changelog +## [0.9.6](https://github.com/open-feature/flagd/compare/flagd-proxy/v0.9.5...flagd-proxy/v0.9.6) (2026-05-28) + + +### โœจ New Features + +* bump core for custom headers, add docs ([#1969](https://github.com/open-feature/flagd/issues/1969)) ([4f33a0c](https://github.com/open-feature/flagd/commit/4f33a0c71818ffcd8063295c4c8c0e9285818105)) + +## [0.9.5](https://github.com/open-feature/flagd/compare/flagd-proxy/v0.9.4...flagd-proxy/v0.9.5) (2026-04-30) + + +### ๐Ÿงน Chore + +* resolve open dependabot security alerts ([#1954](https://github.com/open-feature/flagd/issues/1954)) ([c5adbb7](https://github.com/open-feature/flagd/commit/c5adbb7e9aefc16dfb69852a3d5f67b4473d4305)) + +## [0.9.4](https://github.com/open-feature/flagd/compare/flagd-proxy/v0.9.3...flagd-proxy/v0.9.4) (2026-04-09) + + +### ๐Ÿ› Bug Fixes + +* **security:** update vulnerability-updates [security] ([#1933](https://github.com/open-feature/flagd/issues/1933)) ([04338dc](https://github.com/open-feature/flagd/commit/04338dc21358b80f96da7a5ac736107f08093d60)) +* **security:** update vulnerability-updates [security] ([#1934](https://github.com/open-feature/flagd/issues/1934)) ([40d444a](https://github.com/open-feature/flagd/commit/40d444abac6b0a40a1b5190c2205540eaaaa0b55)) + + +### ๐Ÿงน Chore + +* fix proxy test race ([17cd08f](https://github.com/open-feature/flagd/commit/17cd08f081e5ad2419fc4a3972b7ece2d2a54d33)) + +## [0.9.3](https://github.com/open-feature/flagd/compare/flagd-proxy/v0.9.2...flagd-proxy/v0.9.3) (2026-04-07) + + +### ๐Ÿ› Bug Fixes + +* **security:** update module github.com/go-jose/go-jose/v4 to v4.1.4 [security] ([#1929](https://github.com/open-feature/flagd/issues/1929)) ([cf22a11](https://github.com/open-feature/flagd/commit/cf22a110652af6f3ef867c17b9c6ea9471c9e5f1)) + +## [0.9.2](https://github.com/open-feature/flagd/compare/flagd-proxy/v0.9.1...flagd-proxy/v0.9.2) (2026-03-21) + + +### ๐Ÿ› Bug Fixes + +* **security:** update module google.golang.org/grpc to v1.79.3 [security] ([#1907](https://github.com/open-feature/flagd/issues/1907)) ([ad51d4e](https://github.com/open-feature/flagd/commit/ad51d4e8fe0570474c824273983f54b3ca38b083)) + +## [0.9.1](https://github.com/open-feature/flagd/compare/flagd-proxy/v0.9.0...flagd-proxy/v0.9.1) (2026-03-09) + + +### ๐Ÿ› Bug Fixes + +* **security:** update otel deps, minimum core Go version ([#1897](https://github.com/open-feature/flagd/issues/1897)) ([6b79bf8](https://github.com/open-feature/flagd/commit/6b79bf8419da1e269ecb1d1db03760379fc201cb)) + +## [0.9.0](https://github.com/open-feature/flagd/compare/flagd-proxy/v0.8.3...flagd-proxy/v0.9.0) (2026-03-04) + + +### โš  BREAKING CHANGES + +* no `defaultVariant` -> code default (previosuly FLAG_NOT_FOUND) ([#1862](https://github.com/open-feature/flagd/issues/1862)) + +### โœจ New Features + +* no `defaultVariant` -> code default (previosuly FLAG_NOT_FOUND) ([#1862](https://github.com/open-feature/flagd/issues/1862)) ([89117d8](https://github.com/open-feature/flagd/commit/89117d8eaba0e9d205b3b47544528c42d5698176)) + +## [0.8.3](https://github.com/open-feature/flagd/compare/flagd-proxy/v0.8.2...flagd-proxy/v0.8.3) (2026-01-09) + + +### ๐Ÿ› Bug Fixes + +* **security:** update module github.com/open-feature/flagd/core to v0.13.1 [security] ([#1846](https://github.com/open-feature/flagd/issues/1846)) ([a8a57ad](https://github.com/open-feature/flagd/commit/a8a57adb1d49640bfc14bf02cf77961652f7516a)) + +## [0.8.2](https://github.com/open-feature/flagd/compare/flagd-proxy/v0.8.1...flagd-proxy/v0.8.2) (2025-12-27) + + +### ๐Ÿ› Bug Fixes + +* **security:** update go for various stdlib CVEs ([#1840](https://github.com/open-feature/flagd/issues/1840)) ([6dcb36d](https://github.com/open-feature/flagd/commit/6dcb36d2d6b55b7fe0b6107ac9a25baf302c5cdc)) + +## [0.8.1](https://github.com/open-feature/flagd/compare/flagd-proxy/v0.8.0...flagd-proxy/v0.8.1) (2025-12-23) + + +### ๐Ÿ› Bug Fixes + +* **security:** update module github.com/go-viper/mapstructure/v2 to v2.4.0 [security] ([#1784](https://github.com/open-feature/flagd/issues/1784)) ([037e30b](https://github.com/open-feature/flagd/commit/037e30b2f87897499580b25497049b88da7e386c)) +* **security:** update module golang.org/x/crypto to v0.45.0 [security] ([#1826](https://github.com/open-feature/flagd/issues/1826)) ([7e0762b](https://github.com/open-feature/flagd/commit/7e0762b921ea70bed7915bcaab50e450e0a51158)) + ## [0.8.0](https://github.com/open-feature/flagd/compare/flagd-proxy/v0.7.6...flagd-proxy/v0.8.0) (2025-07-21) diff --git a/flagd-proxy/build.Dockerfile b/flagd-proxy/build.Dockerfile index 1cf6aa51a..33941b2f8 100644 --- a/flagd-proxy/build.Dockerfile +++ b/flagd-proxy/build.Dockerfile @@ -1,6 +1,6 @@ # Main Dockerfile for flagd builds # Build the manager binary -FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder WORKDIR /src diff --git a/flagd-proxy/go.mod b/flagd-proxy/go.mod index e06df3224..e425a7f06 100644 --- a/flagd-proxy/go.mod +++ b/flagd-proxy/go.mod @@ -1,37 +1,35 @@ module github.com/open-feature/flagd/flagd-proxy -go 1.24.0 - -toolchain go1.24.4 +go 1.25.5 require ( - buf.build/gen/go/open-feature/flagd/grpc/go v1.5.1-20250529171031-ebdc14163473.2 - buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.6-20250529171031-ebdc14163473.1 + buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1 + buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1 github.com/dimiro1/banner v1.1.0 github.com/mattn/go-colorable v0.1.14 - github.com/open-feature/flagd/core v0.11.8 - github.com/prometheus/client_golang v1.22.0 + github.com/open-feature/flagd/core v0.15.6 + github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 - go.opentelemetry.io/otel/exporters/prometheus v0.59.0 - go.opentelemetry.io/otel/metric v1.37.0 - go.opentelemetry.io/otel/sdk/metric v1.37.0 + go.opentelemetry.io/otel/exporters/prometheus v0.64.0 + go.opentelemetry.io/otel/metric v1.43.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 go.uber.org/zap v1.27.0 - golang.org/x/net v0.41.0 - golang.org/x/sync v0.15.0 - google.golang.org/grpc v1.73.0 + golang.org/x/net v0.52.0 + golang.org/x/sync v0.20.0 + google.golang.org/grpc v1.80.0 ) require ( - cel.dev/expr v0.23.0 // indirect + cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.121.1 // indirect cloud.google.com/go/auth v0.16.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect cloud.google.com/go/storage v1.55.0 // indirect - connectrpc.com/connect v1.18.1 // indirect + connectrpc.com/connect v1.19.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect @@ -39,42 +37,41 @@ require ( github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect github.com/aws/aws-sdk-go v1.55.6 // indirect - github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/config v1.29.12 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.65 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.25.2 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 // indirect - github.com/aws/smithy-go v1.22.3 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.0 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect - github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect - github.com/evanphx/json-patch/v5 v5.9.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -82,7 +79,7 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -101,65 +98,59 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/open-feature/flagd-schemas v0.2.9-0.20250707123415-08b4c52d3b86 // indirect - github.com/open-feature/open-feature-operator/apis v0.2.45 // indirect + github.com/open-feature/open-feature-operator/api v0.2.47 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.65.0 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/robfig/cron v1.2.0 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/x448/float16 v0.8.4 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect - github.com/zeebo/errs v1.4.0 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/sdk v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect gocloud.dev v0.42.0 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/api v0.235.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.33.2 // indirect - k8s.io/apiextensions-apiserver v0.31.1 // indirect k8s.io/apimachinery v0.33.2 // indirect k8s.io/client-go v0.33.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect - k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect - sigs.k8s.io/controller-runtime v0.19.3 // indirect + k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect + sigs.k8s.io/controller-runtime v0.20.1 // indirect sigs.k8s.io/gateway-api v1.2.1 // indirect - sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect diff --git a/flagd-proxy/go.sum b/flagd-proxy/go.sum index e22f6d528..c6ea99d04 100644 --- a/flagd-proxy/go.sum +++ b/flagd-proxy/go.sum @@ -1,9 +1,9 @@ -buf.build/gen/go/open-feature/flagd/grpc/go v1.5.1-20250529171031-ebdc14163473.2 h1:TZ+7u106u7C7lgNctxG03ABliF46eLhcIZG5Mdo67/E= -buf.build/gen/go/open-feature/flagd/grpc/go v1.5.1-20250529171031-ebdc14163473.2/go.mod h1:4u0WLwfkLob3dC/F8qNctqhtiEv2Mlyi8YgCDDzgYDs= -buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.6-20250529171031-ebdc14163473.1 h1:LdC4xAuUaNdduzQr5VvhjsgrCfpW9IYxYsjyCF0ANs0= -buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.6-20250529171031-ebdc14163473.1/go.mod h1:cCQ49+ttXE2MZ/ciRNb0tCG+F3kj2ZVbP+0/psbhrLY= -cel.dev/expr v0.23.0 h1:wUb94w6OYQS4uXraxo9U+wUAs9jT47Xvl4iPgAwM2ss= -cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1 h1:Vw1UTeqrKDQMasR9eSOh7JsA3Ii1dov0lPMPFwW16gg= +buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1/go.mod h1:uCFRckBTXlZTJczpxd0j8qhQfLIWT8ds/3PlURS54wI= +buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1 h1:vzILwV5p1s2kk4FuaaYNqKPSdivPqyaDsjtQi2qSRuc= +buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1/go.mod h1:itSRQViN+Mq9URSJbXJRlAT9irP54/x5n5sHn9NTKrU= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.121.1 h1:S3kTQSydxmu1JfLRLpKtxRPA7rSrYPRPEUmL/PavVUw= cloud.google.com/go v0.121.1/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw= @@ -11,27 +11,32 @@ cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU= cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= 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.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +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.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= +cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= 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/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0= cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY= cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= -connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= -connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.6.0 h1:PiSrjRPpkQNjrM8H0WwKMnZUdu1RGMtd/LdGKUrOo+c= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.6.0/go.mod h1:oDrbWx4ewMylP7xHivfgixbfGBT6APAwsSoHRKotnIc= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0 h1:UXT0o77lXQrikd1kgwIPQOUect7EoR/+sbP4wQKdzxM= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0/go.mod h1:cTvi54pg19DoT07ekoeMgE/taAwNtCShVeZqA+Iv2xI= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= @@ -39,22 +44,24 @@ github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSW github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc= github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk= github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= -github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.29.12 h1:Y/2a+jLPrPbHpFkpAAYkVEtJmxORlXoo5k2g1fa2sUo= github.com/aws/aws-sdk-go-v2/config v1.29.12/go.mod h1:xse1YTjmORlb/6fhkWi8qJh3cvZi4JoVNhc+NbJt4kI= github.com/aws/aws-sdk-go-v2/credentials v1.17.65 h1:q+nV2yYegofO/SUXruT+pn4KxkxmaQ++1B/QedcKBFM= @@ -63,32 +70,32 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mln github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69 h1:6VFPH/Zi9xYFMJKPQOX5URYkQoXRWeJ7V/7Y6ZDYoms= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69/go.mod h1:GJj8mmO6YT6EqgduWocwhMoxTLFitkhIrK+owzrYL2I= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 h1:lguz0bmOoGzozP9XfRJR1QIayEYo+2vP/No3OfLF0pU= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 h1:jIiopHEV22b4yQP2q36Y0OmwLbsxNWdWwfZRR5QRRO4= -github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2/go.mod h1:U5SNqwhXB3Xe6F47kXvWihPl/ilGaEDe8HD/50Z9wxc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/sso v1.25.2 h1:pdgODsAhGo4dvzC3JAG5Ce0PX8kWXrTZGx+jxADD+5E= github.com/aws/aws-sdk-go-v2/service/sso v1.25.2/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0 h1:90uX0veLKcdHVfvxhkWUQSCi5VabtwMLFutYiRke4oo= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 h1:PZV5W8yk4OtH1JAuhV2PXwwO9v5G5Aoj+eMCn4T+1Kc= github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= -github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -96,8 +103,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= -github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +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/common-nighthawk/go-figure v0.0.0-20200609044655-c4b36f998cf2/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= @@ -107,6 +114,7 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dimiro1/banner v1.1.0 h1:TSfy+FsPIIGLzaMPOt52KrEed/omwFO1P15VA8PMUh0= github.com/dimiro1/banner v1.1.0/go.mod h1:tbL318TJiUaHxOUNN+jnlvFSgsh/RX7iJaQrGgOiTco= github.com/emicklei/go-restful/v3 v3.12.0 h1:y2DdzBAURM29NFF94q6RaY4vjIH1rtwDapwQtU84iWk= @@ -114,18 +122,15 @@ github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRr 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.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +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/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= -github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= -github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= -github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -134,15 +139,13 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= -github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -155,8 +158,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -189,11 +192,10 @@ github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6 github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20250125003558-7fdb3d7e6fa0 h1:my2ucqBZmv+cWHIhZNSIYKzgN8EBGyHdC7zD5sASRAg= +github.com/google/pprof v0.0.0-20250125003558-7fdb3d7e6fa0/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= @@ -217,6 +219,7 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs= +github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -244,13 +247,13 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= -github.com/open-feature/flagd-schemas v0.2.9-0.20250707123415-08b4c52d3b86 h1:r3e+qs3QUdf4+lUi2ZZnSHgYkjeLIb5yu5jo+ypA8iw= -github.com/open-feature/flagd-schemas v0.2.9-0.20250707123415-08b4c52d3b86/go.mod h1:WKtwo1eW9/K6D+4HfgTXWBqCDzpvMhDa5eRxW7R5B2U= -github.com/open-feature/flagd/core v0.11.8 h1:84uDdSzhtVNBnsjuAnBqBXbFwXC2CQ6aO5cNNKKM7uc= -github.com/open-feature/flagd/core v0.11.8/go.mod h1:3dNe+BX8HUpx/mXrGLD554G6cQB67yvuASVTKVC4TU4= -github.com/open-feature/open-feature-operator/apis v0.2.45 h1:URnUf22ZoAx7/W8ek8dXCBYgY8FmnFEuEOSDLROQafY= -github.com/open-feature/open-feature-operator/apis v0.2.45/go.mod h1:PYh/Hfyna1lZYZUeu/8LM0qh0ZgpH7kKEXRLYaaRhGs= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/open-feature/flagd/core v0.15.6 h1:ghTuf/8DpapRjL39Aol8TZLG4HGGAYWCVwRO/HltU+k= +github.com/open-feature/flagd/core v0.15.6/go.mod h1:1SsHbYWrpcEgmFmCpJOMQyD99r5+nhQ4122bV6EfWXw= +github.com/open-feature/open-feature-operator/api v0.2.47 h1:Q8g3Ks63J+AreouX0pn+YMLfoWuQoWfmBb28VCPCxAE= +github.com/open-feature/open-feature-operator/api v0.2.47/go.mod h1:Y3jZiRdhJu7V96VH8jXuV19yHE/02468NWWtX/ehmf0= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -262,20 +265,23 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 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/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= -github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= -github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= @@ -291,63 +297,60 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= -github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= -github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= 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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= -github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= 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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= -go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= +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/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/exporters/prometheus v0.59.0 h1:HHf+wKS6o5++XZhS98wvILrLVgHxjA/AMjqHKes+uzo= -go.opentelemetry.io/otel/exporters/prometheus v0.59.0/go.mod h1:R8GpRXTZrqvXHDEGVH5bF6+JqAZcK8PjJcZ5nGhEWiE= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= gocloud.dev v0.42.0 h1:qzG+9ItUL3RPB62/Amugws28n+4vGZXEoJEAMfjutzw= gocloud.dev v0.42.0/go.mod h1:zkaYAapZfQisXOA4bzhsbA4ckiStGQ3Psvs9/OQ5dPM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -356,11 +359,9 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= -golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= 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= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -385,11 +386,11 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= 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.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.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= @@ -399,8 +400,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -417,16 +418,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -434,8 +435,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -450,15 +451,16 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.235.0 h1:C3MkpQSRxS1Jy6AkzTGKKrpSCOd2WOGrezZ+icKSkKo= google.golang.org/api v0.235.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= @@ -468,17 +470,17 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98 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-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= 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.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= 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= @@ -488,8 +490,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -507,8 +509,6 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= -k8s.io/apiextensions-apiserver v0.31.1 h1:L+hwULvXx+nvTYX/MKM3kKMZyei+UiSXQWciX/N6E40= -k8s.io/apiextensions-apiserver v0.31.1/go.mod h1:tWMPR3sgW+jsl2xm9v7lAyRF1rYEK71i9G5dRtkknoQ= k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= @@ -517,14 +517,14 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/controller-runtime v0.19.3 h1:XO2GvC9OPftRst6xWCpTgBZO04S2cbp0Qqkj8bX1sPw= -sigs.k8s.io/controller-runtime v0.19.3/go.mod h1:j4j87DqtsThvwTv5/Tc5NFRyyF/RF0ip4+62tbTSIUM= +k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= +k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.20.1 h1:JbGMAG/X94NeM3xvjenVUaBjy6Ui4Ogd/J5ZtjZnHaE= +sigs.k8s.io/controller-runtime v0.20.1/go.mod h1:BrP3w158MwvB3ZbNpaAcIKkHQ7YGpYnzpoSTZ8E14WU= sigs.k8s.io/gateway-api v1.2.1 h1:fZZ/+RyRb+Y5tGkwxFKuYuSRQHu9dZtbjenblleOLHM= sigs.k8s.io/gateway-api v1.2.1/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= diff --git a/flagd-proxy/pkg/service/subscriptions/manager.go b/flagd-proxy/pkg/service/subscriptions/manager.go index b6a85a679..349c2e388 100644 --- a/flagd-proxy/pkg/service/subscriptions/manager.go +++ b/flagd-proxy/pkg/service/subscriptions/manager.go @@ -163,13 +163,16 @@ func (s *Coordinator) watchResource(target string) { s.logger.Debug(fmt.Sprintf("watching resource %s", target)) ctx, cancel := context.WithCancel(s.ctx) defer cancel() + s.mu.Lock() sh, ok := s.multiplexers[target] if !ok { + s.mu.Unlock() s.logger.Error(fmt.Sprintf("no sync handler exists for target %s", target)) return } // this cancel is accessed by the cleanup method shutdown the listener + delete the multiplexer sh.cancelFunc = cancel + s.mu.Unlock() go func() { <-ctx.Done() s.mu.Lock() diff --git a/flagd-proxy/tests/loadtest/go.mod b/flagd-proxy/tests/loadtest/go.mod index 169271f61..6133dacad 100644 --- a/flagd-proxy/tests/loadtest/go.mod +++ b/flagd-proxy/tests/loadtest/go.mod @@ -1,21 +1,17 @@ module github.com/open-feature/flagd/flagd-proxy/tests/loadtest -go 1.23.0 - -toolchain go1.24.1 +go 1.25.5 require ( - buf.build/gen/go/open-feature/flagd/grpc/go v1.5.1-20250529171031-ebdc14163473.2 - buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.6-20250529171031-ebdc14163473.1 - google.golang.org/grpc v1.73.0 + buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1 + buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1 + google.golang.org/grpc v1.79.3 ) require ( - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/protobuf v1.36.6 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/flagd-proxy/tests/loadtest/go.sum b/flagd-proxy/tests/loadtest/go.sum index 0d3d1bcbc..70796283f 100644 --- a/flagd-proxy/tests/loadtest/go.sum +++ b/flagd-proxy/tests/loadtest/go.sum @@ -1,19 +1,42 @@ -buf.build/gen/go/open-feature/flagd/grpc/go v1.5.1-20250529171031-ebdc14163473.2 h1:TZ+7u106u7C7lgNctxG03ABliF46eLhcIZG5Mdo67/E= -buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.6-20250529171031-ebdc14163473.1 h1:LdC4xAuUaNdduzQr5VvhjsgrCfpW9IYxYsjyCF0ANs0= +buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1 h1:Vw1UTeqrKDQMasR9eSOh7JsA3Ii1dov0lPMPFwW16gg= +buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1/go.mod h1:uCFRckBTXlZTJczpxd0j8qhQfLIWT8ds/3PlURS54wI= +buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1 h1:vzILwV5p1s2kk4FuaaYNqKPSdivPqyaDsjtQi2qSRuc= +buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1/go.mod h1:itSRQViN+Mq9URSJbXJRlAT9irP54/x5n5sHn9NTKrU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +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/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.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/flagd/CHANGELOG.md b/flagd/CHANGELOG.md index 5d51a4ff1..1e0bbd23f 100644 --- a/flagd/CHANGELOG.md +++ b/flagd/CHANGELOG.md @@ -1,5 +1,185 @@ # Changelog +## [0.15.7](https://github.com/open-feature/flagd/compare/flagd/v0.15.6...flagd/v0.15.7) (2026-05-29) + + +### ๐Ÿ› Bug Fixes + +* **sync:** panic on s3 URI with query string ([#1974](https://github.com/open-feature/flagd/issues/1974)) ([82040ff](https://github.com/open-feature/flagd/commit/82040ff47a33e914e0c4a91a9db321cc1e9510a4)) + +## [0.15.6](https://github.com/open-feature/flagd/compare/flagd/v0.15.5...flagd/v0.15.6) (2026-05-28) + + +### โœจ New Features + +* bump core for custom headers, add docs ([#1969](https://github.com/open-feature/flagd/issues/1969)) ([4f33a0c](https://github.com/open-feature/flagd/commit/4f33a0c71818ffcd8063295c4c8c0e9285818105)) + +## [0.15.5](https://github.com/open-feature/flagd/compare/flagd/v0.15.4...flagd/v0.15.5) (2026-04-30) + + + +### ๐Ÿ› Bug Fixes + +* handle missing/null targeting keys in fractional evaluator ([#1949](https://github.com/open-feature/flagd/issues/1949)) ([651c7bb](https://github.com/open-feature/flagd/commit/651c7bb814eb70f72414ce164e1d2560e6055526)) +* override otel service name and version ([#1956](https://github.com/open-feature/flagd/issues/1956)) ([ec4ff12](https://github.com/open-feature/flagd/commit/ec4ff12e3f8dd37b61d6c7852a1f7dd2a8572d3a)) +* update jsonlogic for and/or bug ([#1957](https://github.com/open-feature/flagd/issues/1957)) ([6edd6e8](https://github.com/open-feature/flagd/commit/6edd6e83e56d7407dc925afe39deae795487dd8c)) +* various custom operator conformance fixes ([#1950](https://github.com/open-feature/flagd/issues/1950)) ([670c91c](https://github.com/open-feature/flagd/commit/670c91cdca80c29fd1cee378d1ea228c4ef36935)) +## [0.15.4](https://github.com/open-feature/flagd/compare/flagd/v0.15.3...flagd/v0.15.4) (2026-04-15) + + +### โœจ New Features + +* add intervalSeed source option ([9926b95](https://github.com/open-feature/flagd/commit/9926b9584bae8478ee3fdca1110d1a6a865bd041)) + +## [0.15.3](https://github.com/open-feature/flagd/compare/flagd/v0.15.2...flagd/v0.15.3) (2026-04-14) + + +### ๐Ÿ› Bug Fixes + +* web and cli docs do not mention s3 ([#1941](https://github.com/open-feature/flagd/issues/1941)) ([49ff1cf](https://github.com/open-feature/flagd/commit/49ff1cfe2d5543feead69d363dc63ea18c718bc0)) + +## [0.15.2](https://github.com/open-feature/flagd/compare/flagd/v0.15.1...flagd/v0.15.2) (2026-04-09) + + +### ๐Ÿ› Bug Fixes + +* **security:** update vulnerability-updates [security] ([#1933](https://github.com/open-feature/flagd/issues/1933)) ([04338dc](https://github.com/open-feature/flagd/commit/04338dc21358b80f96da7a5ac736107f08093d60)) +* **security:** update vulnerability-updates [security] ([#1934](https://github.com/open-feature/flagd/issues/1934)) ([40d444a](https://github.com/open-feature/flagd/commit/40d444abac6b0a40a1b5190c2205540eaaaa0b55)) + + +### โœจ New Features + +* gRPC sync experimental incremental updates ([#1922](https://github.com/open-feature/flagd/issues/1922)) ([d785557](https://github.com/open-feature/flagd/commit/d785557d2df6b89c9b86e886b6b923991dd44696)) + +## [0.15.1](https://github.com/open-feature/flagd/compare/flagd/v0.15.0...flagd/v0.15.1) (2026-04-07) + + +### ๐Ÿ› Bug Fixes + +* object flags without `defaultVaraint` dont default in RPC ([#1925](https://github.com/open-feature/flagd/issues/1925)) ([17f833e](https://github.com/open-feature/flagd/commit/17f833ea53341bce4e85250a089f237a0c002fb9)) +* **security:** update module github.com/go-jose/go-jose/v4 to v4.1.4 [security] ([#1929](https://github.com/open-feature/flagd/issues/1929)) ([cf22a11](https://github.com/open-feature/flagd/commit/cf22a110652af6f3ef867c17b9c6ea9471c9e5f1)) +* zombie process on metrics server fail ([#1926](https://github.com/open-feature/flagd/issues/1926)) ([0271068](https://github.com/open-feature/flagd/commit/0271068ec71b02a2c9ab6afda44193cc2a66a815)) +* mem leak due to unbounded metrics cardinality ([#1931](https://github.com/open-feature/flagd/issues/1931)) ([176866e](https://github.com/open-feature/flagd/commit/176866e71625bee9ef7770700d8ce14e8abd8110)) + +## [0.15.0](https://github.com/open-feature/flagd/compare/flagd/v0.14.5...flagd/v0.15.0) (2026-04-01) + + +### โš  BREAKING CHANGES + +* fractional bucketing improvements ([#1909](https://github.com/open-feature/flagd/issues/1909)) + * no breaking API changes - but fractional pseudorandom assignments will change; update all providers to ensure consistency + +### โœจ New Features + +* fractional bucketing improvements ([#1909](https://github.com/open-feature/flagd/issues/1909)) ([7190878](https://github.com/open-feature/flagd/commit/7190878fd0ea7a6f16fd8fbcdac68b55d9b9a2a5)) + +## [0.14.5](https://github.com/open-feature/flagd/compare/flagd/v0.14.4...flagd/v0.14.5) (2026-03-27) + + +### โœจ New Features + +* gRPC sync endpoint metrics ([#1861](https://github.com/open-feature/flagd/issues/1861)) ([b04dc50](https://github.com/open-feature/flagd/commit/b04dc5074a5be239914c4328653623aad36203ac)) + +## [0.14.4](https://github.com/open-feature/flagd/compare/flagd/v0.14.3...flagd/v0.14.4) (2026-03-21) + + +### ๐Ÿ› Bug Fixes + +* **security:** update module google.golang.org/grpc to v1.79.3 [security] ([#1907](https://github.com/open-feature/flagd/issues/1907)) ([ad51d4e](https://github.com/open-feature/flagd/commit/ad51d4e8fe0570474c824273983f54b3ca38b083)) + +## [0.14.3](https://github.com/open-feature/flagd/compare/flagd/v0.14.2...flagd/v0.14.3) (2026-03-10) + + +### ๐Ÿ› Bug Fixes + +* OFREP service CORS missing AllowedHeaders - blocks Flagd-Selector header ([#1900](https://github.com/open-feature/flagd/issues/1900)) ([08f0429](https://github.com/open-feature/flagd/commit/08f0429fc54f9562f351201d64fc71834588c3e5)) + +## [0.14.2](https://github.com/open-feature/flagd/compare/flagd/v0.14.1...flagd/v0.14.2) (2026-03-09) + + +### ๐Ÿ› Bug Fixes + +* **security:** update otel deps, minimum core Go version ([#1897](https://github.com/open-feature/flagd/issues/1897)) ([6b79bf8](https://github.com/open-feature/flagd/commit/6b79bf8419da1e269ecb1d1db03760379fc201cb)) + + +### โœจ New Features + +* make max header and body size configurable, add default ([#1892](https://github.com/open-feature/flagd/issues/1892)) ([25c5fd7](https://github.com/open-feature/flagd/commit/25c5fd7e80c26eb2c00b20317b2456fe6f927ea3)) + + +### ๐Ÿงน Chore + +* reduce duplication in some tests ([#1895](https://github.com/open-feature/flagd/issues/1895)) ([4a82812](https://github.com/open-feature/flagd/commit/4a828120def8dcfd5d58f5893db1170cc34890eb)) + +## [0.14.1](https://github.com/open-feature/flagd/compare/flagd/v0.14.0...flagd/v0.14.1) (2026-03-04) + + +### ๐Ÿ› Bug Fixes + +* RPC event serialization error, dont send empty messages ([#1871](https://github.com/open-feature/flagd/issues/1871)) ([95d38fd](https://github.com/open-feature/flagd/commit/95d38fd8e7ec2cb17b3ed7a80a46f073e38d9e0e)) + +## [0.14.0](https://github.com/open-feature/flagd/compare/flagd/v0.13.3...flagd/v0.14.0) (2026-03-04) + + +### โš  BREAKING CHANGES + +* no `defaultVariant` -> code default (previosuly FLAG_NOT_FOUND) ([#1862](https://github.com/open-feature/flagd/issues/1862)) + * this is only a minor change impacting OFREP; now flags without default variants are returned without value/variant feilds + +### โœจ New Features + +* no `defaultVariant` -> code default (previosuly FLAG_NOT_FOUND) ([#1862](https://github.com/open-feature/flagd/issues/1862)) ([89117d8](https://github.com/open-feature/flagd/commit/89117d8eaba0e9d205b3b47544528c42d5698176)) + * this is only a minor change impacting OFREP; now flags without default variants are returned without value/variant feilds + +## [0.13.3](https://github.com/open-feature/flagd/compare/flagd/v0.13.2...flagd/v0.13.3) (2026-02-09) + + +### ๐Ÿ› Bug Fixes + +* case sensitivity in header context mapping ([#1855](https://github.com/open-feature/flagd/issues/1855)) ([be5c94f](https://github.com/open-feature/flagd/commit/be5c94fc06f7cced8d6ee3701f59374a1f315fc3)) + +## [0.13.2](https://github.com/open-feature/flagd/compare/flagd/v0.13.1...flagd/v0.13.2) (2026-01-09) + + +### ๐Ÿ› Bug Fixes + +* **security:** update module github.com/open-feature/flagd/core to v0.13.1 [security] ([#1846](https://github.com/open-feature/flagd/issues/1846)) ([a8a57ad](https://github.com/open-feature/flagd/commit/a8a57adb1d49640bfc14bf02cf77961652f7516a)) + +## [0.13.1](https://github.com/open-feature/flagd/compare/flagd/v0.13.0...flagd/v0.13.1) (2025-12-27) + + +### ๐Ÿ› Bug Fixes + +* **security:** update go for various stdlib CVEs ([#1840](https://github.com/open-feature/flagd/issues/1840)) ([6dcb36d](https://github.com/open-feature/flagd/commit/6dcb36d2d6b55b7fe0b6107ac9a25baf302c5cdc)) + +## [0.13.0](https://github.com/open-feature/flagd/compare/flagd/v0.12.9...flagd/v0.13.0) (2025-12-23) + + +### ๐Ÿ› Bug Fixes + +* fixing sync return format missing flag layer, adding full e2e suite ([#1827](https://github.com/open-feature/flagd/issues/1827)) ([570693d](https://github.com/open-feature/flagd/commit/570693d9e7b3200c0865e7ebb3b467ccfc38bb88)) +* **security:** update module github.com/go-viper/mapstructure/v2 to v2.4.0 [security] ([#1784](https://github.com/open-feature/flagd/issues/1784)) ([037e30b](https://github.com/open-feature/flagd/commit/037e30b2f87897499580b25497049b88da7e386c)) +* **security:** update module golang.org/x/crypto to v0.45.0 [security] ([#1826](https://github.com/open-feature/flagd/issues/1826)) ([7e0762b](https://github.com/open-feature/flagd/commit/7e0762b921ea70bed7915bcaab50e450e0a51158)) + + +### โœจ New Features + +* add support for http-based ofrep metrics ([#1803](https://github.com/open-feature/flagd/issues/1803)) ([fcd19b3](https://github.com/open-feature/flagd/commit/fcd19b310b319c9837b41c19d6f082ac750cb81d)) +* cleanup evaluator interface ([#1793](https://github.com/open-feature/flagd/issues/1793)) ([aa504f7](https://github.com/open-feature/flagd/commit/aa504f7077093746f886248a4766d9ae5587bf3d)) +* enable parsing of array flag configurations for flagd ([#1797](https://github.com/open-feature/flagd/issues/1797)) ([97c6ffa](https://github.com/open-feature/flagd/commit/97c6ffaf2b51765ccd6aaec38c2902ed2ac8f5f3)) +* multi-project support via selectors and flagSetId namespacing ([#1702](https://github.com/open-feature/flagd/issues/1702)) ([f9ce46f](https://github.com/open-feature/flagd/commit/f9ce46f1032e7cb423e0e5c75a7c02f91ab5a88f)) +* normalize selector in sync (use header as in OFREP and RPC) ([#1815](https://github.com/open-feature/flagd/issues/1815)) ([c1f06cb](https://github.com/open-feature/flagd/commit/c1f06cb00fc5425d6ee73d6cfca314e9711913df)) + + +### ๐Ÿงน Chore + +* **refactor:** use memdb for flag storage ([#1697](https://github.com/open-feature/flagd/issues/1697)) ([5c5c1cf](https://github.com/open-feature/flagd/commit/5c5c1cfe84890c4cdd74c9b82504fd2632965221)) + + +### ๐Ÿ”„ Refactoring + +* store cleanup ([#1705](https://github.com/open-feature/flagd/issues/1705)) ([bcff8d7](https://github.com/open-feature/flagd/commit/bcff8d757b6d0ca69bccee26ba41880bdf2b5040)) + ## [0.12.9](https://github.com/open-feature/flagd/compare/flagd/v0.12.8...flagd/v0.12.9) (2025-07-28) diff --git a/flagd/build.Dockerfile b/flagd/build.Dockerfile index 5f7442828..8a67587dc 100644 --- a/flagd/build.Dockerfile +++ b/flagd/build.Dockerfile @@ -1,6 +1,6 @@ # Main Dockerfile for flagd builds # Build the manager binary -FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder WORKDIR /src diff --git a/flagd/cmd/start.go b/flagd/cmd/start.go index 83745dd5a..5b6ed8499 100644 --- a/flagd/cmd/start.go +++ b/flagd/cmd/start.go @@ -40,6 +40,8 @@ const ( contextValueFlagName = "context-value" headerToContextKeyFlagName = "context-from-header" streamDeadlineFlagName = "stream-deadline" + maxRequestBodyFlagName = "max-request-body" + maxRequestHeaderFlagName = "max-request-header" ) func init() { @@ -62,7 +64,7 @@ func init() { flags.StringP(serverKeyPathFlagName, "k", "", "Server side tls key path") flags.StringSliceP( uriFlagName, "f", []string{}, "Set a sync provider uri to read data from, this can be a filepath,"+ - " URL (HTTP and gRPC), FeatureFlag custom resource, or GCS or Azure Blob. "+ + " URL (HTTP and gRPC), FeatureFlag custom resource, or GCS, Azure Blob or S3. "+ "When flag keys are duplicated across multiple providers the "+ "merge priority follows the index of the flag arguments, as such flags from the uri at index 0 take the "+ "lowest precedence, with duplicated keys being overwritten by those from the uri at index 1. "+ @@ -70,9 +72,10 @@ func init() { ) flags.StringSliceP(corsFlagName, "C", []string{}, "CORS allowed origins, * will allow all origins") flags.StringP( - sourcesFlagName, "s", "", "JSON representation of an array of SourceConfig objects. This object contains "+ - "2 required fields, uri (string) and provider (string). Documentation for this object: "+ - "https://flagd.dev/reference/sync-configuration/#source-configuration", + sourcesFlagName, "s", "", "JSON representation of an array of SourceConfig objects. "+ + "Required fields: uri (string) and provider (string). "+ + "Optional source-specific fields are also available, "+ + "see https://flagd.dev/reference/sync-configuration/#source-configuration", ) flags.StringP(logFormatFlagName, "z", "console", "Set the logging format, e.g. console or json") flags.StringP(metricsExporter, "t", "", "Set the metrics exporter. Default(if unset) is Prometheus."+ @@ -91,6 +94,8 @@ func init() { "header values to context values, where key is Header name, value is context key") flags.Duration(streamDeadlineFlagName, 0, "Set a server-side deadline for flagd sync and event streams (default 0, means no deadline).") flags.Bool(disableSyncMetadata, false, "Disables the getMetadata endpoint of the sync service. Defaults to false, but will default to true in later versions.") + flags.Int64P(maxRequestBodyFlagName, "B", 1_000_000, "Maximum allowed request body size in bytes. Requests exceeding this are rejected with HTTP 413 (OFREP) or 429 (connect). Set to 0 to disable. WARNING: disabling this limit may allow memory exhaustion from oversized requests.") + flags.Int64P(maxRequestHeaderFlagName, "R", 1_000_000, "Maximum allowed request header size in bytes. Requests exceeding this are rejected with HTTP 431. Set to 0 to use Go's built-in default (1 MiB). WARNING: setting a very large or zero value may allow memory exhaustion from oversized headers.") bindFlags(flags) } @@ -117,6 +122,8 @@ func bindFlags(flags *pflag.FlagSet) { _ = viper.BindPFlag(headerToContextKeyFlagName, flags.Lookup(headerToContextKeyFlagName)) _ = viper.BindPFlag(streamDeadlineFlagName, flags.Lookup(streamDeadlineFlagName)) _ = viper.BindPFlag(disableSyncMetadata, flags.Lookup(disableSyncMetadata)) + _ = viper.BindPFlag(maxRequestBodyFlagName, flags.Lookup(maxRequestBodyFlagName)) + _ = viper.BindPFlag(maxRequestHeaderFlagName, flags.Lookup(maxRequestHeaderFlagName)) } // startCmd represents the start command @@ -171,6 +178,16 @@ var startCmd = &cobra.Command{ headerToContextKeyMappings[k] = v } + // Request size limits + maxRequestBodyBytes := viper.GetInt64(maxRequestBodyFlagName) + if maxRequestBodyBytes > 0 { + rtLogger.Info(fmt.Sprintf("request body limit set to %d bytes", maxRequestBodyBytes)) + } + maxRequestHeaderBytes := viper.GetInt64(maxRequestHeaderFlagName) + if maxRequestHeaderBytes > 0 { + rtLogger.Info(fmt.Sprintf("request header limit set to %d bytes", maxRequestHeaderBytes)) + } + // Build Runtime ----------------------------------------------------------- rt, err := runtime.FromConfig(logger, Version, runtime.Config{ CORS: viper.GetStringSlice(corsFlagName), @@ -193,6 +210,8 @@ var startCmd = &cobra.Command{ SyncProviders: syncProviders, ContextValues: contextValuesToMap, HeaderToContextKeyMappings: headerToContextKeyMappings, + MaxRequestBodyBytes: maxRequestBodyBytes, + MaxRequestHeaderBytes: maxRequestHeaderBytes, }) if err != nil { rtLogger.Fatal(err.Error()) diff --git a/flagd/go.mod b/flagd/go.mod index 94a9578dc..07093fb0e 100644 --- a/flagd/go.mod +++ b/flagd/go.mod @@ -1,44 +1,43 @@ module github.com/open-feature/flagd/flagd -go 1.24.0 - -toolchain go1.24.4 +go 1.25.5 require ( - buf.build/gen/go/open-feature/flagd/connectrpc/go v1.18.1-20250529171031-ebdc14163473.1 - buf.build/gen/go/open-feature/flagd/grpc/go v1.5.1-20250529171031-ebdc14163473.2 - buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.6-20250529171031-ebdc14163473.1 - connectrpc.com/connect v1.18.1 + buf.build/gen/go/open-feature/flagd/connectrpc/go v1.19.1-20260217192757-1388a552fc3c.2 + buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1 + buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1 + connectrpc.com/connect v1.19.1 github.com/dimiro1/banner v1.1.0 github.com/gorilla/mux v1.8.1 github.com/mattn/go-colorable v0.1.14 - github.com/open-feature/flagd/core v0.11.8 - github.com/prometheus/client_golang v1.22.0 + github.com/open-feature/flagd/core v0.15.6 + github.com/prometheus/client_golang v1.23.2 github.com/rs/cors v1.11.1 github.com/rs/xid v1.6.0 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/spf13/viper v1.20.1 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 - go.opentelemetry.io/otel v1.37.0 - go.opentelemetry.io/otel/sdk v1.37.0 - go.opentelemetry.io/otel/sdk/metric v1.37.0 - go.opentelemetry.io/otel/trace v1.37.0 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 go.uber.org/mock v0.5.2 go.uber.org/zap v1.27.0 - golang.org/x/net v0.41.0 - golang.org/x/sync v0.15.0 - google.golang.org/grpc v1.73.0 - google.golang.org/protobuf v1.36.6 + golang.org/x/net v0.52.0 + golang.org/x/sync v0.20.0 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 ) require ( - cel.dev/expr v0.23.0 // indirect + cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.121.1 // indirect cloud.google.com/go/auth v0.16.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect cloud.google.com/go/storage v1.55.0 // indirect @@ -50,46 +49,44 @@ require ( github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect github.com/aws/aws-sdk-go v1.55.6 // indirect - github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/config v1.29.12 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.65 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.25.2 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 // indirect - github.com/aws/smithy-go v1.22.3 // indirect - github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cenkalti/backoff/v5 v5.0.2 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/diegoholiveira/jsonlogic/v3 v3.8.4 // indirect + github.com/diegoholiveira/jsonlogic/v3 v3.9.1 // indirect github.com/emicklei/go-restful/v3 v3.12.0 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect - github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect - github.com/evanphx/json-patch/v5 v5.9.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -97,7 +94,7 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -106,7 +103,7 @@ require ( github.com/google/wire v0.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect @@ -120,70 +117,76 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/open-feature/flagd-schemas v0.2.9-0.20250707123415-08b4c52d3b86 // indirect - github.com/open-feature/open-feature-operator/apis v0.2.45 // indirect + github.com/open-feature/flagd-schemas v0.2.13 // indirect + github.com/open-feature/open-feature-operator/api v0.2.47 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.65.0 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/robfig/cron v1.2.0 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect - github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/twmb/murmur3 v1.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect - github.com/zeebo/errs v1.4.0 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/prometheus v0.59.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect + go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect + go.opentelemetry.io/otel/log v0.19.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect gocloud.dev v0.42.0 // indirect - golang.org/x/crypto v0.39.0 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/api v0.235.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.33.2 // indirect - k8s.io/apiextensions-apiserver v0.31.1 // indirect k8s.io/apimachinery v0.33.2 // indirect k8s.io/client-go v0.33.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect - k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect - sigs.k8s.io/controller-runtime v0.19.3 // indirect + k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect + sigs.k8s.io/controller-runtime v0.20.1 // indirect sigs.k8s.io/gateway-api v1.2.1 // indirect - sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect diff --git a/flagd/go.sum b/flagd/go.sum index a9ac28d91..783af02d7 100644 --- a/flagd/go.sum +++ b/flagd/go.sum @@ -1,11 +1,11 @@ -buf.build/gen/go/open-feature/flagd/connectrpc/go v1.18.1-20250529171031-ebdc14163473.1 h1:qnXaezo9s36RjysUq6omZlU3rjQNykMpqCXfCL/CH1Q= -buf.build/gen/go/open-feature/flagd/connectrpc/go v1.18.1-20250529171031-ebdc14163473.1/go.mod h1:/9fZNSPUzUwGRzDAjyasa5NC+YFgQthby355ZVuKS2Q= -buf.build/gen/go/open-feature/flagd/grpc/go v1.5.1-20250529171031-ebdc14163473.2 h1:TZ+7u106u7C7lgNctxG03ABliF46eLhcIZG5Mdo67/E= -buf.build/gen/go/open-feature/flagd/grpc/go v1.5.1-20250529171031-ebdc14163473.2/go.mod h1:4u0WLwfkLob3dC/F8qNctqhtiEv2Mlyi8YgCDDzgYDs= -buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.6-20250529171031-ebdc14163473.1 h1:LdC4xAuUaNdduzQr5VvhjsgrCfpW9IYxYsjyCF0ANs0= -buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.6-20250529171031-ebdc14163473.1/go.mod h1:cCQ49+ttXE2MZ/ciRNb0tCG+F3kj2ZVbP+0/psbhrLY= -cel.dev/expr v0.23.0 h1:wUb94w6OYQS4uXraxo9U+wUAs9jT47Xvl4iPgAwM2ss= -cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +buf.build/gen/go/open-feature/flagd/connectrpc/go v1.19.1-20260217192757-1388a552fc3c.2 h1:n2DShwj5AfzieZKN2tid3gFt/HCZo/UUn4qMbUJ4H7M= +buf.build/gen/go/open-feature/flagd/connectrpc/go v1.19.1-20260217192757-1388a552fc3c.2/go.mod h1:NRDpVnsDW1gkVfxKOXVpUkW9Tx5SIUPXgqCUq3dftew= +buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1 h1:Vw1UTeqrKDQMasR9eSOh7JsA3Ii1dov0lPMPFwW16gg= +buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1/go.mod h1:uCFRckBTXlZTJczpxd0j8qhQfLIWT8ds/3PlURS54wI= +buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1 h1:vzILwV5p1s2kk4FuaaYNqKPSdivPqyaDsjtQi2qSRuc= +buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1/go.mod h1:itSRQViN+Mq9URSJbXJRlAT9irP54/x5n5sHn9NTKrU= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.121.1 h1:S3kTQSydxmu1JfLRLpKtxRPA7rSrYPRPEUmL/PavVUw= cloud.google.com/go v0.121.1/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw= @@ -13,8 +13,8 @@ cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU= cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= 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.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +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.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= @@ -27,8 +27,8 @@ cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2zn cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY= cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= -connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= -connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= connectrpc.com/otelconnect v0.7.2 h1:WlnwFzaW64dN06JXU+hREPUGeEzpz3Acz2ACOmN8cMI= connectrpc.com/otelconnect v0.7.2/go.mod h1:JS7XUKfuJs2adhCnXhNHPHLz6oAaZniCJdSF00OZSew= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo= @@ -52,8 +52,8 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mo github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk= @@ -62,10 +62,10 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk= github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= -github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.29.12 h1:Y/2a+jLPrPbHpFkpAAYkVEtJmxORlXoo5k2g1fa2sUo= github.com/aws/aws-sdk-go-v2/config v1.29.12/go.mod h1:xse1YTjmORlb/6fhkWi8qJh3cvZi4JoVNhc+NbJt4kI= github.com/aws/aws-sdk-go-v2/credentials v1.17.65 h1:q+nV2yYegofO/SUXruT+pn4KxkxmaQ++1B/QedcKBFM= @@ -74,45 +74,43 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mln github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69 h1:6VFPH/Zi9xYFMJKPQOX5URYkQoXRWeJ7V/7Y6ZDYoms= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69/go.mod h1:GJj8mmO6YT6EqgduWocwhMoxTLFitkhIrK+owzrYL2I= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 h1:lguz0bmOoGzozP9XfRJR1QIayEYo+2vP/No3OfLF0pU= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 h1:jIiopHEV22b4yQP2q36Y0OmwLbsxNWdWwfZRR5QRRO4= -github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2/go.mod h1:U5SNqwhXB3Xe6F47kXvWihPl/ilGaEDe8HD/50Z9wxc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/sso v1.25.2 h1:pdgODsAhGo4dvzC3JAG5Ce0PX8kWXrTZGx+jxADD+5E= github.com/aws/aws-sdk-go-v2/service/sso v1.25.2/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0 h1:90uX0veLKcdHVfvxhkWUQSCi5VabtwMLFutYiRke4oo= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 h1:PZV5W8yk4OtH1JAuhV2PXwwO9v5G5Aoj+eMCn4T+1Kc= github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= -github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= -github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= -github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= -github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= -github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +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/common-nighthawk/go-figure v0.0.0-20200609044655-c4b36f998cf2/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= @@ -124,8 +122,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/diegoholiveira/jsonlogic/v3 v3.8.4 h1:IVVU/VLz2hn10ImbmibjiUkdVsSFIB1vfDaOVsaipH4= -github.com/diegoholiveira/jsonlogic/v3 v3.8.4/go.mod h1:OYRb6FSTVmMM+MNQ7ElmMsczyNSepw+OU4Z8emDSi4w= +github.com/diegoholiveira/jsonlogic/v3 v3.9.1 h1:BMZ4DxiZyIHVxoip29bc9alMg4cBvZ0lDzBC+/osOtQ= +github.com/diegoholiveira/jsonlogic/v3 v3.9.1/go.mod h1:807lvTWhwOX6yHwkr42unn3VpC2eMLkvT/WsGGqWDfE= github.com/dimiro1/banner v1.1.0 h1:TSfy+FsPIIGLzaMPOt52KrEed/omwFO1P15VA8PMUh0= github.com/dimiro1/banner v1.1.0/go.mod h1:tbL318TJiUaHxOUNN+jnlvFSgsh/RX7iJaQrGgOiTco= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= @@ -135,19 +133,15 @@ github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRr 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.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +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/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= -github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= -github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= -github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -156,15 +150,13 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= -github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -177,8 +169,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -211,12 +203,10 @@ github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6 github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20250125003558-7fdb3d7e6fa0 h1:my2ucqBZmv+cWHIhZNSIYKzgN8EBGyHdC7zD5sASRAg= +github.com/google/pprof v0.0.0-20250125003558-7fdb3d7e6fa0/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= @@ -231,8 +221,8 @@ github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3 github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo= @@ -285,11 +275,12 @@ github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= -github.com/open-feature/flagd-schemas v0.2.9-0.20250707123415-08b4c52d3b86 h1:r3e+qs3QUdf4+lUi2ZZnSHgYkjeLIb5yu5jo+ypA8iw= -github.com/open-feature/flagd-schemas v0.2.9-0.20250707123415-08b4c52d3b86/go.mod h1:WKtwo1eW9/K6D+4HfgTXWBqCDzpvMhDa5eRxW7R5B2U= -github.com/open-feature/flagd/core v0.11.8/go.mod h1:3dNe+BX8HUpx/mXrGLD554G6cQB67yvuASVTKVC4TU4= -github.com/open-feature/open-feature-operator/apis v0.2.45 h1:URnUf22ZoAx7/W8ek8dXCBYgY8FmnFEuEOSDLROQafY= -github.com/open-feature/open-feature-operator/apis v0.2.45/go.mod h1:PYh/Hfyna1lZYZUeu/8LM0qh0ZgpH7kKEXRLYaaRhGs= +github.com/open-feature/flagd-schemas v0.2.13 h1:LzoyQfirfpR8cxI4PKnoFRtpwPjpC/cOO8N0n8dpbRc= +github.com/open-feature/flagd-schemas v0.2.13/go.mod h1:C0jnJ4C3j2LzGuqKgLDdTsdfKEWQp6sOHZyxu3QohFU= +github.com/open-feature/flagd/core v0.15.6 h1:ghTuf/8DpapRjL39Aol8TZLG4HGGAYWCVwRO/HltU+k= +github.com/open-feature/flagd/core v0.15.6/go.mod h1:1SsHbYWrpcEgmFmCpJOMQyD99r5+nhQ4122bV6EfWXw= +github.com/open-feature/open-feature-operator/api v0.2.47 h1:Q8g3Ks63J+AreouX0pn+YMLfoWuQoWfmBb28VCPCxAE= +github.com/open-feature/open-feature-operator/api v0.2.47/go.mod h1:Y3jZiRdhJu7V96VH8jXuV19yHE/02468NWWtX/ehmf0= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -301,21 +292,23 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 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/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= -github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= -github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= @@ -338,8 +331,8 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= -github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= -github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= 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= @@ -349,58 +342,71 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= -github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= 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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= -go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= +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/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 h1:zG8GlgXCJQd5BU98C0hZnBbElszTmUgCNCfYneaDL0A= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0/go.mod h1:hOfBCz8kv/wuq73Mx2H2QnWokh/kHZxkh6SNF2bdKtw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= -go.opentelemetry.io/otel/exporters/prometheus v0.59.0 h1:HHf+wKS6o5++XZhS98wvILrLVgHxjA/AMjqHKes+uzo= -go.opentelemetry.io/otel/exporters/prometheus v0.59.0/go.mod h1:R8GpRXTZrqvXHDEGVH5bF6+JqAZcK8PjJcZ5nGhEWiE= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= -go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= @@ -409,6 +415,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= gocloud.dev v0.42.0 h1:qzG+9ItUL3RPB62/Amugws28n+4vGZXEoJEAMfjutzw= gocloud.dev v0.42.0/go.mod h1:zkaYAapZfQisXOA4bzhsbA4ckiStGQ3Psvs9/OQ5dPM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -417,8 +425,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= @@ -431,8 +439,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= 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-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -448,11 +456,11 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= 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.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.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= @@ -462,8 +470,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -480,16 +488,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -497,8 +505,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -513,16 +521,16 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.235.0 h1:C3MkpQSRxS1Jy6AkzTGKKrpSCOd2WOGrezZ+icKSkKo= google.golang.org/api v0.235.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= @@ -532,17 +540,17 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98 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-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= 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.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= 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= @@ -552,8 +560,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -571,8 +579,6 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= -k8s.io/apiextensions-apiserver v0.31.1 h1:L+hwULvXx+nvTYX/MKM3kKMZyei+UiSXQWciX/N6E40= -k8s.io/apiextensions-apiserver v0.31.1/go.mod h1:tWMPR3sgW+jsl2xm9v7lAyRF1rYEK71i9G5dRtkknoQ= k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= @@ -581,14 +587,14 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/controller-runtime v0.19.3 h1:XO2GvC9OPftRst6xWCpTgBZO04S2cbp0Qqkj8bX1sPw= -sigs.k8s.io/controller-runtime v0.19.3/go.mod h1:j4j87DqtsThvwTv5/Tc5NFRyyF/RF0ip4+62tbTSIUM= +k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= +k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.20.1 h1:JbGMAG/X94NeM3xvjenVUaBjy6Ui4Ogd/J5ZtjZnHaE= +sigs.k8s.io/controller-runtime v0.20.1/go.mod h1:BrP3w158MwvB3ZbNpaAcIKkHQ7YGpYnzpoSTZ8E14WU= sigs.k8s.io/gateway-api v1.2.1 h1:fZZ/+RyRb+Y5tGkwxFKuYuSRQHu9dZtbjenblleOLHM= sigs.k8s.io/gateway-api v1.2.1/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= diff --git a/flagd/pkg/runtime/from_config.go b/flagd/pkg/runtime/from_config.go index 08fcc5b0c..700cfbf80 100644 --- a/flagd/pkg/runtime/from_config.go +++ b/flagd/pkg/runtime/from_config.go @@ -46,6 +46,8 @@ type Config struct { ContextValues map[string]any HeaderToContextKeyMappings map[string]string + MaxRequestBodyBytes int64 + MaxRequestHeaderBytes int64 } // FromConfig builds a runtime from startup configurations @@ -81,6 +83,9 @@ func FromConfig(logger *logger.Logger, version string, config Config) (*Runtime, sources := []string{} + // URIs are registered verbatim (including any blob query string like + // s3://bucket/key?use_path_style=true); the sync implementation must + // emit DataSync.Source matching this exact string. See flagd#1973. for _, provider := range config.SyncProviders { sources = append(sources, provider.URI) } @@ -105,10 +110,12 @@ func FromConfig(logger *logger.Logger, version string, config Config) (*Runtime, // ofrep service ofrepService, err := ofrep.NewOfrepService(jsonEvaluator, config.CORS, ofrep.SvcConfiguration{ - Logger: logger.WithFields(zap.String("component", "OFREPService")), - Port: config.OfrepServicePort, - ServiceName: svcName, - MetricsRecorder: recorder, + Logger: logger.WithFields(zap.String("component", "OFREPService")), + Port: config.OfrepServicePort, + ServiceName: svcName, + MetricsRecorder: recorder, + MaxRequestBodyBytes: config.MaxRequestBodyBytes, + MaxRequestHeaderBytes: config.MaxRequestHeaderBytes, }, config.ContextValues, config.HeaderToContextKeyMappings, @@ -129,6 +136,7 @@ func FromConfig(logger *logger.Logger, version string, config Config) (*Runtime, SocketPath: config.SyncServiceSocketPath, StreamDeadline: config.StreamDeadline, DisableSyncMetadata: config.DisableSyncMetadata, + MetricsRecorder: recorder, }) if err != nil { return nil, fmt.Errorf("error creating sync service: %w", err) @@ -165,6 +173,8 @@ func FromConfig(logger *logger.Logger, version string, config Config) (*Runtime, ContextValues: config.ContextValues, HeaderToContextKeyMappings: config.HeaderToContextKeyMappings, StreamDeadline: config.StreamDeadline, + MaxRequestBodyBytes: config.MaxRequestBodyBytes, + MaxRequestHeaderBytes: config.MaxRequestHeaderBytes, }, Syncs: iSyncs, }, nil diff --git a/flagd/pkg/service/flag-evaluation/connect_service.go b/flagd/pkg/service/flag-evaluation/connect_service.go index 3fa1b4a93..d0cc822d0 100644 --- a/flagd/pkg/service/flag-evaluation/connect_service.go +++ b/flagd/pkg/service/flag-evaluation/connect_service.go @@ -12,6 +12,7 @@ import ( "time" evaluationV1 "buf.build/gen/go/open-feature/flagd/connectrpc/go/flagd/evaluation/v1/evaluationv1connect" + evaluationV2 "buf.build/gen/go/open-feature/flagd/connectrpc/go/flagd/evaluation/v2/evaluationv2connect" schemaConnectV1 "buf.build/gen/go/open-feature/flagd/connectrpc/go/schema/v1/schemav1connect" "github.com/open-feature/flagd/core/pkg/evaluator" "github.com/open-feature/flagd/core/pkg/logger" @@ -36,20 +37,24 @@ import ( const ( ErrorPrefix = "FlagdError:" - flagdSchemaPrefix = "/flagd" + flagdSchemaPrefix = "/flagd" + flagdV2SchemaPrefix = "/flagd.evaluation.v2" ) -// bufSwitchHandler combines the handlers of the old and new evaluation schema and combines them into one -// this way we support both the new and the (deprecated) old schemas until only the new schema is supported +// bufSwitchHandler combines the handlers of the old and new evaluation schemas +// this way we support both the new (v2) and the old (v1 and deprecated) schemas // NOTE: this will not be required anymore when it is time to work on https://github.com/open-feature/flagd/issues/1088 type bufSwitchHandler struct { old http.Handler - new http.Handler + v1 http.Handler + v2 http.Handler } func (b bufSwitchHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { - if strings.HasPrefix(request.URL.Path, flagdSchemaPrefix) { - b.new.ServeHTTP(writer, request) + if strings.HasPrefix(request.URL.Path, flagdV2SchemaPrefix) { + b.v2.ServeHTTP(writer, request) + } else if strings.HasPrefix(request.URL.Path, flagdSchemaPrefix) { + b.v1.ServeHTTP(writer, request) } else { b.old.ServeHTTP(writer, request) } @@ -64,9 +69,6 @@ type ConnectService struct { server *http.Server metricsServer *http.Server - serverMtx sync.RWMutex - metricsServerMtx sync.RWMutex - readinessEnabled bool } @@ -97,32 +99,10 @@ func (s *ConnectService) Serve(ctx context.Context, svcConf service.Configuratio s.readinessEnabled = true g.Go(func() error { - return s.startServer(svcConf) - }) - g.Go(func() error { - return s.startMetricsServer(svcConf) - }) - g.Go(func() error { - <-gCtx.Done() - s.serverMtx.RLock() - defer s.serverMtx.RUnlock() - if s.server != nil { - if err := s.server.Shutdown(gCtx); err != nil { - return fmt.Errorf("error returned from flag evaluation server shutdown: %w", err) - } - } - return nil + return s.startServer(gCtx, svcConf) }) g.Go(func() error { - <-gCtx.Done() - s.metricsServerMtx.RLock() - defer s.metricsServerMtx.RUnlock() - if s.metricsServer != nil { - if err := s.metricsServer.Shutdown(gCtx); err != nil { - return fmt.Errorf("error returned from metrics server shutdown: %w", err) - } - } - return nil + return s.startMetricsServer(gCtx, svcConf) }) if err := g.Wait(); err != nil { return fmt.Errorf("errgroup closed with error: %w", err) @@ -168,9 +148,22 @@ func (s *ConnectService) setupServer(svcConf service.Configuration) (net.Listene _, oldHandler := schemaConnectV1.NewServiceHandler(fes, append(svcConf.Options, marshalOpts)...) - // register handler for new flag evaluation schema + // register handler for new flag evaluation schema (v1) + + v1Fes := NewFlagEvaluationService(s.logger.WithFields(zap.String("component", "flagd.evaluation.v1")), + s.eval, + s.eventingConfiguration, + s.metrics, + svcConf.ContextValues, + svcConf.HeaderToContextKeyMappings, + svcConf.StreamDeadline, + ) + + _, v1Handler := evaluationV1.NewServiceHandler(v1Fes, append(svcConf.Options, marshalOpts)...) + + // register handler for evaluation v2 schema (with optional value and variant) - newFes := NewFlagEvaluationService(s.logger.WithFields(zap.String("component", "flagd.evaluation.v1")), + v2Fes := NewFlagEvaluationServiceV2(s.logger.WithFields(zap.String("component", "flagd.evaluation.v2")), s.eval, s.eventingConfiguration, s.metrics, @@ -179,19 +172,24 @@ func (s *ConnectService) setupServer(svcConf service.Configuration) (net.Listene svcConf.StreamDeadline, ) - _, newHandler := evaluationV1.NewServiceHandler(newFes, append(svcConf.Options, marshalOpts)...) + _, v2Handler := evaluationV2.NewServiceHandler(v2Fes, append(svcConf.Options, marshalOpts)...) bs := bufSwitchHandler{ old: oldHandler, - new: newHandler, + v1: v1Handler, + v2: v2Handler, + } + + var svcHandler http.Handler = bs + if svcConf.MaxRequestBodyBytes > 0 { + svcHandler = http.MaxBytesHandler(svcHandler, svcConf.MaxRequestBodyBytes) } - s.serverMtx.Lock() s.server = &http.Server{ ReadHeaderTimeout: time.Second, - Handler: bs, + Handler: svcHandler, + MaxHeaderBytes: int(svcConf.MaxRequestHeaderBytes), } - s.serverMtx.Unlock() // Add middlewares metricsMiddleware := metricsmw.NewHTTPMetric(metricsmw.Config{ @@ -226,31 +224,47 @@ func (s *ConnectService) Shutdown() { }) } -func (s *ConnectService) startServer(svcConf service.Configuration) error { +func serveWithShutdown(ctx context.Context, server *http.Server, serveFn func() error) error { + errChan := make(chan error, 1) + go func() { errChan <- serveFn() }() + + select { + case err := <-errChan: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil + case <-ctx.Done(): + // use a fresh context; ctx is already cancelled + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("error shutting down server: %w", err) + } + // wait for server to fully stop + <-errChan + return nil + } +} + +func (s *ConnectService) startServer(ctx context.Context, svcConf service.Configuration) error { lis, err := s.setupServer(svcConf) if err != nil { return err } s.logger.Info(fmt.Sprintf("Flag IResolver listening at %s", lis.Addr())) + if svcConf.CertPath != "" && svcConf.KeyPath != "" { - if err := s.server.ServeTLS( - lis, - svcConf.CertPath, - svcConf.KeyPath, - ); err != nil && !errors.Is(err, http.ErrServerClosed) { - return fmt.Errorf("error returned from flag evaluation server: %w", err) - } - } else { - if err := s.server.Serve( - lis, - ); err != nil && !errors.Is(err, http.ErrServerClosed) { - return fmt.Errorf("error returned from flag evaluation server: %w", err) - } + return serveWithShutdown(ctx, s.server, func() error { + return s.server.ServeTLS(lis, svcConf.CertPath, svcConf.KeyPath) + }) } - return nil + return serveWithShutdown(ctx, s.server, func() error { + return s.server.Serve(lis) + }) } -func (s *ConnectService) startMetricsServer(svcConf service.Configuration) error { +func (s *ConnectService) startMetricsServer(ctx context.Context, svcConf service.Configuration) error { s.logger.Info(fmt.Sprintf("metrics and probes listening at %d", svcConf.ManagementPort)) srv := grpc.NewServer() @@ -279,16 +293,11 @@ func (s *ConnectService) startMetricsServer(svcConf service.Configuration) error } }) - s.metricsServerMtx.Lock() s.metricsServer = &http.Server{ Addr: fmt.Sprintf(":%d", svcConf.ManagementPort), ReadHeaderTimeout: 3 * time.Second, Handler: h2c.NewHandler(handler, &http2.Server{}), // we need to use h2c to support plaintext HTTP2 } - s.metricsServerMtx.Unlock() - if err := s.metricsServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - return fmt.Errorf("error returned from metrics server: %w", err) - } - return nil + return serveWithShutdown(ctx, s.metricsServer, s.metricsServer.ListenAndServe) } diff --git a/flagd/pkg/service/flag-evaluation/connect_service_test.go b/flagd/pkg/service/flag-evaluation/connect_service_test.go index 032806533..74f111dba 100644 --- a/flagd/pkg/service/flag-evaluation/connect_service_test.go +++ b/flagd/pkg/service/flag-evaluation/connect_service_test.go @@ -1,11 +1,13 @@ package service import ( + "bytes" "context" "errors" "fmt" "net/http" "os" + "strings" "sync" "testing" "time" @@ -15,7 +17,6 @@ import ( mock "github.com/open-feature/flagd/core/pkg/evaluator/mock" "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/model" - "github.com/open-feature/flagd/core/pkg/notifications" iservice "github.com/open-feature/flagd/core/pkg/service" "github.com/open-feature/flagd/core/pkg/store" "github.com/open-feature/flagd/core/pkg/telemetry" @@ -29,7 +30,9 @@ import ( "google.golang.org/protobuf/types/known/structpb" ) -func TestConnectService_UnixConnection(t *testing.T) { +const resolveAllURLFmt = "http://localhost:%d/flagd.evaluation.v1.Service/ResolveAll" + +func TestConnectServiceUnixConnection(t *testing.T) { type evalFields struct { result bool variant string @@ -157,7 +160,10 @@ func TestAddMiddleware(t *testing.T) { }() require.Eventually(t, func() bool { - resp, err := http.Get(fmt.Sprintf("http://localhost:%d/flagd.evaluation.v1.Service/ResolveAll", port)) + resp, err := http.Get(fmt.Sprintf(resolveAllURLFmt, port)) + if err == nil && resp != nil { + resp.Body.Close() + } // with the default http handler we should get a method not allowed (405) when attempting a GET request return err == nil && resp.StatusCode == http.StatusMethodNotAllowed }, 3*time.Second, 100*time.Millisecond) @@ -165,9 +171,10 @@ func TestAddMiddleware(t *testing.T) { svc.AddMiddleware(mwMock) // with the injected middleware, the GET method should work - resp, err := http.Get(fmt.Sprintf("http://localhost:%d/flagd.evaluation.v1.Service/ResolveAll", port)) + resp, err := http.Get(fmt.Sprintf(resolveAllURLFmt, port)) require.Nil(t, err) + defer resp.Body.Close() // verify that the status we return in the mocked middleware require.Equal(t, http.StatusOK, resp.StatusCode) } @@ -243,7 +250,7 @@ func TestConnectServiceWatcher(t *testing.T) { Key: "flag1", DefaultVariant: "off", }, - }, model.Metadata{}) + }, model.Metadata{}, false) // notification type ofType := iservice.ConfigurationChange @@ -254,8 +261,8 @@ func TestConnectServiceWatcher(t *testing.T) { select { case n := <-sChan: require.Equal(t, ofType, n.Type, "expected notification type: %s, but received %s", ofType, n.Type) - notifications := n.Data["flags"].(notifications.Notifications) - flag1, ok := notifications["flag1"].(map[string]interface{}) + flags := n.Data["flags"].(map[string]interface{}) + flag1, ok := flags["flag1"].(map[string]interface{}) require.True(t, ok, "flag1 notification should be a map[string]interface{}") require.Equal(t, flag1["type"], string(model.NotificationCreate), "expected notification type: %s, but received %s", model.NotificationCreate, flag1["type"]) case <-timeout.Done(): @@ -308,3 +315,79 @@ func TestConnectServiceShutdown(t *testing.T) { t.Error("timeout while waiting for notifications") } } + +// startConnectService creates a ConnectService with a mock evaluator and metric recorder, +// starts it in a background goroutine with the given configuration, and waits until it is ready. +// It returns the port the service is listening on. +func startConnectService(t *testing.T, port uint16, conf iservice.Configuration) { + t.Helper() + + ctrl := gomock.NewController(t) + eval := mock.NewMockIEvaluator(ctrl) + + exp := metric.NewManualReader() + rs := resource.NewWithAttributes("testSchema") + metricRecorder := telemetry.NewOTelRecorder(exp, rs, "limit-test") + + svc := NewConnectService(logger.NewLogger(nil, false), eval, nil, metricRecorder) + + conf.ReadinessProbe = func() bool { return true } + conf.Port = port + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + go func() { + _ = svc.Serve(ctx, conf) + }() + + require.Eventually(t, func() bool { + resp, err := http.Get(fmt.Sprintf(resolveAllURLFmt, port)) + if err == nil && resp != nil { + resp.Body.Close() + } + return err == nil && resp != nil + }, 3*time.Second, 100*time.Millisecond) +} + +func TestConnectServiceRequestBodySizeLimit(t *testing.T) { + const port = 18291 + + startConnectService(t, port, iservice.Configuration{ + MaxRequestBodyBytes: 10, // allow only 10 bytes + }) + + // Valid JSON that exceeds the 10-byte body limit, so MaxBytesReader fires mid-parse. + largeBody := []byte(`{"flagKey":"` + strings.Repeat("a", 100) + `"}`) + req, err := http.NewRequest(http.MethodPost, + fmt.Sprintf("http://localhost:%d/flagd.evaluation.v1.Service/ResolveBoolean", port), + bytes.NewReader(largeBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + // connect-go maps MaxBytesError (resource exhausted) to HTTP 429. + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) +} + +func TestConnectServiceRequestHeaderSizeLimit(t *testing.T) { + const port = 18292 + + startConnectService(t, port, iservice.Configuration{ + MaxRequestHeaderBytes: 100, // 10000-byte test header value easily exceeds 100 + slop + }) + + req, err := http.NewRequest(http.MethodPost, + fmt.Sprintf("http://localhost:%d/flagd.evaluation.v1.Service/ResolveBoolean", port), + bytes.NewReader([]byte("{}"))) + require.NoError(t, err) + // Use valid ASCII to avoid client-side rejection; value exceeds MaxHeaderBytes + slop. + req.Header.Set("X-Large-Header", strings.Repeat("a", 10000)) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusRequestHeaderFieldsTooLarge, resp.StatusCode) +} diff --git a/flagd/pkg/service/flag-evaluation/context_utils.go b/flagd/pkg/service/flag-evaluation/context_utils.go new file mode 100644 index 000000000..72d7c00ad --- /dev/null +++ b/flagd/pkg/service/flag-evaluation/context_utils.go @@ -0,0 +1,36 @@ +package service + +import ( + "net/http" +) + +// MergeContextsAndHeaders merges evaluation contexts with static context values and header-based context. +// highest priority > header-context-from-cli > static-context-from-cli > request-context > lowest priority +// Header names are matched case-insensitively according to HTTP specification. +func MergeContextsAndHeaders( + requestContext map[string]any, + staticContext map[string]any, + headers http.Header, + headerToContextKeyMappings map[string]string, +) map[string]any { + merged := make(map[string]any) + + // request-body/client context first (lowest priority) + for k, v := range requestContext { + merged[k] = v + } + + // static/config context (overrides request context) + for k, v := range staticContext { + merged[k] = v + } + + // header-derived context (highest priority) we use .Get which is case-insensitive + for headerName, contextKey := range headerToContextKeyMappings { + if value := headers.Get(headerName); value != "" { + merged[contextKey] = value + } + } + + return merged +} diff --git a/flagd/pkg/service/flag-evaluation/context_utils_test.go b/flagd/pkg/service/flag-evaluation/context_utils_test.go new file mode 100644 index 000000000..71d0f480e --- /dev/null +++ b/flagd/pkg/service/flag-evaluation/context_utils_test.go @@ -0,0 +1,182 @@ +package service + +import ( + "net/http" + "reflect" + "testing" +) + +const ( + headerXUserTier = "X-User-Tier" + headerXUserTierLowercase = "x-user-tier" + headerXUserEmailLowercase = "x-user-email" +) + +func TestMergeContextsWithHeaders(t *testing.T) { + type args struct { + requestContext map[string]any + staticContext map[string]any + headers http.Header + headerToContextKeyMappings map[string]string + } + + tests := []struct { + name string + args args + want map[string]any + }{ + { + name: "empty contexts and headers", + args: args{ + requestContext: map[string]any{}, + staticContext: map[string]any{}, + headers: http.Header{}, + headerToContextKeyMappings: map[string]string{}, + }, + want: map[string]any{}, + }, + { + name: "request context only", + args: args{ + requestContext: map[string]any{"k1": "v1"}, + staticContext: map[string]any{}, + headers: http.Header{}, + headerToContextKeyMappings: map[string]string{}, + }, + want: map[string]any{"k1": "v1"}, + }, + { + name: "static context overrides request context", + args: args{ + requestContext: map[string]any{"k1": "v1", "k2": "v2"}, + staticContext: map[string]any{"k2": "v22", "k3": "v3"}, + headers: http.Header{}, + headerToContextKeyMappings: map[string]string{}, + }, + want: map[string]any{"k1": "v1", "k2": "v22", "k3": "v3"}, + }, + { + name: "exact case match - canonical header with canonical mapping", + args: args{ + requestContext: map[string]any{}, + staticContext: map[string]any{}, + headers: func() http.Header { + h := http.Header{} + h.Set(headerXUserTier, "premium") + return h + }(), + headerToContextKeyMappings: map[string]string{headerXUserTier: "userTier"}, + }, + want: map[string]any{"userTier": "premium"}, + }, + { + name: "case mismatch - lowercase header mapping with canonical header", + args: args{ + requestContext: map[string]any{}, + staticContext: map[string]any{}, + headers: func() http.Header { + h := http.Header{} + h.Set(headerXUserTier, "premium") + return h + }(), + headerToContextKeyMappings: map[string]string{headerXUserTierLowercase: "userTier"}, + }, + want: map[string]any{"userTier": "premium"}, + }, + { + name: "case mismatch - canonical mapping with lowercase header", + args: args{ + requestContext: map[string]any{}, + staticContext: map[string]any{}, + headers: func() http.Header { + h := http.Header{} + h.Set(headerXUserTierLowercase, "premium") + return h + }(), + headerToContextKeyMappings: map[string]string{headerXUserTier: "userTier"}, + }, + want: map[string]any{"userTier": "premium"}, + }, + { + name: "multiple headers with mixed case", + args: args{ + requestContext: map[string]any{}, + staticContext: map[string]any{}, + headers: func() http.Header { + h := http.Header{} + h.Set(headerXUserTier, "premium") + h.Set(headerXUserEmailLowercase, "user@example.com") + h.Set("X-Request-ID", "req-123") + return h + }(), + headerToContextKeyMappings: map[string]string{ + headerXUserTierLowercase: "userTier", + headerXUserEmailLowercase: "userEmail", + "x-request-id": "requestId", + }, + }, + want: map[string]any{ + "userTier": "premium", + "userEmail": "user@example.com", + "requestId": "req-123", + }, + }, + { + name: "header context overrides static context", + args: args{ + requestContext: map[string]any{"k1": "v1"}, + staticContext: map[string]any{"k2": "v22"}, + headers: func() http.Header { + h := http.Header{} + h.Set("X-Override", "override-value") + return h + }(), + headerToContextKeyMappings: map[string]string{"X-Override": "k2"}, + }, + want: map[string]any{"k1": "v1", "k2": "override-value"}, + }, + { + name: "header not present - should not be in context", + args: args{ + requestContext: map[string]any{}, + staticContext: map[string]any{}, + headers: http.Header{}, + headerToContextKeyMappings: map[string]string{ + "X-Missing": "missingKey", + }, + }, + want: map[string]any{}, + }, + { + name: "empty header value - should not be added", + args: args{ + requestContext: map[string]any{}, + staticContext: map[string]any{}, + headers: func() http.Header { + h := http.Header{} + h.Set("X-Empty", "") + return h + }(), + headerToContextKeyMappings: map[string]string{ + "X-Empty": "emptyKey", + }, + }, + want: map[string]any{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MergeContextsAndHeaders( + tt.args.requestContext, + tt.args.staticContext, + tt.args.headers, + tt.args.headerToContextKeyMappings, + ) + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("\ngot: %+v\nwant: %+v", got, tt.want) + } + }) + } +} diff --git a/flagd/pkg/service/flag-evaluation/eventing.go b/flagd/pkg/service/flag-evaluation/eventing.go index d9a47b02d..40410bbea 100644 --- a/flagd/pkg/service/flag-evaluation/eventing.go +++ b/flagd/pkg/service/flag-evaluation/eventing.go @@ -41,17 +41,23 @@ func (eventing *eventingConfiguration) Subscribe(ctx context.Context, id any, se for result := range watcher { newFlags := make(map[string]model.Flag) for _, flag := range result.Flags { - // we should be either selecting on a flag set here, or using the source-priority - duplicates are already handled, so we don't have to worry about overwrites + // we should be either selecting on a flag set here, or using the source-priority - duplicates are already handled, so we don't have to worry about overwrites newFlags[flag.Key] = flag } // ignore the first notification (nil old flags), the watcher emits on initialization, but for RPC we don't care until there's a change if oldFlags != nil { notifications := notifications.NewFromFlags(oldFlags, newFlags) + // if there are no changes, don't emit a notification + if len(notifications) == 0 { + oldFlags = newFlags + continue + } notifier <- iservice.Notification{ Type: iservice.ConfigurationChange, Data: map[string]interface{}{ - "flags": notifications, + // don't use our custom type or it cannot be serialized, convert to map + "flags": map[string]interface{}(notifications), }, } } diff --git a/flagd/pkg/service/flag-evaluation/eventing_test.go b/flagd/pkg/service/flag-evaluation/eventing_test.go index de4564691..d2870187a 100644 --- a/flagd/pkg/service/flag-evaluation/eventing_test.go +++ b/flagd/pkg/service/flag-evaluation/eventing_test.go @@ -4,27 +4,33 @@ import ( "context" "sync" "testing" + "time" "github.com/open-feature/flagd/core/pkg/logger" + "github.com/open-feature/flagd/core/pkg/model" iservice "github.com/open-feature/flagd/core/pkg/service" "github.com/open-feature/flagd/core/pkg/store" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" ) -func TestSubscribe(t *testing.T) { - // given - sources := []string{"source1", "source2"} +// newTestEventingConfig creates an eventingConfiguration backed by a test store. +func newTestEventingConfig(t *testing.T, sources []string) (*eventingConfiguration, store.IStore) { + t.Helper() log := logger.NewLogger(nil, false) s, err := store.NewStore(log, sources) - if err != nil { - t.Fatalf("NewStore failed: %v", err) - } + require.NoError(t, err) + return &eventingConfiguration{ + subs: make(map[interface{}]chan iservice.Notification), + mu: &sync.RWMutex{}, + store: s, + logger: log, + }, s +} - eventing := &eventingConfiguration{ - subs: make(map[interface{}]chan iservice.Notification), - mu: &sync.RWMutex{}, - store: s, - } +func TestSubscribe(t *testing.T) { + // given + eventing, _ := newTestEventingConfig(t, []string{"source1", "source2"}) idA := "a" chanA := make(chan iservice.Notification, 1) @@ -43,17 +49,7 @@ func TestSubscribe(t *testing.T) { func TestUnsubscribe(t *testing.T) { // given - sources := []string{"source1", "source2"} - log := logger.NewLogger(nil, false) - s, err := store.NewStore(log, sources) - if err != nil { - t.Fatalf("NewStore failed: %v", err) - } - eventing := &eventingConfiguration{ - subs: make(map[interface{}]chan iservice.Notification), - mu: &sync.RWMutex{}, - store: s, - } + eventing, _ := newTestEventingConfig(t, []string{"source1", "source2"}) idA := "a" chanA := make(chan iservice.Notification, 1) @@ -71,3 +67,72 @@ func TestUnsubscribe(t *testing.T) { "expected subscription cleared, but value present: %v", eventing.subs[idA]) require.Equal(t, chanB, eventing.subs[idB], "incorrect subscription association") } + +// TestNotificationCompatibleWithStructpb verifies that notification data from +// flag change events can be converted to protobuf structs, as required by the +// EventStream handlers. This is a regression test for +// https://github.com/open-feature/flagd/discussions/1869 +func TestNotificationCompatibleWithStructpb(t *testing.T) { + sources := []string{"source1"} + eventing, s := newTestEventingConfig(t, sources) + + notifyChan := make(chan iservice.Notification, 1) + eventing.Subscribe(context.Background(), "test", nil, notifyChan) + // allow the subscription goroutine to process the initial watch result + time.Sleep(100 * time.Millisecond) + + // first update sets up oldFlags. + s.Update(sources[0], []model.Flag{ + {Key: "flag1", DefaultVariant: "off"}, + }, model.Metadata{}, false) + + // second update triggers a ConfigurationChange with a real diff. + s.Update(sources[0], []model.Flag{ + {Key: "flag1", DefaultVariant: "on"}, + }, model.Metadata{}, false) + + select { + case n := <-notifyChan: + require.Equal(t, iservice.ConfigurationChange, n.Type) + // contains a named map type instead of plain map[string]interface{}. + _, err := structpb.NewStruct(n.Data) + require.NoError(t, err, "notification data must be compatible with structpb.NewStruct") + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for notification") + } +} + +// TestNoNotificationWhenFlagsUnchanged verifies that no ConfigurationChange +// notification is sent when a store update contains the same flags as before. +func TestNoNotificationWhenFlagsUnchanged(t *testing.T) { + sources := []string{"source1"} + eventing, s := newTestEventingConfig(t, sources) + + notifyChan := make(chan iservice.Notification, 1) + eventing.Subscribe(context.Background(), "test", nil, notifyChan) + time.Sleep(100 * time.Millisecond) + + // first update creates flag1 โ€” this produces a notification (create). + s.Update(sources[0], []model.Flag{ + {Key: "flag1", DefaultVariant: "off"}, + }, model.Metadata{}, false) + + // drain the first notification (flag creation). + select { + case <-notifyChan: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for first notification") + } + + // second update with the same flags โ€” should not produce a notification. + s.Update(sources[0], []model.Flag{ + {Key: "flag1", DefaultVariant: "off"}, + }, model.Metadata{}, false) + + select { + case n := <-notifyChan: + t.Fatalf("unexpected notification received: %v", n) + case <-time.After(500 * time.Millisecond): + // expected: no notification sent + } +} diff --git a/flagd/pkg/service/flag-evaluation/flag_evaluator.go b/flagd/pkg/service/flag-evaluation/flag_evaluator.go index 58c1c3b80..ff376ebfb 100644 --- a/flagd/pkg/service/flag-evaluation/flag_evaluator.go +++ b/flagd/pkg/service/flag-evaluation/flag_evaluator.go @@ -341,22 +341,8 @@ func (s *OldFlagEvaluationService) ResolveObject( return res, err } -// mergeContexts combines context values from headers, static context (from cli) and request context. -// highest priority > header-context-from-cli > static-context-from-cli > request-context > lowest priority func mergeContexts(reqCtx, configFlagsCtx map[string]any, headers http.Header, headerToContextKeyMappings map[string]string) map[string]any { - merged := make(map[string]any) - for k, v := range reqCtx { - merged[k] = v - } - for k, v := range configFlagsCtx { - merged[k] = v - } - for header, contextKey := range headerToContextKeyMappings { - if values, ok := headers[header]; ok { - merged[contextKey] = values[0] - } - } - return merged + return MergeContextsAndHeaders(reqCtx, configFlagsCtx, headers, headerToContextKeyMappings) } // resolve is a generic flag resolver diff --git a/flagd/pkg/service/flag-evaluation/flag_evaluator_types.go b/flagd/pkg/service/flag-evaluation/flag_evaluator_types.go index 416aca047..11a437c38 100644 --- a/flagd/pkg/service/flag-evaluation/flag_evaluator_types.go +++ b/flagd/pkg/service/flag-evaluation/flag_evaluator_types.go @@ -4,6 +4,7 @@ import ( "fmt" evalV1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/evaluation/v1" + evalV2 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/evaluation/v2" schemaV1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/schema/v1" "connectrpc.com/connect" "google.golang.org/protobuf/types/known/structpb" @@ -171,3 +172,205 @@ func (r *objectResponse) SetResult(value map[string]any, variant, reason string, } return nil } + +// V2 response types with optional value and variant + +type responseV2[T constraints] interface { + SetResult(value T, variant, reason string, metadata map[string]interface{}) error + SetReasonOnly(reason string, metadata map[string]interface{}) error +} + +// compile-time interface satisfaction checks +var ( + _ responseV2[bool] = (*booleanResponseV2)(nil) + _ responseV2[string] = (*stringResponseV2)(nil) + _ responseV2[float64] = (*floatResponseV2)(nil) + _ responseV2[int64] = (*intResponseV2)(nil) + _ responseV2[map[string]any] = (*objectResponseV2)(nil) +) + +type booleanResponseV2 struct { + evalV2Resp *connect.Response[evalV2.ResolveBooleanResponse] +} + +func (r *booleanResponseV2) SetResult(value bool, variant, reason string, metadata map[string]interface{}) error { + newStruct, err := structpb.NewStruct(metadata) + if err != nil { + return fmt.Errorf("failure to wrap metadata %w", err) + } + + if r.evalV2Resp != nil { + r.evalV2Resp.Msg.Value = &value + if variant != "" { + r.evalV2Resp.Msg.Variant = &variant + } + r.evalV2Resp.Msg.Reason = reason + r.evalV2Resp.Msg.Metadata = newStruct + } + + return nil +} + +func (r *booleanResponseV2) SetReasonOnly(reason string, metadata map[string]interface{}) error { + newStruct, err := structpb.NewStruct(metadata) + if err != nil { + return fmt.Errorf("failure to wrap metadata %w", err) + } + + if r.evalV2Resp != nil { + // Leave Value and Variant as nil (unset) + r.evalV2Resp.Msg.Reason = reason + r.evalV2Resp.Msg.Metadata = newStruct + } + + return nil +} + +type stringResponseV2 struct { + evalV2Resp *connect.Response[evalV2.ResolveStringResponse] +} + +func (r *stringResponseV2) SetResult(value string, variant, reason string, metadata map[string]interface{}) error { + newStruct, err := structpb.NewStruct(metadata) + if err != nil { + return fmt.Errorf("failure to wrap metadata %w", err) + } + + if r.evalV2Resp != nil { + r.evalV2Resp.Msg.Value = &value + if variant != "" { + r.evalV2Resp.Msg.Variant = &variant + } + r.evalV2Resp.Msg.Reason = reason + r.evalV2Resp.Msg.Metadata = newStruct + } + + return nil +} + +func (r *stringResponseV2) SetReasonOnly(reason string, metadata map[string]interface{}) error { + newStruct, err := structpb.NewStruct(metadata) + if err != nil { + return fmt.Errorf("failure to wrap metadata %w", err) + } + + if r.evalV2Resp != nil { + // Leave Value and Variant as nil (unset) + r.evalV2Resp.Msg.Reason = reason + r.evalV2Resp.Msg.Metadata = newStruct + } + + return nil +} + +type floatResponseV2 struct { + evalV2Resp *connect.Response[evalV2.ResolveFloatResponse] +} + +func (r *floatResponseV2) SetResult(value float64, variant, reason string, metadata map[string]interface{}) error { + newStruct, err := structpb.NewStruct(metadata) + if err != nil { + return fmt.Errorf("failure to wrap metadata %w", err) + } + + if r.evalV2Resp != nil { + r.evalV2Resp.Msg.Value = &value + if variant != "" { + r.evalV2Resp.Msg.Variant = &variant + } + r.evalV2Resp.Msg.Reason = reason + r.evalV2Resp.Msg.Metadata = newStruct + } + + return nil +} + +func (r *floatResponseV2) SetReasonOnly(reason string, metadata map[string]interface{}) error { + newStruct, err := structpb.NewStruct(metadata) + if err != nil { + return fmt.Errorf("failure to wrap metadata %w", err) + } + + if r.evalV2Resp != nil { + // Leave Value and Variant as nil (unset) + r.evalV2Resp.Msg.Reason = reason + r.evalV2Resp.Msg.Metadata = newStruct + } + + return nil +} + +type intResponseV2 struct { + evalV2Resp *connect.Response[evalV2.ResolveIntResponse] +} + +func (r *intResponseV2) SetResult(value int64, variant, reason string, metadata map[string]interface{}) error { + newStruct, err := structpb.NewStruct(metadata) + if err != nil { + return fmt.Errorf("failure to wrap metadata %w", err) + } + + if r.evalV2Resp != nil { + r.evalV2Resp.Msg.Value = &value + if variant != "" { + r.evalV2Resp.Msg.Variant = &variant + } + r.evalV2Resp.Msg.Reason = reason + r.evalV2Resp.Msg.Metadata = newStruct + } + return nil +} + +func (r *intResponseV2) SetReasonOnly(reason string, metadata map[string]interface{}) error { + newStruct, err := structpb.NewStruct(metadata) + if err != nil { + return fmt.Errorf("failure to wrap metadata %w", err) + } + + if r.evalV2Resp != nil { + // Leave Value and Variant as nil (unset) + r.evalV2Resp.Msg.Reason = reason + r.evalV2Resp.Msg.Metadata = newStruct + } + return nil +} + +type objectResponseV2 struct { + evalV2Resp *connect.Response[evalV2.ResolveObjectResponse] +} + +func (r *objectResponseV2) SetResult(value map[string]any, variant, reason string, + metadata map[string]interface{}, +) error { + newStruct, err := structpb.NewStruct(metadata) + if err != nil { + return fmt.Errorf("failure to wrap metadata %w", err) + } + if r.evalV2Resp != nil { + r.evalV2Resp.Msg.Reason = reason + val, err := structpb.NewStruct(value) + if err != nil { + return fmt.Errorf("struct response construction: %w", err) + } + + r.evalV2Resp.Msg.Value = val + if variant != "" { + r.evalV2Resp.Msg.Variant = &variant + } + r.evalV2Resp.Msg.Metadata = newStruct + } + return nil +} + +func (r *objectResponseV2) SetReasonOnly(reason string, metadata map[string]interface{}) error { + newStruct, err := structpb.NewStruct(metadata) + if err != nil { + return fmt.Errorf("failure to wrap metadata %w", err) + } + if r.evalV2Resp != nil { + // Leave Value and Variant as nil (unset) + r.evalV2Resp.Msg.Reason = reason + r.evalV2Resp.Msg.Metadata = newStruct + } + return nil +} diff --git a/flagd/pkg/service/flag-evaluation/flag_evaluator_v1.go b/flagd/pkg/service/flag-evaluation/flag_evaluator_v1.go new file mode 100644 index 000000000..747a8742b --- /dev/null +++ b/flagd/pkg/service/flag-evaluation/flag_evaluator_v1.go @@ -0,0 +1,370 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + evalV1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/evaluation/v1" + "connectrpc.com/connect" + "github.com/open-feature/flagd/core/pkg/evaluator" + "github.com/open-feature/flagd/core/pkg/logger" + "github.com/open-feature/flagd/core/pkg/service" + "github.com/open-feature/flagd/core/pkg/store" + "github.com/open-feature/flagd/core/pkg/telemetry" + flagdService "github.com/open-feature/flagd/flagd/pkg/service" + "github.com/rs/xid" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/types/known/structpb" +) + +type FlagEvaluationService struct { + logger *logger.Logger + eval evaluator.IEvaluator + metrics telemetry.IMetricsRecorder + eventingConfiguration IEvents + flagEvalTracer trace.Tracer + contextValues map[string]any + headerToContextKeyMappings map[string]string + deadline time.Duration +} + +// NewFlagEvaluationService creates a FlagEvaluationService with provided parameters +func NewFlagEvaluationService(log *logger.Logger, + eval evaluator.IEvaluator, + eventingCfg IEvents, + metricsRecorder telemetry.IMetricsRecorder, + contextValues map[string]any, + headerToContextKeyMappings map[string]string, + streamDeadline time.Duration, +) *FlagEvaluationService { + svc := &FlagEvaluationService{ + logger: log, + eval: eval, + metrics: &telemetry.NoopMetricsRecorder{}, + eventingConfiguration: eventingCfg, + flagEvalTracer: otel.Tracer("flagd.evaluation.v1"), + contextValues: contextValues, + headerToContextKeyMappings: headerToContextKeyMappings, + deadline: streamDeadline, + } + + if metricsRecorder != nil { + svc.metrics = metricsRecorder + } + + return svc +} + +// nolint:dupl,funlen +func (s *FlagEvaluationService) ResolveAll( + ctx context.Context, + req *connect.Request[evalV1.ResolveAllRequest], +) (*connect.Response[evalV1.ResolveAllResponse], error) { + reqID := xid.New().String() + defer s.logger.ClearFields(reqID) + + ctx, span := s.flagEvalTracer.Start(ctx, "resolveAll", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + res := &evalV1.ResolveAllResponse{ + Flags: make(map[string]*evalV1.AnyFlag), + } + + selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER) + selector := store.NewSelector(selectorExpression) + evaluationContext := mergeContexts(req.Msg.GetContext().AsMap(), s.contextValues, req.Header(), s.headerToContextKeyMappings) + ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector) + ctx = context.WithValue(ctx, evaluator.ProtoVersionKey, "v1") + + resolutions, flagSetMetadata, err := s.eval.ResolveAllValues(ctx, reqID, evaluationContext) + if err != nil { + s.logger.WarnWithID(reqID, fmt.Sprintf("error resolving all flags: %v", err)) + return nil, fmt.Errorf("error resolving flags. Tracking ID: %s", reqID) + } + + span.SetAttributes(attribute.Int("feature_flag.count", len(resolutions))) + for _, resolved := range resolutions { + // register the impression and reason for each flag evaluated + s.metrics.RecordEvaluation(ctx, resolved.Error, resolved.Reason, resolved.Variant, resolved.FlagKey) + switch v := resolved.Value.(type) { + case bool: + res.Flags[resolved.FlagKey] = &evalV1.AnyFlag{ + Reason: resolved.Reason, + Variant: resolved.Variant, + Value: &evalV1.AnyFlag_BoolValue{ + BoolValue: v, + }, + } + case string: + res.Flags[resolved.FlagKey] = &evalV1.AnyFlag{ + Reason: resolved.Reason, + Variant: resolved.Variant, + Value: &evalV1.AnyFlag_StringValue{ + StringValue: v, + }, + } + case float64: + res.Flags[resolved.FlagKey] = &evalV1.AnyFlag{ + Reason: resolved.Reason, + Variant: resolved.Variant, + Value: &evalV1.AnyFlag_DoubleValue{ + DoubleValue: v, + }, + } + case map[string]any: + val, err := structpb.NewStruct(v) + if err != nil { + s.logger.ErrorWithID(reqID, fmt.Sprintf("struct response construction: %v", err)) + continue + } + res.Flags[resolved.FlagKey] = &evalV1.AnyFlag{ + Reason: resolved.Reason, + Variant: resolved.Variant, + Value: &evalV1.AnyFlag_ObjectValue{ + ObjectValue: val, + }, + } + } + metadata, err := structpb.NewStruct(resolved.Metadata) + if err != nil { + s.logger.WarnWithID(reqID, fmt.Sprintf("error resolving all flags: %v", err)) + return nil, fmt.Errorf("error resolving flags. Tracking ID: %s", reqID) + } + + res.Flags[resolved.FlagKey].Metadata = metadata + } + res.Metadata, err = structpb.NewStruct(flagSetMetadata) + if err != nil { + s.logger.WarnWithID(reqID, fmt.Sprintf("error resolving all flags: %v", err)) + return nil, fmt.Errorf("error resolving flags. Tracking ID: %s", reqID) + } + + return connect.NewResponse(res), nil +} + +// nolint: dupl +func (s *FlagEvaluationService) EventStream( + ctx context.Context, + req *connect.Request[evalV1.EventStreamRequest], + stream *connect.ServerStream[evalV1.EventStreamResponse], +) error { + // attach server-side stream deadline to context + s.logger.Debug("starting event stream for request") + + if s.deadline != 0 { + streamDeadline := time.Now().Add(s.deadline) + deadlineCtx, cancel := context.WithDeadline(ctx, streamDeadline) + ctx = deadlineCtx + defer cancel() + } + + s.logger.Debug("starting event stream for request") + requestNotificationChan := make(chan service.Notification, 1) + selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER) + selector := store.NewSelector(selectorExpression) + s.eventingConfiguration.Subscribe(ctx, req, &selector, requestNotificationChan) + defer s.eventingConfiguration.Unsubscribe(req) + + requestNotificationChan <- service.Notification{ + Type: service.ProviderReady, + } + for { + select { + case <-time.After(20 * time.Second): + err := stream.Send(&evalV1.EventStreamResponse{ + Type: string(service.KeepAlive), + }) + if err != nil { + s.logger.Error(err.Error()) + } + case notification := <-requestNotificationChan: + d, err := structpb.NewStruct(notification.Data) + if err != nil { + s.logger.Error(err.Error()) + } + err = stream.Send(&evalV1.EventStreamResponse{ + Type: string(notification.Type), + Data: d, + }) + if err != nil { + s.logger.Error(err.Error()) + } + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + s.logger.Debug(fmt.Sprintf("server-side deadline of %s exceeded, exiting stream request with grpc error code 4", s.deadline.String())) + return connect.NewError(connect.CodeDeadlineExceeded, fmt.Errorf("%s", "stream closed due to server-side timeout")) + } + return nil + } + } +} + +func (s *FlagEvaluationService) ResolveBoolean( + ctx context.Context, + req *connect.Request[evalV1.ResolveBooleanRequest], +) (*connect.Response[evalV1.ResolveBooleanResponse], error) { + ctx, span := s.flagEvalTracer.Start(ctx, "resolveBoolean", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER) + selector := store.NewSelector(selectorExpression) + ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector) + ctx = context.WithValue(ctx, evaluator.ProtoVersionKey, "v1") + + res := connect.NewResponse(&evalV1.ResolveBooleanResponse{}) + err := resolve( + ctx, + s.logger, + s.eval.ResolveBooleanValue, + req.Header(), + req.Msg.GetFlagKey(), + req.Msg.GetContext(), + &booleanResponse{evalV1Resp: res}, + s.metrics, + s.contextValues, + s.headerToContextKeyMappings, + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) + } + + return res, err +} + +func (s *FlagEvaluationService) ResolveString( + ctx context.Context, + req *connect.Request[evalV1.ResolveStringRequest], +) (*connect.Response[evalV1.ResolveStringResponse], error) { + ctx, span := s.flagEvalTracer.Start(ctx, "resolveString", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER) + selector := store.NewSelector(selectorExpression) + ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector) + ctx = context.WithValue(ctx, evaluator.ProtoVersionKey, "v1") + + res := connect.NewResponse(&evalV1.ResolveStringResponse{}) + err := resolve( + ctx, + s.logger, + s.eval.ResolveStringValue, + req.Header(), + req.Msg.GetFlagKey(), + req.Msg.GetContext(), + &stringResponse{evalV1Resp: res}, + s.metrics, + s.contextValues, + s.headerToContextKeyMappings, + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) + } + + return res, err +} + +func (s *FlagEvaluationService) ResolveInt( + ctx context.Context, + req *connect.Request[evalV1.ResolveIntRequest], +) (*connect.Response[evalV1.ResolveIntResponse], error) { + ctx, span := s.flagEvalTracer.Start(ctx, "resolveInt", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER) + selector := store.NewSelector(selectorExpression) + ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector) + ctx = context.WithValue(ctx, evaluator.ProtoVersionKey, "v1") + + res := connect.NewResponse(&evalV1.ResolveIntResponse{}) + err := resolve( + ctx, + s.logger, + s.eval.ResolveIntValue, + req.Header(), + req.Msg.GetFlagKey(), + req.Msg.GetContext(), + &intResponse{evalV1Resp: res}, + s.metrics, + s.contextValues, + s.headerToContextKeyMappings, + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) + } + + return res, err +} + +func (s *FlagEvaluationService) ResolveFloat( + ctx context.Context, + req *connect.Request[evalV1.ResolveFloatRequest], +) (*connect.Response[evalV1.ResolveFloatResponse], error) { + ctx, span := s.flagEvalTracer.Start(ctx, "resolveFloat", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER) + selector := store.NewSelector(selectorExpression) + ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector) + ctx = context.WithValue(ctx, evaluator.ProtoVersionKey, "v1") + + res := connect.NewResponse(&evalV1.ResolveFloatResponse{}) + err := resolve( + ctx, + s.logger, + s.eval.ResolveFloatValue, + req.Header(), + req.Msg.GetFlagKey(), + req.Msg.GetContext(), + &floatResponse{evalV1Resp: res}, + s.metrics, + s.contextValues, + s.headerToContextKeyMappings, + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) + } + + return res, err +} + +func (s *FlagEvaluationService) ResolveObject( + ctx context.Context, + req *connect.Request[evalV1.ResolveObjectRequest], +) (*connect.Response[evalV1.ResolveObjectResponse], error) { + ctx, span := s.flagEvalTracer.Start(ctx, "resolveObject", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER) + selector := store.NewSelector(selectorExpression) + ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector) + ctx = context.WithValue(ctx, evaluator.ProtoVersionKey, "v1") + + res := connect.NewResponse(&evalV1.ResolveObjectResponse{}) + err := resolve( + ctx, + s.logger, + s.eval.ResolveObjectValue, + req.Header(), + req.Msg.GetFlagKey(), + req.Msg.GetContext(), + &objectResponse{evalV1Resp: res}, + s.metrics, + s.contextValues, + s.headerToContextKeyMappings, + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) + } + + return res, err +} diff --git a/flagd/pkg/service/flag-evaluation/flag_evaluator_v1_test.go b/flagd/pkg/service/flag-evaluation/flag_evaluator_v1_test.go new file mode 100644 index 000000000..70dbf3bf6 --- /dev/null +++ b/flagd/pkg/service/flag-evaluation/flag_evaluator_v1_test.go @@ -0,0 +1,1069 @@ +package service + +import ( + "context" + "errors" + "net/http" + "reflect" + "testing" + + evalV1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/evaluation/v1" + "connectrpc.com/connect" + "github.com/open-feature/flagd/core/pkg/evaluator" + mock "github.com/open-feature/flagd/core/pkg/evaluator/mock" + "github.com/open-feature/flagd/core/pkg/logger" + "github.com/open-feature/flagd/core/pkg/model" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.uber.org/mock/gomock" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestConnectServiceV2_ResolveAll(t *testing.T) { + tests := map[string]struct { + req *evalV1.ResolveAllRequest + evalRes []evaluator.AnyValue + metadataRes model.Metadata + evalErr error + wantErr bool + wantRes *evalV1.ResolveAllResponse + }{ + "happy-path": { + req: &evalV1.ResolveAllRequest{}, + evalRes: []evaluator.AnyValue{ + { + Value: true, + Variant: "bool-true", + Reason: "true", + FlagKey: "bool", + }, + { + Value: float64(12.12), + Variant: "float", + Reason: "float", + FlagKey: "float", + }, + { + Value: "hello", + Variant: "string", + Reason: "string", + FlagKey: "string", + }, + { + Value: "hello", + Variant: "object", + Reason: "string", + FlagKey: "object", + }, + }, + metadataRes: model.Metadata{ + "key": "value", + }, + wantRes: &evalV1.ResolveAllResponse{ + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "key": structpb.NewStringValue("value"), + }, + }, + Flags: map[string]*evalV1.AnyFlag{ + "bool": { + Value: &evalV1.AnyFlag_BoolValue{ + BoolValue: true, + }, + Reason: "STATIC", + }, + "float": { + Value: &evalV1.AnyFlag_DoubleValue{ + DoubleValue: float64(12.12), + }, + Reason: "STATIC", + }, + "string": { + Value: &evalV1.AnyFlag_StringValue{ + StringValue: "hello", + }, + Reason: "STATIC", + }, + }, + }, + }, + "resolver error": { + req: &evalV1.ResolveAllRequest{}, + evalRes: []evaluator.AnyValue{}, + evalErr: errors.New("some error from internal evaluator"), + wantErr: true, + }, + } + ctrl := gomock.NewController(t) + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // given + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveAllValues(gomock.Any(), gomock.Any(), gomock.Any()).Return( + tt.evalRes, tt.metadataRes, tt.evalErr, + ).AnyTimes() + + metrics, exp := getMetricReader() + s := NewFlagEvaluationService(logger.NewLogger(nil, false), eval, &eventingConfiguration{}, metrics, nil, nil, 0) + + // when + got, err := s.ResolveAll(context.Background(), connect.NewRequest(tt.req)) + + // then + if tt.wantErr { + if err == nil { + t.Error("expected error but git none") + } + + return + } + + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(t, err) + // the impression metric is registered + require.Equal(t, len(data.ScopeMetrics), 1) + require.EqualValues(t, tt.wantRes.Metadata, got.Msg.Metadata) + for _, flag := range tt.evalRes { + switch v := flag.Value.(type) { + case bool: + val := got.Msg.Flags[flag.FlagKey].Value.(*evalV1.AnyFlag_BoolValue) + require.Equal(t, v, val.BoolValue) + case string: + val := got.Msg.Flags[flag.FlagKey].Value.(*evalV1.AnyFlag_StringValue) + require.Equal(t, v, val.StringValue) + case float64: + val := got.Msg.Flags[flag.FlagKey].Value.(*evalV1.AnyFlag_DoubleValue) + require.Equal(t, v, val.DoubleValue) + } + } + }) + } +} + +type resolveBooleanArgsV2 struct { + evalFields resolveBooleanEvalFieldsV2 + functionArgs resolveBooleanFunctionArgsV2 + want *evalV1.ResolveBooleanResponse + wantErr error + mCount int +} +type resolveBooleanFunctionArgsV2 struct { + ctx context.Context + req *evalV1.ResolveBooleanRequest +} +type resolveBooleanEvalFieldsV2 struct { + result bool + evalCommons +} + +func TestFlag_EvaluationV2_ResolveBoolean(t *testing.T) { + ctrl := gomock.NewController(t) + + tests := map[string]resolveBooleanArgsV2{ + "happy path": { + mCount: 1, + evalFields: resolveBooleanEvalFieldsV2{ + result: true, + evalCommons: happyCommon, + }, + functionArgs: resolveBooleanFunctionArgsV2{ + context.Background(), + &evalV1.ResolveBooleanRequest{ + FlagKey: "bool", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveBooleanResponse{ + Value: true, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + "eval returns error": { + mCount: 1, + evalFields: resolveBooleanEvalFieldsV2{ + result: true, + evalCommons: sadCommon, + }, + functionArgs: resolveBooleanFunctionArgsV2{ + context.Background(), + &evalV1.ResolveBooleanRequest{ + FlagKey: "bool", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveBooleanResponse{ + Value: true, + Variant: ":(", + Reason: model.ErrorReason, + Metadata: responseStruct, + }, + wantErr: errors.New("eval interface error"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveBooleanValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + nil, + nil, + 0, + ) + got, err := s.ResolveBoolean(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("Flag_Evaluation.ResolveBoolean() error = %v, wantErr %v", err.Error(), tt.wantErr.Error()) + return + } + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(t, err) + // the impression metric is registered + require.Equal(t, len(data.ScopeMetrics), tt.mCount) + require.Equal(t, tt.want, got.Msg) + }) + } +} + +func BenchmarkFlag_EvaluationV2_ResolveBoolean(b *testing.B) { + ctrl := gomock.NewController(b) + tests := map[string]resolveBooleanArgsV2{ + "happy path": { + evalFields: resolveBooleanEvalFieldsV2{ + result: true, + evalCommons: happyCommon, + }, + functionArgs: resolveBooleanFunctionArgsV2{ + context.Background(), + &evalV1.ResolveBooleanRequest{ + FlagKey: "bool", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveBooleanResponse{ + Value: true, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + } + for name, tt := range tests { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveBooleanValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + nil, + nil, + 0, + ) + b.Run(name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + got, err := s.ResolveBoolean(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + b.Errorf("Flag_Evaluation.ResolveBoolean() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(b, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(b, err) + // the impression metric is registered + require.Equal(b, len(data.ScopeMetrics), 1) + } + }) + } +} + +type resolveStringArgsV2 struct { + evalFields resolveStringEvalFieldsV2 + functionArgs resolveStringFunctionArgsV2 + want *evalV1.ResolveStringResponse + wantErr error + mCount int +} +type resolveStringFunctionArgsV2 struct { + ctx context.Context + req *evalV1.ResolveStringRequest +} +type resolveStringEvalFieldsV2 struct { + result string + evalCommons +} + +func TestFlag_EvaluationV2_ResolveString(t *testing.T) { + ctrl := gomock.NewController(t) + tests := map[string]resolveStringArgsV2{ + "happy path": { + mCount: 1, + evalFields: resolveStringEvalFieldsV2{ + result: "true", + evalCommons: happyCommon, + }, + functionArgs: resolveStringFunctionArgsV2{ + context.Background(), + &evalV1.ResolveStringRequest{ + FlagKey: "string", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveStringResponse{ + Value: "true", + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + "eval returns error": { + mCount: 1, + evalFields: resolveStringEvalFieldsV2{ + result: "true", + evalCommons: sadCommon, + }, + functionArgs: resolveStringFunctionArgsV2{ + context.Background(), + &evalV1.ResolveStringRequest{ + FlagKey: "string", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveStringResponse{ + Value: "true", + Variant: ":(", + Reason: model.ErrorReason, + Metadata: responseStruct, + }, + wantErr: errors.New("eval interface error"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveStringValue( + gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ) + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + nil, + nil, + 0, + ) + got, err := s.ResolveString(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("Flag_Evaluation.ResolveString() error = %v, wantErr %v", err, tt.wantErr) + return + } + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(t, err) + // the impression metric is registered + require.Equal(t, len(data.ScopeMetrics), tt.mCount) + require.Equal(t, tt.want, got.Msg) + }) + } +} + +func BenchmarkFlag_EvaluationV2_ResolveString(b *testing.B) { + ctrl := gomock.NewController(b) + tests := map[string]resolveStringArgsV2{ + "happy path": { + evalFields: resolveStringEvalFieldsV2{ + result: "true", + evalCommons: happyCommon, + }, + functionArgs: resolveStringFunctionArgsV2{ + context.Background(), + &evalV1.ResolveStringRequest{ + FlagKey: "string", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveStringResponse{ + Value: "true", + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + } + for name, tt := range tests { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveStringValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + nil, + nil, + 0, + ) + b.Run(name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + got, err := s.ResolveString(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + b.Errorf("Flag_Evaluation.ResolveString() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(b, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(b, err) + // the impression metric is registered + require.Equal(b, len(data.ScopeMetrics), 1) + } + }) + } +} + +type resolveFloatArgsV2 struct { + evalFields resolveFloatEvalFieldsV2 + functionArgs resolveFloatFunctionArgsV2 + want *evalV1.ResolveFloatResponse + wantErr error + mCount int +} +type resolveFloatFunctionArgsV2 struct { + ctx context.Context + req *evalV1.ResolveFloatRequest +} +type resolveFloatEvalFieldsV2 struct { + result float64 + evalCommons +} + +func TestFlag_EvaluationV2_ResolveFloat(t *testing.T) { + ctrl := gomock.NewController(t) + tests := map[string]resolveFloatArgsV2{ + "happy path": { + mCount: 1, + evalFields: resolveFloatEvalFieldsV2{ + result: 12, + evalCommons: happyCommon, + }, + functionArgs: resolveFloatFunctionArgsV2{ + context.Background(), + &evalV1.ResolveFloatRequest{ + FlagKey: "float", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveFloatResponse{ + Value: 12, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + "eval returns error": { + mCount: 1, + evalFields: resolveFloatEvalFieldsV2{ + result: 12, + evalCommons: sadCommon, + }, + functionArgs: resolveFloatFunctionArgsV2{ + context.Background(), + &evalV1.ResolveFloatRequest{ + FlagKey: "float", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveFloatResponse{ + Value: 12, + Variant: ":(", + Reason: model.ErrorReason, + Metadata: responseStruct, + }, + wantErr: errors.New("eval interface error"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveFloatValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + nil, + nil, + 0, + ) + got, err := s.ResolveFloat(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("Flag_Evaluation.ResolveNumber() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(t, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(t, err) + // the impression metric is registered + require.Equal(t, len(data.ScopeMetrics), tt.mCount) + }) + } +} + +func BenchmarkFlag_EvaluationV2_ResolveFloat(b *testing.B) { + ctrl := gomock.NewController(b) + tests := map[string]resolveFloatArgsV2{ + "happy path": { + evalFields: resolveFloatEvalFieldsV2{ + result: 12, + evalCommons: happyCommon, + }, + functionArgs: resolveFloatFunctionArgsV2{ + context.Background(), + &evalV1.ResolveFloatRequest{ + FlagKey: "float", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveFloatResponse{ + Value: 12, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + } + for name, tt := range tests { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveFloatValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + nil, + nil, + 0, + ) + b.Run(name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + got, err := s.ResolveFloat(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + b.Errorf("Flag_Evaluation.ResolveNumber() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(b, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(b, err) + // the impression metric is registered + require.Equal(b, len(data.ScopeMetrics), 1) + } + }) + } +} + +type resolveIntArgsV2 struct { + evalFields resolveIntEvalFieldsV2 + functionArgs resolveIntFunctionArgsV2 + want *evalV1.ResolveIntResponse + wantErr error + mCount int +} +type resolveIntFunctionArgsV2 struct { + ctx context.Context + req *evalV1.ResolveIntRequest +} +type resolveIntEvalFieldsV2 struct { + result int64 + evalCommons +} + +func TestFlag_EvaluationV2_ResolveInt(t *testing.T) { + ctrl := gomock.NewController(t) + tests := map[string]resolveIntArgsV2{ + "happy path": { + mCount: 1, + evalFields: resolveIntEvalFieldsV2{ + result: 12, + evalCommons: happyCommon, + }, + functionArgs: resolveIntFunctionArgsV2{ + context.Background(), + &evalV1.ResolveIntRequest{ + FlagKey: "int", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveIntResponse{ + Value: 12, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + "eval returns error": { + mCount: 1, + evalFields: resolveIntEvalFieldsV2{ + result: 12, + evalCommons: sadCommon, + }, + functionArgs: resolveIntFunctionArgsV2{ + context.Background(), + &evalV1.ResolveIntRequest{ + FlagKey: "int", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveIntResponse{ + Value: 12, + Variant: ":(", + Reason: model.ErrorReason, + Metadata: responseStruct, + }, + wantErr: errors.New("eval interface error"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveIntValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + nil, + nil, + 0, + ) + got, err := s.ResolveInt(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("Flag_Evaluation.ResolveNumber() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(t, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(t, err) + // the impression metric is registered + require.Equal(t, len(data.ScopeMetrics), tt.mCount) + }) + } +} + +func BenchmarkFlag_EvaluationV2_ResolveInt(b *testing.B) { + ctrl := gomock.NewController(b) + tests := map[string]resolveIntArgsV2{ + "happy path": { + evalFields: resolveIntEvalFieldsV2{ + result: 12, + evalCommons: happyCommon, + }, + functionArgs: resolveIntFunctionArgsV2{ + context.Background(), + &evalV1.ResolveIntRequest{ + FlagKey: "int", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveIntResponse{ + Value: 12, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + } + for name, tt := range tests { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveIntValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + nil, + nil, + 0, + ) + b.Run(name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + got, err := s.ResolveInt(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + b.Errorf("Flag_Evaluation.ResolveNumber() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(b, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(b, err) + // the impression metric is registered + require.Equal(b, len(data.ScopeMetrics), 1) + } + }) + } +} + +type resolveObjectArgsV2 struct { + evalFields resolveObjectEvalFieldsV2 + functionArgs resolveObjectFunctionArgsV2 + want *evalV1.ResolveObjectResponse + wantErr error + mCount int +} +type resolveObjectFunctionArgsV2 struct { + ctx context.Context + req *evalV1.ResolveObjectRequest +} +type resolveObjectEvalFieldsV2 struct { + result map[string]interface{} + evalCommons +} + +func TestFlag_EvaluationV2_ResolveObject(t *testing.T) { + ctrl := gomock.NewController(t) + tests := map[string]resolveObjectArgsV2{ + "happy path": { + mCount: 1, + evalFields: resolveObjectEvalFieldsV2{ + result: map[string]interface{}{ + "food": "bars", + }, + evalCommons: happyCommon, + }, + functionArgs: resolveObjectFunctionArgsV2{ + context.Background(), + &evalV1.ResolveObjectRequest{ + FlagKey: "object", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveObjectResponse{ + Value: nil, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + "eval returns error": { + mCount: 1, + evalFields: resolveObjectEvalFieldsV2{ + result: map[string]interface{}{ + "food": "bars", + }, + evalCommons: sadCommon, + }, + functionArgs: resolveObjectFunctionArgsV2{ + context.Background(), + &evalV1.ResolveObjectRequest{ + FlagKey: "object", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveObjectResponse{ + Variant: ":(", + Reason: model.ErrorReason, + Metadata: responseStruct, + }, + wantErr: errors.New("eval interface error"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveObjectValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + nil, + nil, + 0, + ) + + outParsed, err := structpb.NewStruct(tt.evalFields.result) + if err != nil { + t.Error(err) + } + tt.want.Value = outParsed + got, err := s.ResolveObject(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("Flag_Evaluation.ResolveObject() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(t, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(t, err) + // the impression metric is registered + require.Equal(t, len(data.ScopeMetrics), tt.mCount) + }) + } +} + +func BenchmarkFlag_EvaluationV2_ResolveObject(b *testing.B) { + ctrl := gomock.NewController(b) + tests := map[string]resolveObjectArgsV2{ + "happy path": { + evalFields: resolveObjectEvalFieldsV2{ + result: map[string]interface{}{ + "food": "bars", + }, + evalCommons: happyCommon, + }, + functionArgs: resolveObjectFunctionArgsV2{ + context.Background(), + &evalV1.ResolveObjectRequest{ + FlagKey: "object", + Context: &structpb.Struct{}, + }, + }, + want: &evalV1.ResolveObjectResponse{ + Value: nil, + Reason: model.DefaultReason, + Variant: "on", + Metadata: responseStruct, + }, + wantErr: nil, + }, + } + for name, tt := range tests { + eval := mock.NewMockIEvaluator(ctrl) + eval.EXPECT().ResolveObjectValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( + tt.evalFields.result, + tt.evalFields.variant, + tt.evalFields.reason, + tt.evalFields.metadata, + tt.wantErr, + ).AnyTimes() + metrics, exp := getMetricReader() + s := NewFlagEvaluationService( + logger.NewLogger(nil, false), + eval, + &eventingConfiguration{}, + metrics, + nil, + nil, + 0, + ) + if name != "eval returns error" { + outParsed, err := structpb.NewStruct(tt.evalFields.result) + if err != nil { + b.Error(err) + } + tt.want.Value = outParsed + } + b.Run(name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + got, err := s.ResolveObject(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) + if (err != nil) && !errors.Is(err, tt.wantErr) { + b.Errorf("Flag_Evaluation.ResolveObject() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.Equal(b, tt.want, got.Msg) + var data metricdata.ResourceMetrics + err = exp.Collect(context.TODO(), &data) + require.Nil(b, err) + // the impression metric is registered + require.Equal(b, len(data.ScopeMetrics), 1) + } + }) + } +} + +// TestFlag_EvaluationV2_ErrorCodes test validate error mapping from known errors to connect.Code and avoid accidental +// changes. This is essential as SDK implementations rely on connect. Code to differentiate GRPC errors vs Flag errors. +// For any change in error codes, we must change respective SDK. +func TestFlag_EvaluationV2_ErrorCodes(t *testing.T) { + tests := []struct { + err error + code connect.Code + }{ + { + err: errors.New(model.FlagNotFoundErrorCode), + code: connect.CodeNotFound, + }, + { + err: errors.New(model.TypeMismatchErrorCode), + code: connect.CodeInvalidArgument, + }, + { + err: errors.New(model.ParseErrorCode), + code: connect.CodeDataLoss, + }, + { + err: errors.New(model.FlagDisabledErrorCode), + code: connect.CodeNotFound, + }, + { + err: errors.New(model.GeneralErrorCode), + code: connect.CodeUnknown, + }, + } + + for _, test := range tests { + err := errFormat(test.err) + + var connectErr *connect.Error + ok := errors.As(err, &connectErr) + + if !ok { + t.Error("formatted error is not of type connect.Error") + } + + if connectErr.Code() != test.code { + t.Errorf("expected code %s, but got code %s for model error %s", test.code, connectErr.Code(), + test.err.Error()) + } + } +} + +func Test_mergeContexts(t *testing.T) { + type args struct { + headers http.Header + headerToContextKeyMappings map[string]string + clientContext map[string]any + configContext map[string]any + } + + tests := []struct { + name string + args args + want map[string]any + }{ + { + name: "merge contexts with no headers, with no header-context mappings", + args: args{ + clientContext: map[string]any{"k1": "v1", "k2": "v2"}, + configContext: map[string]any{"k2": "v22", "k3": "v3"}, + headers: http.Header{}, + headerToContextKeyMappings: map[string]string{}, + }, + // static context should "win" + want: map[string]any{"k1": "v1", "k2": "v22", "k3": "v3"}, + }, + { + name: "merge contexts with headers, with no header-context mappings", + args: args{ + clientContext: map[string]any{"k1": "v1", "k2": "v2"}, + configContext: map[string]any{"k2": "v22", "k3": "v3"}, + headers: http.Header{"X-Key": []string{"value"}, "X-Token": []string{"token"}}, + headerToContextKeyMappings: map[string]string{}, + }, + // static context should "win" + want: map[string]any{"k1": "v1", "k2": "v22", "k3": "v3"}, + }, + { + name: "merge contexts with no headers, with header-context mappings", + args: args{ + clientContext: map[string]any{"k1": "v1", "k2": "v2"}, + configContext: map[string]any{"k2": "v22", "k3": "v3"}, + headers: http.Header{}, + headerToContextKeyMappings: map[string]string{"X-Key": "k2"}, + }, + // static context should "win" + want: map[string]any{"k1": "v1", "k2": "v22", "k3": "v3"}, + }, + { + name: "merge contexts with headers, with header-context mappings", + args: args{ + clientContext: map[string]any{"k1": "v1", "k2": "v2"}, + configContext: map[string]any{"k2": "v22", "k3": "v3"}, + headers: http.Header{"X-Key": []string{"value"}, "X-Token": []string{"token"}}, + headerToContextKeyMappings: map[string]string{"X-Key": "k2"}, + }, + // header context should "win" + want: map[string]any{"k1": "v1", "k2": "value", "k3": "v3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mergeContexts(tt.args.clientContext, tt.args.configContext, tt.args.headers, tt.args.headerToContextKeyMappings) + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("\ngot: %+v\nwant: %+v", got, tt.want) + } + }) + } +} diff --git a/flagd/pkg/service/flag-evaluation/flag_evaluator_v2.go b/flagd/pkg/service/flag-evaluation/flag_evaluator_v2.go index 083173783..c91971b0b 100644 --- a/flagd/pkg/service/flag-evaluation/flag_evaluator_v2.go +++ b/flagd/pkg/service/flag-evaluation/flag_evaluator_v2.go @@ -4,25 +4,27 @@ import ( "context" "errors" "fmt" + "net/http" "time" - evalV1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/evaluation/v1" + evalV2 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/evaluation/v2" "connectrpc.com/connect" "github.com/open-feature/flagd/core/pkg/evaluator" "github.com/open-feature/flagd/core/pkg/logger" + "github.com/open-feature/flagd/core/pkg/model" "github.com/open-feature/flagd/core/pkg/service" "github.com/open-feature/flagd/core/pkg/store" "github.com/open-feature/flagd/core/pkg/telemetry" flagdService "github.com/open-feature/flagd/flagd/pkg/service" "github.com/rs/xid" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" "google.golang.org/protobuf/types/known/structpb" ) -type FlagEvaluationService struct { +type FlagEvaluationServiceV2 struct { logger *logger.Logger eval evaluator.IEvaluator metrics telemetry.IMetricsRecorder @@ -33,21 +35,21 @@ type FlagEvaluationService struct { deadline time.Duration } -// NewFlagEvaluationService creates a FlagEvaluationService with provided parameters -func NewFlagEvaluationService(log *logger.Logger, +// NewFlagEvaluationServiceV2 creates a FlagEvaluationServiceV2 with provided parameters +func NewFlagEvaluationServiceV2(log *logger.Logger, eval evaluator.IEvaluator, eventingCfg IEvents, metricsRecorder telemetry.IMetricsRecorder, contextValues map[string]any, headerToContextKeyMappings map[string]string, streamDeadline time.Duration, -) *FlagEvaluationService { - svc := &FlagEvaluationService{ +) *FlagEvaluationServiceV2 { + svc := &FlagEvaluationServiceV2{ logger: log, eval: eval, metrics: &telemetry.NoopMetricsRecorder{}, eventingConfiguration: eventingCfg, - flagEvalTracer: otel.Tracer("flagd.evaluation.v1"), + flagEvalTracer: otel.Tracer("flagd.evaluation.v2"), contextValues: contextValues, headerToContextKeyMappings: headerToContextKeyMappings, deadline: streamDeadline, @@ -60,97 +62,11 @@ func NewFlagEvaluationService(log *logger.Logger, return svc } -// nolint:dupl,funlen -func (s *FlagEvaluationService) ResolveAll( - ctx context.Context, - req *connect.Request[evalV1.ResolveAllRequest], -) (*connect.Response[evalV1.ResolveAllResponse], error) { - reqID := xid.New().String() - defer s.logger.ClearFields(reqID) - - ctx, span := s.flagEvalTracer.Start(ctx, "resolveAll", trace.WithSpanKind(trace.SpanKindServer)) - defer span.End() - - res := &evalV1.ResolveAllResponse{ - Flags: make(map[string]*evalV1.AnyFlag), - } - - selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER) - selector := store.NewSelector(selectorExpression) - evaluationContext := mergeContexts(req.Msg.GetContext().AsMap(), s.contextValues, req.Header(), s.headerToContextKeyMappings) - ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector) - - resolutions, flagSetMetadata, err := s.eval.ResolveAllValues(ctx, reqID, evaluationContext) - if err != nil { - s.logger.WarnWithID(reqID, fmt.Sprintf("error resolving all flags: %v", err)) - return nil, fmt.Errorf("error resolving flags. Tracking ID: %s", reqID) - } - - span.SetAttributes(attribute.Int("feature_flag.count", len(resolutions))) - for _, resolved := range resolutions { - // register the impression and reason for each flag evaluated - s.metrics.RecordEvaluation(ctx, resolved.Error, resolved.Reason, resolved.Variant, resolved.FlagKey) - switch v := resolved.Value.(type) { - case bool: - res.Flags[resolved.FlagKey] = &evalV1.AnyFlag{ - Reason: resolved.Reason, - Variant: resolved.Variant, - Value: &evalV1.AnyFlag_BoolValue{ - BoolValue: v, - }, - } - case string: - res.Flags[resolved.FlagKey] = &evalV1.AnyFlag{ - Reason: resolved.Reason, - Variant: resolved.Variant, - Value: &evalV1.AnyFlag_StringValue{ - StringValue: v, - }, - } - case float64: - res.Flags[resolved.FlagKey] = &evalV1.AnyFlag{ - Reason: resolved.Reason, - Variant: resolved.Variant, - Value: &evalV1.AnyFlag_DoubleValue{ - DoubleValue: v, - }, - } - case map[string]any: - val, err := structpb.NewStruct(v) - if err != nil { - s.logger.ErrorWithID(reqID, fmt.Sprintf("struct response construction: %v", err)) - continue - } - res.Flags[resolved.FlagKey] = &evalV1.AnyFlag{ - Reason: resolved.Reason, - Variant: resolved.Variant, - Value: &evalV1.AnyFlag_ObjectValue{ - ObjectValue: val, - }, - } - } - metadata, err := structpb.NewStruct(resolved.Metadata) - if err != nil { - s.logger.WarnWithID(reqID, fmt.Sprintf("error resolving all flags: %v", err)) - return nil, fmt.Errorf("error resolving flags. Tracking ID: %s", reqID) - } - - res.Flags[resolved.FlagKey].Metadata = metadata - } - res.Metadata, err = structpb.NewStruct(flagSetMetadata) - if err != nil { - s.logger.WarnWithID(reqID, fmt.Sprintf("error resolving all flags: %v", err)) - return nil, fmt.Errorf("error resolving flags. Tracking ID: %s", reqID) - } - - return connect.NewResponse(res), nil -} - // nolint: dupl -func (s *FlagEvaluationService) EventStream( +func (s *FlagEvaluationServiceV2) EventStream( ctx context.Context, - req *connect.Request[evalV1.EventStreamRequest], - stream *connect.ServerStream[evalV1.EventStreamResponse], + req *connect.Request[evalV2.EventStreamRequest], + stream *connect.ServerStream[evalV2.EventStreamResponse], ) error { // attach server-side stream deadline to context s.logger.Debug("starting event stream for request") @@ -175,7 +91,7 @@ func (s *FlagEvaluationService) EventStream( for { select { case <-time.After(20 * time.Second): - err := stream.Send(&evalV1.EventStreamResponse{ + err := stream.Send(&evalV2.EventStreamResponse{ Type: string(service.KeepAlive), }) if err != nil { @@ -186,7 +102,7 @@ func (s *FlagEvaluationService) EventStream( if err != nil { s.logger.Error(err.Error()) } - err = stream.Send(&evalV1.EventStreamResponse{ + err = stream.Send(&evalV2.EventStreamResponse{ Type: string(notification.Type), Data: d, }) @@ -203,162 +119,180 @@ func (s *FlagEvaluationService) EventStream( } } -func (s *FlagEvaluationService) ResolveBoolean( +func (s *FlagEvaluationServiceV2) ResolveBoolean( ctx context.Context, - req *connect.Request[evalV1.ResolveBooleanRequest], -) (*connect.Response[evalV1.ResolveBooleanResponse], error) { - ctx, span := s.flagEvalTracer.Start(ctx, "resolveBoolean", trace.WithSpanKind(trace.SpanKindServer)) + req *connect.Request[evalV2.ResolveBooleanRequest], +) (*connect.Response[evalV2.ResolveBooleanResponse], error) { + ctx, span := s.startResolveV2(ctx, "resolveBoolean", req.Header()) defer span.End() - selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER) - selector := store.NewSelector(selectorExpression) - ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector) - - res := connect.NewResponse(&evalV1.ResolveBooleanResponse{}) - err := resolve( - ctx, - s.logger, - s.eval.ResolveBooleanValue, - req.Header(), - req.Msg.GetFlagKey(), - req.Msg.GetContext(), - &booleanResponse{evalV1Resp: res}, - s.metrics, - s.contextValues, - s.headerToContextKeyMappings, + res := connect.NewResponse(&evalV2.ResolveBooleanResponse{}) + err := resolveV2( + ctx, s.logger, s.eval.ResolveBooleanValue, req.Header(), + req.Msg.GetFlagKey(), req.Msg.GetContext(), + &booleanResponseV2{evalV2Resp: res}, + s.metrics, s.contextValues, s.headerToContextKeyMappings, ) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) - } + recordResolveErrorV2(span, err, req.Msg.GetFlagKey()) return res, err } -func (s *FlagEvaluationService) ResolveString( +func (s *FlagEvaluationServiceV2) ResolveString( ctx context.Context, - req *connect.Request[evalV1.ResolveStringRequest], -) (*connect.Response[evalV1.ResolveStringResponse], error) { - ctx, span := s.flagEvalTracer.Start(ctx, "resolveString", trace.WithSpanKind(trace.SpanKindServer)) + req *connect.Request[evalV2.ResolveStringRequest], +) (*connect.Response[evalV2.ResolveStringResponse], error) { + ctx, span := s.startResolveV2(ctx, "resolveString", req.Header()) defer span.End() - selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER) - selector := store.NewSelector(selectorExpression) - ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector) - - res := connect.NewResponse(&evalV1.ResolveStringResponse{}) - err := resolve( - ctx, - s.logger, - s.eval.ResolveStringValue, - req.Header(), - req.Msg.GetFlagKey(), - req.Msg.GetContext(), - &stringResponse{evalV1Resp: res}, - s.metrics, - s.contextValues, - s.headerToContextKeyMappings, + res := connect.NewResponse(&evalV2.ResolveStringResponse{}) + err := resolveV2( + ctx, s.logger, s.eval.ResolveStringValue, req.Header(), + req.Msg.GetFlagKey(), req.Msg.GetContext(), + &stringResponseV2{evalV2Resp: res}, + s.metrics, s.contextValues, s.headerToContextKeyMappings, ) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) - } + recordResolveErrorV2(span, err, req.Msg.GetFlagKey()) return res, err } -func (s *FlagEvaluationService) ResolveInt( +func (s *FlagEvaluationServiceV2) ResolveInt( ctx context.Context, - req *connect.Request[evalV1.ResolveIntRequest], -) (*connect.Response[evalV1.ResolveIntResponse], error) { - ctx, span := s.flagEvalTracer.Start(ctx, "resolveInt", trace.WithSpanKind(trace.SpanKindServer)) + req *connect.Request[evalV2.ResolveIntRequest], +) (*connect.Response[evalV2.ResolveIntResponse], error) { + ctx, span := s.startResolveV2(ctx, "resolveInt", req.Header()) defer span.End() - selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER) - selector := store.NewSelector(selectorExpression) - ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector) - - res := connect.NewResponse(&evalV1.ResolveIntResponse{}) - err := resolve( - ctx, - s.logger, - s.eval.ResolveIntValue, - req.Header(), - req.Msg.GetFlagKey(), - req.Msg.GetContext(), - &intResponse{evalV1Resp: res}, - s.metrics, - s.contextValues, - s.headerToContextKeyMappings, + res := connect.NewResponse(&evalV2.ResolveIntResponse{}) + err := resolveV2( + ctx, s.logger, s.eval.ResolveIntValue, req.Header(), + req.Msg.GetFlagKey(), req.Msg.GetContext(), + &intResponseV2{evalV2Resp: res}, + s.metrics, s.contextValues, s.headerToContextKeyMappings, ) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) - } + recordResolveErrorV2(span, err, req.Msg.GetFlagKey()) return res, err } -func (s *FlagEvaluationService) ResolveFloat( +func (s *FlagEvaluationServiceV2) ResolveFloat( ctx context.Context, - req *connect.Request[evalV1.ResolveFloatRequest], -) (*connect.Response[evalV1.ResolveFloatResponse], error) { - ctx, span := s.flagEvalTracer.Start(ctx, "resolveFloat", trace.WithSpanKind(trace.SpanKindServer)) + req *connect.Request[evalV2.ResolveFloatRequest], +) (*connect.Response[evalV2.ResolveFloatResponse], error) { + ctx, span := s.startResolveV2(ctx, "resolveFloat", req.Header()) defer span.End() - selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER) - selector := store.NewSelector(selectorExpression) - ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector) - - res := connect.NewResponse(&evalV1.ResolveFloatResponse{}) - err := resolve( - ctx, - s.logger, - s.eval.ResolveFloatValue, - req.Header(), - req.Msg.GetFlagKey(), - req.Msg.GetContext(), - &floatResponse{evalV1Resp: res}, - s.metrics, - s.contextValues, - s.headerToContextKeyMappings, + res := connect.NewResponse(&evalV2.ResolveFloatResponse{}) + err := resolveV2( + ctx, s.logger, s.eval.ResolveFloatValue, req.Header(), + req.Msg.GetFlagKey(), req.Msg.GetContext(), + &floatResponseV2{evalV2Resp: res}, + s.metrics, s.contextValues, s.headerToContextKeyMappings, ) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) - } + recordResolveErrorV2(span, err, req.Msg.GetFlagKey()) return res, err } -func (s *FlagEvaluationService) ResolveObject( +func (s *FlagEvaluationServiceV2) ResolveObject( ctx context.Context, - req *connect.Request[evalV1.ResolveObjectRequest], -) (*connect.Response[evalV1.ResolveObjectResponse], error) { - ctx, span := s.flagEvalTracer.Start(ctx, "resolveObject", trace.WithSpanKind(trace.SpanKindServer)) + req *connect.Request[evalV2.ResolveObjectRequest], +) (*connect.Response[evalV2.ResolveObjectResponse], error) { + ctx, span := s.startResolveV2(ctx, "resolveObject", req.Header()) defer span.End() - selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER) + res := connect.NewResponse(&evalV2.ResolveObjectResponse{}) + err := resolveV2( + ctx, s.logger, s.eval.ResolveObjectValue, req.Header(), + req.Msg.GetFlagKey(), req.Msg.GetContext(), + &objectResponseV2{evalV2Resp: res}, + s.metrics, s.contextValues, s.headerToContextKeyMappings, + ) + recordResolveErrorV2(span, err, req.Msg.GetFlagKey()) + + return res, err +} + +func resolveV2[T constraints](ctx context.Context, logger *logger.Logger, resolver resolverSignature[T], header http.Header, flagKey string, + evaluationContext *structpb.Struct, resp response[T], metrics telemetry.IMetricsRecorder, + configContextValues map[string]any, configHeaderToContextKeyMappings map[string]string, +) error { + reqID := xid.New().String() + defer logger.ClearFields(reqID) + + mergedContext := mergeContexts(evaluationContext.AsMap(), configContextValues, header, configHeaderToContextKeyMappings) + + logger.WriteFields( + reqID, + zap.String("flag-key", flagKey), + zap.Strings("context-keys", formatContextKeys(mergedContext)), + ) + + var evalErrFormatted error + result, variant, reason, metadata, evalErr := resolver(ctx, reqID, flagKey, mergedContext) + if evalErr != nil { + logger.WarnWithID(reqID, fmt.Sprintf("returning error response, reason: %v", evalErr)) + reason = model.ErrorReason + evalErrFormatted = errFormat(evalErr) + } + + if metrics != nil { + metrics.RecordEvaluation(ctx, evalErr, reason, variant, flagKey) + } + + spanFromContext := trace.SpanFromContext(ctx) + spanFromContext.SetAttributes(telemetry.SemConvFeatureFlagAttributes(flagKey, variant)...) + + if reason == model.FallbackReason { + if respV2, ok := resp.(responseV2[T]); ok { + if err := respV2.SetReasonOnly(model.DefaultReason, metadata); err != nil { + logger.ErrorWithID(reqID, err.Error()) + return fmt.Errorf("error setting response result: %w", err) + } + } + } else { + if err := resp.SetResult(result, variant, reason, metadata); err != nil && evalErr == nil { + logger.ErrorWithID(reqID, err.Error()) + return fmt.Errorf("error setting response result: %w", err) + } + } + + return evalErrFormatted +} + +// startResolveV2 initialises tracing and selector context common to every Resolve* method. +func (s *FlagEvaluationServiceV2) startResolveV2( + ctx context.Context, spanName string, header http.Header, +) (context.Context, trace.Span) { + ctx, span := s.flagEvalTracer.Start(ctx, spanName, trace.WithSpanKind(trace.SpanKindServer)) + + selectorExpression := header.Get(flagdService.FLAGD_SELECTOR_HEADER) selector := store.NewSelector(selectorExpression) ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector) - res := connect.NewResponse(&evalV1.ResolveObjectResponse{}) - err := resolve( - ctx, - s.logger, - s.eval.ResolveObjectValue, - req.Header(), - req.Msg.GetFlagKey(), - req.Msg.GetContext(), - &objectResponse{evalV1Resp: res}, - s.metrics, - s.contextValues, - s.headerToContextKeyMappings, - ) + return ctx, span +} + +// recordResolveErrorV2 records an evaluation error on the active span. +func recordResolveErrorV2(span trace.Span, err error, flagKey string) { if err != nil { span.RecordError(err) - span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey())) + span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", flagKey)) } +} - return res, err +// errFormatV2 formats errors for V2 API, excluding FLAG_NOT_FOUND and PARSE_ERROR which are not errors in V2 +func errFormatV2(err error) error { + ReadableErrorMsg := model.GetErrorMessage(err.Error()) + switch err.Error() { + case model.FlagDisabledErrorCode: + return connect.NewError(connect.CodeNotFound, fmt.Errorf("%s", ReadableErrorMsg)) + case model.TypeMismatchErrorCode: + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("%s", ReadableErrorMsg)) + case model.GeneralErrorCode: + return connect.NewError(connect.CodeUnknown, fmt.Errorf("%s", ReadableErrorMsg)) + } + + return err } diff --git a/flagd/pkg/service/flag-evaluation/flag_evaluator_v2_test.go b/flagd/pkg/service/flag-evaluation/flag_evaluator_v2_test.go index 9a5788ab8..c64699a62 100644 --- a/flagd/pkg/service/flag-evaluation/flag_evaluator_v2_test.go +++ b/flagd/pkg/service/flag-evaluation/flag_evaluator_v2_test.go @@ -2,14 +2,10 @@ package service import ( "context" - "errors" - "net/http" - "reflect" "testing" - evalV1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/evaluation/v1" + evalV2 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/evaluation/v2" "connectrpc.com/connect" - "github.com/open-feature/flagd/core/pkg/evaluator" mock "github.com/open-feature/flagd/core/pkg/evaluator/mock" "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/model" @@ -19,1051 +15,109 @@ import ( "google.golang.org/protobuf/types/known/structpb" ) -func TestConnectServiceV2_ResolveAll(t *testing.T) { - tests := map[string]struct { - req *evalV1.ResolveAllRequest - evalRes []evaluator.AnyValue - metadataRes model.Metadata - evalErr error - wantErr bool - wantRes *evalV1.ResolveAllResponse - }{ - "happy-path": { - req: &evalV1.ResolveAllRequest{}, - evalRes: []evaluator.AnyValue{ - { - Value: true, - Variant: "bool-true", - Reason: "true", - FlagKey: "bool", - }, - { - Value: float64(12.12), - Variant: "float", - Reason: "float", - FlagKey: "float", - }, - { - Value: "hello", - Variant: "string", - Reason: "string", - FlagKey: "string", - }, - { - Value: "hello", - Variant: "object", - Reason: "string", - FlagKey: "object", - }, - }, - metadataRes: model.Metadata{ - "key": "value", - }, - wantRes: &evalV1.ResolveAllResponse{ - Metadata: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "key": structpb.NewStringValue("value"), - }, - }, - Flags: map[string]*evalV1.AnyFlag{ - "bool": { - Value: &evalV1.AnyFlag_BoolValue{ - BoolValue: true, - }, - Reason: "STATIC", - }, - "float": { - Value: &evalV1.AnyFlag_DoubleValue{ - DoubleValue: float64(12.12), - }, - Reason: "STATIC", - }, - "string": { - Value: &evalV1.AnyFlag_StringValue{ - StringValue: "hello", - }, - Reason: "STATIC", - }, - }, - }, - }, - "resolver error": { - req: &evalV1.ResolveAllRequest{}, - evalRes: []evaluator.AnyValue{}, - evalErr: errors.New("some error from internal evaluator"), - wantErr: true, - }, - } - ctrl := gomock.NewController(t) - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - // given - eval := mock.NewMockIEvaluator(ctrl) - eval.EXPECT().ResolveAllValues(gomock.Any(), gomock.Any(), gomock.Any()).Return( - tt.evalRes, tt.metadataRes, tt.evalErr, - ).AnyTimes() - - metrics, exp := getMetricReader() - s := NewFlagEvaluationService(logger.NewLogger(nil, false), eval, &eventingConfiguration{}, metrics, nil, nil, 0) - - // when - got, err := s.ResolveAll(context.Background(), connect.NewRequest(tt.req)) - - // then - if tt.wantErr { - if err == nil { - t.Error("expected error but git none") - } - - return - } - - var data metricdata.ResourceMetrics - err = exp.Collect(context.TODO(), &data) - require.Nil(t, err) - // the impression metric is registered - require.Equal(t, len(data.ScopeMetrics), 1) - require.EqualValues(t, tt.wantRes.Metadata, got.Msg.Metadata) - for _, flag := range tt.evalRes { - switch v := flag.Value.(type) { - case bool: - val := got.Msg.Flags[flag.FlagKey].Value.(*evalV1.AnyFlag_BoolValue) - require.Equal(t, v, val.BoolValue) - case string: - val := got.Msg.Flags[flag.FlagKey].Value.(*evalV1.AnyFlag_StringValue) - require.Equal(t, v, val.StringValue) - case float64: - val := got.Msg.Flags[flag.FlagKey].Value.(*evalV1.AnyFlag_DoubleValue) - require.Equal(t, v, val.DoubleValue) - } - } - }) - } -} - -type resolveBooleanArgsV2 struct { - evalFields resolveBooleanEvalFieldsV2 - functionArgs resolveBooleanFunctionArgsV2 - want *evalV1.ResolveBooleanResponse - wantErr error - mCount int -} -type resolveBooleanFunctionArgsV2 struct { - ctx context.Context - req *evalV1.ResolveBooleanRequest -} -type resolveBooleanEvalFieldsV2 struct { - result bool - evalCommons -} - -func TestFlag_EvaluationV2_ResolveBoolean(t *testing.T) { - ctrl := gomock.NewController(t) - - tests := map[string]resolveBooleanArgsV2{ - "happy path": { - mCount: 1, - evalFields: resolveBooleanEvalFieldsV2{ - result: true, - evalCommons: happyCommon, - }, - functionArgs: resolveBooleanFunctionArgsV2{ - context.Background(), - &evalV1.ResolveBooleanRequest{ - FlagKey: "bool", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveBooleanResponse{ - Value: true, - Reason: model.DefaultReason, - Variant: "on", - Metadata: responseStruct, - }, - wantErr: nil, - }, - "eval returns error": { - mCount: 1, - evalFields: resolveBooleanEvalFieldsV2{ - result: true, - evalCommons: sadCommon, - }, - functionArgs: resolveBooleanFunctionArgsV2{ - context.Background(), - &evalV1.ResolveBooleanRequest{ - FlagKey: "bool", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveBooleanResponse{ - Value: true, - Variant: ":(", - Reason: model.ErrorReason, - Metadata: responseStruct, - }, - wantErr: errors.New("eval interface error"), - }, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - eval := mock.NewMockIEvaluator(ctrl) - eval.EXPECT().ResolveBooleanValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( - tt.evalFields.result, - tt.evalFields.variant, - tt.evalFields.reason, - tt.evalFields.metadata, - tt.wantErr, - ).AnyTimes() - metrics, exp := getMetricReader() - s := NewFlagEvaluationService( - logger.NewLogger(nil, false), - eval, - &eventingConfiguration{}, - metrics, - nil, - nil, - 0, - ) - got, err := s.ResolveBoolean(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) - if (err != nil) && !errors.Is(err, tt.wantErr) { - t.Errorf("Flag_Evaluation.ResolveBoolean() error = %v, wantErr %v", err.Error(), tt.wantErr.Error()) - return - } - var data metricdata.ResourceMetrics - err = exp.Collect(context.TODO(), &data) - require.Nil(t, err) - // the impression metric is registered - require.Equal(t, len(data.ScopeMetrics), tt.mCount) - require.Equal(t, tt.want, got.Msg) - }) - } -} - -func BenchmarkFlag_EvaluationV2_ResolveBoolean(b *testing.B) { - ctrl := gomock.NewController(b) - tests := map[string]resolveBooleanArgsV2{ - "happy path": { - evalFields: resolveBooleanEvalFieldsV2{ - result: true, - evalCommons: happyCommon, - }, - functionArgs: resolveBooleanFunctionArgsV2{ - context.Background(), - &evalV1.ResolveBooleanRequest{ - FlagKey: "bool", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveBooleanResponse{ - Value: true, - Reason: model.DefaultReason, - Variant: "on", - Metadata: responseStruct, - }, - wantErr: nil, - }, - } - for name, tt := range tests { - eval := mock.NewMockIEvaluator(ctrl) - eval.EXPECT().ResolveBooleanValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( - tt.evalFields.result, - tt.evalFields.variant, - tt.evalFields.reason, - tt.evalFields.metadata, - tt.wantErr, - ).AnyTimes() - metrics, exp := getMetricReader() - s := NewFlagEvaluationService( - logger.NewLogger(nil, false), - eval, - &eventingConfiguration{}, - metrics, - nil, - nil, - 0, - ) - b.Run(name, func(b *testing.B) { - for i := 0; i < b.N; i++ { - got, err := s.ResolveBoolean(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) - if (err != nil) && !errors.Is(err, tt.wantErr) { - b.Errorf("Flag_Evaluation.ResolveBoolean() error = %v, wantErr %v", err, tt.wantErr) - return - } - require.Equal(b, tt.want, got.Msg) - var data metricdata.ResourceMetrics - err = exp.Collect(context.TODO(), &data) - require.Nil(b, err) - // the impression metric is registered - require.Equal(b, len(data.ScopeMetrics), 1) - } - }) - } -} - -type resolveStringArgsV2 struct { - evalFields resolveStringEvalFieldsV2 - functionArgs resolveStringFunctionArgsV2 - want *evalV1.ResolveStringResponse - wantErr error - mCount int -} -type resolveStringFunctionArgsV2 struct { - ctx context.Context - req *evalV1.ResolveStringRequest -} -type resolveStringEvalFieldsV2 struct { - result string - evalCommons -} - -func TestFlag_EvaluationV2_ResolveString(t *testing.T) { - ctrl := gomock.NewController(t) - tests := map[string]resolveStringArgsV2{ - "happy path": { - mCount: 1, - evalFields: resolveStringEvalFieldsV2{ - result: "true", - evalCommons: happyCommon, - }, - functionArgs: resolveStringFunctionArgsV2{ - context.Background(), - &evalV1.ResolveStringRequest{ - FlagKey: "string", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveStringResponse{ - Value: "true", - Reason: model.DefaultReason, - Variant: "on", - Metadata: responseStruct, - }, - wantErr: nil, - }, - "eval returns error": { - mCount: 1, - evalFields: resolveStringEvalFieldsV2{ - result: "true", - evalCommons: sadCommon, - }, - functionArgs: resolveStringFunctionArgsV2{ - context.Background(), - &evalV1.ResolveStringRequest{ - FlagKey: "string", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveStringResponse{ - Value: "true", - Variant: ":(", - Reason: model.ErrorReason, - Metadata: responseStruct, - }, - wantErr: errors.New("eval interface error"), - }, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - eval := mock.NewMockIEvaluator(ctrl) - eval.EXPECT().ResolveStringValue( - gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( - tt.evalFields.result, - tt.evalFields.variant, - tt.evalFields.reason, - tt.evalFields.metadata, - tt.wantErr, - ) - metrics, exp := getMetricReader() - s := NewFlagEvaluationService( - logger.NewLogger(nil, false), - eval, - &eventingConfiguration{}, - metrics, - nil, - nil, - 0, - ) - got, err := s.ResolveString(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) - if (err != nil) && !errors.Is(err, tt.wantErr) { - t.Errorf("Flag_Evaluation.ResolveString() error = %v, wantErr %v", err, tt.wantErr) - return - } - var data metricdata.ResourceMetrics - err = exp.Collect(context.TODO(), &data) - require.Nil(t, err) - // the impression metric is registered - require.Equal(t, len(data.ScopeMetrics), tt.mCount) - require.Equal(t, tt.want, got.Msg) - }) - } -} - -func BenchmarkFlag_EvaluationV2_ResolveString(b *testing.B) { - ctrl := gomock.NewController(b) - tests := map[string]resolveStringArgsV2{ - "happy path": { - evalFields: resolveStringEvalFieldsV2{ - result: "true", - evalCommons: happyCommon, - }, - functionArgs: resolveStringFunctionArgsV2{ - context.Background(), - &evalV1.ResolveStringRequest{ - FlagKey: "string", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveStringResponse{ - Value: "true", - Reason: model.DefaultReason, - Variant: "on", - Metadata: responseStruct, - }, - wantErr: nil, - }, - } - for name, tt := range tests { - eval := mock.NewMockIEvaluator(ctrl) - eval.EXPECT().ResolveStringValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( - tt.evalFields.result, - tt.evalFields.variant, - tt.evalFields.reason, - tt.evalFields.metadata, - tt.wantErr, - ).AnyTimes() - metrics, exp := getMetricReader() - s := NewFlagEvaluationService( - logger.NewLogger(nil, false), - eval, - &eventingConfiguration{}, - metrics, - nil, - nil, - 0, - ) - b.Run(name, func(b *testing.B) { - for i := 0; i < b.N; i++ { - got, err := s.ResolveString(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) - if (err != nil) && !errors.Is(err, tt.wantErr) { - b.Errorf("Flag_Evaluation.ResolveString() error = %v, wantErr %v", err, tt.wantErr) - return - } - require.Equal(b, tt.want, got.Msg) - var data metricdata.ResourceMetrics - err = exp.Collect(context.TODO(), &data) - require.Nil(b, err) - // the impression metric is registered - require.Equal(b, len(data.ScopeMetrics), 1) - } - }) - } -} - -type resolveFloatArgsV2 struct { - evalFields resolveFloatEvalFieldsV2 - functionArgs resolveFloatFunctionArgsV2 - want *evalV1.ResolveFloatResponse - wantErr error - mCount int -} -type resolveFloatFunctionArgsV2 struct { - ctx context.Context - req *evalV1.ResolveFloatRequest -} -type resolveFloatEvalFieldsV2 struct { - result float64 - evalCommons -} - -func TestFlag_EvaluationV2_ResolveFloat(t *testing.T) { - ctrl := gomock.NewController(t) - tests := map[string]resolveFloatArgsV2{ - "happy path": { - mCount: 1, - evalFields: resolveFloatEvalFieldsV2{ - result: 12, - evalCommons: happyCommon, - }, - functionArgs: resolveFloatFunctionArgsV2{ - context.Background(), - &evalV1.ResolveFloatRequest{ - FlagKey: "float", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveFloatResponse{ - Value: 12, - Reason: model.DefaultReason, - Variant: "on", - Metadata: responseStruct, - }, - wantErr: nil, - }, - "eval returns error": { - mCount: 1, - evalFields: resolveFloatEvalFieldsV2{ - result: 12, - evalCommons: sadCommon, - }, - functionArgs: resolveFloatFunctionArgsV2{ - context.Background(), - &evalV1.ResolveFloatRequest{ - FlagKey: "float", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveFloatResponse{ - Value: 12, - Variant: ":(", - Reason: model.ErrorReason, - Metadata: responseStruct, - }, - wantErr: errors.New("eval interface error"), - }, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - eval := mock.NewMockIEvaluator(ctrl) - eval.EXPECT().ResolveFloatValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( - tt.evalFields.result, - tt.evalFields.variant, - tt.evalFields.reason, - tt.evalFields.metadata, - tt.wantErr, - ).AnyTimes() - metrics, exp := getMetricReader() - s := NewFlagEvaluationService( - logger.NewLogger(nil, false), - eval, - &eventingConfiguration{}, - metrics, - nil, - nil, - 0, - ) - got, err := s.ResolveFloat(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) - if (err != nil) && !errors.Is(err, tt.wantErr) { - t.Errorf("Flag_Evaluation.ResolveNumber() error = %v, wantErr %v", err, tt.wantErr) - return - } - require.Equal(t, tt.want, got.Msg) - var data metricdata.ResourceMetrics - err = exp.Collect(context.TODO(), &data) - require.Nil(t, err) - // the impression metric is registered - require.Equal(t, len(data.ScopeMetrics), tt.mCount) - }) - } -} - -func BenchmarkFlag_EvaluationV2_ResolveFloat(b *testing.B) { - ctrl := gomock.NewController(b) - tests := map[string]resolveFloatArgsV2{ - "happy path": { - evalFields: resolveFloatEvalFieldsV2{ - result: 12, - evalCommons: happyCommon, - }, - functionArgs: resolveFloatFunctionArgsV2{ - context.Background(), - &evalV1.ResolveFloatRequest{ - FlagKey: "float", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveFloatResponse{ - Value: 12, - Reason: model.DefaultReason, - Variant: "on", - Metadata: responseStruct, - }, - wantErr: nil, - }, - } - for name, tt := range tests { - eval := mock.NewMockIEvaluator(ctrl) - eval.EXPECT().ResolveFloatValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( - tt.evalFields.result, - tt.evalFields.variant, - tt.evalFields.reason, - tt.evalFields.metadata, - tt.wantErr, - ).AnyTimes() - metrics, exp := getMetricReader() - s := NewFlagEvaluationService( - logger.NewLogger(nil, false), - eval, - &eventingConfiguration{}, - metrics, - nil, - nil, - 0, - ) - b.Run(name, func(b *testing.B) { - for i := 0; i < b.N; i++ { - got, err := s.ResolveFloat(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) - if (err != nil) && !errors.Is(err, tt.wantErr) { - b.Errorf("Flag_Evaluation.ResolveNumber() error = %v, wantErr %v", err, tt.wantErr) - return - } - require.Equal(b, tt.want, got.Msg) - var data metricdata.ResourceMetrics - err = exp.Collect(context.TODO(), &data) - require.Nil(b, err) - // the impression metric is registered - require.Equal(b, len(data.ScopeMetrics), 1) - } - }) - } -} - -type resolveIntArgsV2 struct { - evalFields resolveIntEvalFieldsV2 - functionArgs resolveIntFunctionArgsV2 - want *evalV1.ResolveIntResponse - wantErr error - mCount int -} -type resolveIntFunctionArgsV2 struct { - ctx context.Context - req *evalV1.ResolveIntRequest -} -type resolveIntEvalFieldsV2 struct { - result int64 - evalCommons -} - -func TestFlag_EvaluationV2_ResolveInt(t *testing.T) { - ctrl := gomock.NewController(t) - tests := map[string]resolveIntArgsV2{ - "happy path": { - mCount: 1, - evalFields: resolveIntEvalFieldsV2{ - result: 12, - evalCommons: happyCommon, - }, - functionArgs: resolveIntFunctionArgsV2{ - context.Background(), - &evalV1.ResolveIntRequest{ - FlagKey: "int", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveIntResponse{ - Value: 12, - Reason: model.DefaultReason, - Variant: "on", - Metadata: responseStruct, - }, - wantErr: nil, - }, - "eval returns error": { - mCount: 1, - evalFields: resolveIntEvalFieldsV2{ - result: 12, - evalCommons: sadCommon, - }, - functionArgs: resolveIntFunctionArgsV2{ - context.Background(), - &evalV1.ResolveIntRequest{ - FlagKey: "int", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveIntResponse{ - Value: 12, - Variant: ":(", - Reason: model.ErrorReason, - Metadata: responseStruct, - }, - wantErr: errors.New("eval interface error"), - }, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - eval := mock.NewMockIEvaluator(ctrl) - eval.EXPECT().ResolveIntValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( - tt.evalFields.result, - tt.evalFields.variant, - tt.evalFields.reason, - tt.evalFields.metadata, - tt.wantErr, - ).AnyTimes() - metrics, exp := getMetricReader() - s := NewFlagEvaluationService( - logger.NewLogger(nil, false), - eval, - &eventingConfiguration{}, - metrics, - nil, - nil, - 0, - ) - got, err := s.ResolveInt(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) - if (err != nil) && !errors.Is(err, tt.wantErr) { - t.Errorf("Flag_Evaluation.ResolveNumber() error = %v, wantErr %v", err, tt.wantErr) - return - } - require.Equal(t, tt.want, got.Msg) - var data metricdata.ResourceMetrics - err = exp.Collect(context.TODO(), &data) - require.Nil(t, err) - // the impression metric is registered - require.Equal(t, len(data.ScopeMetrics), tt.mCount) - }) - } -} - -func BenchmarkFlag_EvaluationV2_ResolveInt(b *testing.B) { - ctrl := gomock.NewController(b) - tests := map[string]resolveIntArgsV2{ - "happy path": { - evalFields: resolveIntEvalFieldsV2{ - result: 12, - evalCommons: happyCommon, - }, - functionArgs: resolveIntFunctionArgsV2{ - context.Background(), - &evalV1.ResolveIntRequest{ - FlagKey: "int", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveIntResponse{ - Value: 12, - Reason: model.DefaultReason, - Variant: "on", - Metadata: responseStruct, - }, - wantErr: nil, - }, - } - for name, tt := range tests { - eval := mock.NewMockIEvaluator(ctrl) - eval.EXPECT().ResolveIntValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( - tt.evalFields.result, - tt.evalFields.variant, - tt.evalFields.reason, - tt.evalFields.metadata, - tt.wantErr, - ).AnyTimes() - metrics, exp := getMetricReader() - s := NewFlagEvaluationService( - logger.NewLogger(nil, false), - eval, - &eventingConfiguration{}, - metrics, - nil, - nil, - 0, - ) - b.Run(name, func(b *testing.B) { - for i := 0; i < b.N; i++ { - got, err := s.ResolveInt(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) - if (err != nil) && !errors.Is(err, tt.wantErr) { - b.Errorf("Flag_Evaluation.ResolveNumber() error = %v, wantErr %v", err, tt.wantErr) - return - } - require.Equal(b, tt.want, got.Msg) - var data metricdata.ResourceMetrics - err = exp.Collect(context.TODO(), &data) - require.Nil(b, err) - // the impression metric is registered - require.Equal(b, len(data.ScopeMetrics), 1) - } - }) - } -} - -type resolveObjectArgsV2 struct { - evalFields resolveObjectEvalFieldsV2 - functionArgs resolveObjectFunctionArgsV2 - want *evalV1.ResolveObjectResponse - wantErr error - mCount int -} -type resolveObjectFunctionArgsV2 struct { - ctx context.Context - req *evalV1.ResolveObjectRequest -} -type resolveObjectEvalFieldsV2 struct { - result map[string]interface{} - evalCommons -} - -func TestFlag_EvaluationV2_ResolveObject(t *testing.T) { - ctrl := gomock.NewController(t) - tests := map[string]resolveObjectArgsV2{ - "happy path": { - mCount: 1, - evalFields: resolveObjectEvalFieldsV2{ - result: map[string]interface{}{ - "food": "bars", - }, - evalCommons: happyCommon, - }, - functionArgs: resolveObjectFunctionArgsV2{ - context.Background(), - &evalV1.ResolveObjectRequest{ - FlagKey: "object", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveObjectResponse{ - Value: nil, - Reason: model.DefaultReason, - Variant: "on", - Metadata: responseStruct, - }, - wantErr: nil, - }, - "eval returns error": { - mCount: 1, - evalFields: resolveObjectEvalFieldsV2{ - result: map[string]interface{}{ - "food": "bars", - }, - evalCommons: sadCommon, - }, - functionArgs: resolveObjectFunctionArgsV2{ - context.Background(), - &evalV1.ResolveObjectRequest{ - FlagKey: "object", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveObjectResponse{ - Variant: ":(", - Reason: model.ErrorReason, - Metadata: responseStruct, - }, - wantErr: errors.New("eval interface error"), - }, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - eval := mock.NewMockIEvaluator(ctrl) - eval.EXPECT().ResolveObjectValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( - tt.evalFields.result, - tt.evalFields.variant, - tt.evalFields.reason, - tt.evalFields.metadata, - tt.wantErr, - ).AnyTimes() - metrics, exp := getMetricReader() - s := NewFlagEvaluationService( - logger.NewLogger(nil, false), - eval, - &eventingConfiguration{}, - metrics, - nil, - nil, - 0, - ) - - outParsed, err := structpb.NewStruct(tt.evalFields.result) - if err != nil { - t.Error(err) - } - tt.want.Value = outParsed - got, err := s.ResolveObject(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) - if (err != nil) && !errors.Is(err, tt.wantErr) { - t.Errorf("Flag_Evaluation.ResolveObject() error = %v, wantErr %v", err, tt.wantErr) - return - } - require.Equal(t, tt.want, got.Msg) - var data metricdata.ResourceMetrics - err = exp.Collect(context.TODO(), &data) - require.Nil(t, err) - // the impression metric is registered - require.Equal(t, len(data.ScopeMetrics), tt.mCount) - }) - } -} - -func BenchmarkFlag_EvaluationV2_ResolveObject(b *testing.B) { - ctrl := gomock.NewController(b) - tests := map[string]resolveObjectArgsV2{ - "happy path": { - evalFields: resolveObjectEvalFieldsV2{ - result: map[string]interface{}{ - "food": "bars", - }, - evalCommons: happyCommon, - }, - functionArgs: resolveObjectFunctionArgsV2{ - context.Background(), - &evalV1.ResolveObjectRequest{ - FlagKey: "object", - Context: &structpb.Struct{}, - }, - }, - want: &evalV1.ResolveObjectResponse{ - Value: nil, - Reason: model.DefaultReason, - Variant: "on", - Metadata: responseStruct, - }, - wantErr: nil, - }, - } - for name, tt := range tests { - eval := mock.NewMockIEvaluator(ctrl) - eval.EXPECT().ResolveObjectValue(gomock.Any(), gomock.Any(), tt.functionArgs.req.FlagKey, gomock.Any()).Return( - tt.evalFields.result, - tt.evalFields.variant, - tt.evalFields.reason, - tt.evalFields.metadata, - tt.wantErr, - ).AnyTimes() - metrics, exp := getMetricReader() - s := NewFlagEvaluationService( - logger.NewLogger(nil, false), - eval, - &eventingConfiguration{}, - metrics, - nil, - nil, - 0, - ) - if name != "eval returns error" { - outParsed, err := structpb.NewStruct(tt.evalFields.result) - if err != nil { - b.Error(err) - } - tt.want.Value = outParsed - } - b.Run(name, func(b *testing.B) { - for i := 0; i < b.N; i++ { - got, err := s.ResolveObject(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req)) - if (err != nil) && !errors.Is(err, tt.wantErr) { - b.Errorf("Flag_Evaluation.ResolveObject() error = %v, wantErr %v", err, tt.wantErr) - return - } - require.Equal(b, tt.want, got.Msg) - var data metricdata.ResourceMetrics - err = exp.Collect(context.TODO(), &data) - require.Nil(b, err) - // the impression metric is registered - require.Equal(b, len(data.ScopeMetrics), 1) - } - }) - } -} - -// TestFlag_EvaluationV2_ErrorCodes test validate error mapping from known errors to connect.Code and avoid accidental -// changes. This is essential as SDK implementations rely on connect. Code to differentiate GRPC errors vs Flag errors. -// For any change in error codes, we must change respective SDK. -func TestFlag_EvaluationV2_ErrorCodes(t *testing.T) { +// fallbackResult captures the fields we care about from any resolve response. +type fallbackResult struct { + value any + variant *string + reason string + metadata *structpb.Struct +} + +// TestFlagEvaluationServiceV2_Fallback tests that the V2 service correctly +// handles the fallback case (null targeting + no defaultVariant) for all flag +// types. Each response type's SetReasonOnly method must set reason to DEFAULT +// with nil value and variant, signaling the provider to use the code default. +// note: V2 signals fallback via SetReasonOnly (V1 uses an error instead); +// other V2 behaviors are covered in flag_evaluator_v1_test.go. +func TestFlagEvaluationServiceV2_Fallback(t *testing.T) { tests := []struct { - err error - code connect.Code + name string + // run sets up the mock, calls the service, and returns the common response fields + run func(eval *mock.MockIEvaluator, s *FlagEvaluationServiceV2) (fallbackResult, error) }{ { - err: errors.New(model.FlagNotFoundErrorCode), - code: connect.CodeNotFound, - }, - { - err: errors.New(model.TypeMismatchErrorCode), - code: connect.CodeInvalidArgument, - }, - { - err: errors.New(model.ParseErrorCode), - code: connect.CodeDataLoss, - }, - { - err: errors.New(model.FlagDisabledErrorCode), - code: connect.CodeNotFound, + name: "boolean", + run: func(eval *mock.MockIEvaluator, s *FlagEvaluationServiceV2) (fallbackResult, error) { + eval.EXPECT().ResolveBooleanValue(gomock.Any(), gomock.Any(), "flag", gomock.Any()).Return( + false, "", model.FallbackReason, metadata, nil, + ) + got, err := s.ResolveBoolean(context.Background(), connect.NewRequest(&evalV2.ResolveBooleanRequest{ + FlagKey: "flag", Context: &structpb.Struct{}, + })) + return fallbackResult{got.Msg.Value, got.Msg.Variant, got.Msg.Reason, got.Msg.Metadata}, err + }, }, { - err: errors.New(model.GeneralErrorCode), - code: connect.CodeUnknown, - }, - } - - for _, test := range tests { - err := errFormat(test.err) - - var connectErr *connect.Error - ok := errors.As(err, &connectErr) - - if !ok { - t.Error("formatted error is not of type connect.Error") - } - - if connectErr.Code() != test.code { - t.Errorf("expected code %s, but got code %s for model error %s", test.code, connectErr.Code(), - test.err.Error()) - } - } -} - -func Test_mergeContexts(t *testing.T) { - type args struct { - headers http.Header - headerToContextKeyMappings map[string]string - clientContext map[string]any - configContext map[string]any - } - - tests := []struct { - name string - args args - want map[string]any - }{ - { - name: "merge contexts with no headers, with no header-context mappings", - args: args{ - clientContext: map[string]any{"k1": "v1", "k2": "v2"}, - configContext: map[string]any{"k2": "v22", "k3": "v3"}, - headers: http.Header{}, - headerToContextKeyMappings: map[string]string{}, + name: "string", + run: func(eval *mock.MockIEvaluator, s *FlagEvaluationServiceV2) (fallbackResult, error) { + eval.EXPECT().ResolveStringValue(gomock.Any(), gomock.Any(), "flag", gomock.Any()).Return( + "", "", model.FallbackReason, metadata, nil, + ) + got, err := s.ResolveString(context.Background(), connect.NewRequest(&evalV2.ResolveStringRequest{ + FlagKey: "flag", Context: &structpb.Struct{}, + })) + return fallbackResult{got.Msg.Value, got.Msg.Variant, got.Msg.Reason, got.Msg.Metadata}, err }, - // static context should "win" - want: map[string]any{"k1": "v1", "k2": "v22", "k3": "v3"}, }, { - name: "merge contexts with headers, with no header-context mappings", - args: args{ - clientContext: map[string]any{"k1": "v1", "k2": "v2"}, - configContext: map[string]any{"k2": "v22", "k3": "v3"}, - headers: http.Header{"X-key": []string{"value"}, "X-token": []string{"token"}}, - headerToContextKeyMappings: map[string]string{}, + name: "int", + run: func(eval *mock.MockIEvaluator, s *FlagEvaluationServiceV2) (fallbackResult, error) { + eval.EXPECT().ResolveIntValue(gomock.Any(), gomock.Any(), "flag", gomock.Any()).Return( + int64(0), "", model.FallbackReason, metadata, nil, + ) + got, err := s.ResolveInt(context.Background(), connect.NewRequest(&evalV2.ResolveIntRequest{ + FlagKey: "flag", Context: &structpb.Struct{}, + })) + return fallbackResult{got.Msg.Value, got.Msg.Variant, got.Msg.Reason, got.Msg.Metadata}, err }, - // static context should "win" - want: map[string]any{"k1": "v1", "k2": "v22", "k3": "v3"}, }, { - name: "merge contexts with no headers, with header-context mappings", - args: args{ - clientContext: map[string]any{"k1": "v1", "k2": "v2"}, - configContext: map[string]any{"k2": "v22", "k3": "v3"}, - headers: http.Header{}, - headerToContextKeyMappings: map[string]string{"X-key": "k2"}, + name: "float", + run: func(eval *mock.MockIEvaluator, s *FlagEvaluationServiceV2) (fallbackResult, error) { + eval.EXPECT().ResolveFloatValue(gomock.Any(), gomock.Any(), "flag", gomock.Any()).Return( + float64(0), "", model.FallbackReason, metadata, nil, + ) + got, err := s.ResolveFloat(context.Background(), connect.NewRequest(&evalV2.ResolveFloatRequest{ + FlagKey: "flag", Context: &structpb.Struct{}, + })) + return fallbackResult{got.Msg.Value, got.Msg.Variant, got.Msg.Reason, got.Msg.Metadata}, err }, - // static context should "win" - want: map[string]any{"k1": "v1", "k2": "v22", "k3": "v3"}, }, { - name: "merge contexts with headers, with header-context mappings", - args: args{ - clientContext: map[string]any{"k1": "v1", "k2": "v2"}, - configContext: map[string]any{"k2": "v22", "k3": "v3"}, - headers: http.Header{"X-key": []string{"value"}, "X-token": []string{"token"}}, - headerToContextKeyMappings: map[string]string{"X-key": "k2"}, + name: "object", + run: func(eval *mock.MockIEvaluator, s *FlagEvaluationServiceV2) (fallbackResult, error) { + eval.EXPECT().ResolveObjectValue(gomock.Any(), gomock.Any(), "flag", gomock.Any()).Return( + nil, "", model.FallbackReason, metadata, nil, + ) + got, err := s.ResolveObject(context.Background(), connect.NewRequest(&evalV2.ResolveObjectRequest{ + FlagKey: "flag", Context: &structpb.Struct{}, + })) + return fallbackResult{got.Msg.Value, got.Msg.Variant, got.Msg.Reason, got.Msg.Metadata}, err }, - // header context should "win" - want: map[string]any{"k1": "v1", "k2": "value", "k3": "v3"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := mergeContexts(tt.args.clientContext, tt.args.configContext, tt.args.headers, tt.args.headerToContextKeyMappings) + ctrl := gomock.NewController(t) + eval := mock.NewMockIEvaluator(ctrl) + metrics, exp := getMetricReader() + s := NewFlagEvaluationServiceV2( + logger.NewLogger(nil, false), eval, &eventingConfiguration{}, + metrics, nil, nil, 0, + ) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("\ngot: %+v\nwant: %+v", got, tt.want) - } + result, err := tt.run(eval, s) + + require.NoError(t, err) + require.Nil(t, result.value, "value should be nil so the provider falls back to the code default") + require.Nil(t, result.variant, "variant should be nil") + require.Equal(t, model.DefaultReason, result.reason) + require.Equal(t, responseStruct, result.metadata) + + var data metricdata.ResourceMetrics + require.NoError(t, exp.Collect(context.TODO(), &data)) + require.Equal(t, 1, len(data.ScopeMetrics)) }) } } diff --git a/flagd/pkg/service/flag-evaluation/ofrep/handler.go b/flagd/pkg/service/flag-evaluation/ofrep/handler.go index cbc8d435b..ac3f2ddf8 100644 --- a/flagd/pkg/service/flag-evaluation/ofrep/handler.go +++ b/flagd/pkg/service/flag-evaluation/ofrep/handler.go @@ -3,6 +3,7 @@ package ofrep import ( "context" "encoding/json" + "errors" "fmt" "net/http" @@ -14,6 +15,7 @@ import ( "github.com/open-feature/flagd/core/pkg/store" "github.com/open-feature/flagd/core/pkg/telemetry" "github.com/open-feature/flagd/flagd/pkg/service" + evalservice "github.com/open-feature/flagd/flagd/pkg/service/flag-evaluation" metricsmw "github.com/open-feature/flagd/flagd/pkg/service/middleware/metrics" "github.com/rs/xid" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" @@ -72,6 +74,7 @@ func NewOfrepHandler( return otelhttp.NewHandler(router, "flagd.ofrep") } + func (h *handler) HandleFlagEvaluation(w http.ResponseWriter, r *http.Request) { requestID := xid.New().String() defer h.Logger.ClearFields(requestID) @@ -88,8 +91,9 @@ func (h *handler) HandleFlagEvaluation(w http.ResponseWriter, r *http.Request) { flagKey := vars[key] request, err := extractOfrepRequest(r) if err != nil { - h.writeJSONToResponse(http.StatusBadRequest, ofrep.ContextErrorResponseFrom(flagKey), w) - return + if h.handleExtractionError(w, err, ofrep.ContextErrorResponseFrom(flagKey)) { + return + } } evaluationContext := flagdContext(h.Logger, requestID, request, h.contextValues, r.Header, h.headerToContextKeyMappings) selectorExpression := r.Header.Get(service.FLAGD_SELECTOR_HEADER) @@ -111,8 +115,9 @@ func (h *handler) HandleBulkEvaluation(w http.ResponseWriter, r *http.Request) { request, err := extractOfrepRequest(r) if err != nil { - h.writeJSONToResponse(http.StatusBadRequest, ofrep.BulkEvaluationContextError(), w) - return + if h.handleExtractionError(w, err, ofrep.BulkEvaluationContextError()) { + return + } } evaluationContext := flagdContext(h.Logger, requestID, request, h.contextValues, r.Header, h.headerToContextKeyMappings) @@ -150,39 +155,48 @@ func (h *handler) writeJSONToResponse(status int, payload interface{}, w http.Re } } +// handleExtractionError checks for errors from extractOfrepRequest and writes an appropriate response. +// It returns true if an error was handled. +func (h *handler) handleExtractionError(w http.ResponseWriter, err error, errorPayload any) bool { + if err == nil { + return false + } + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + h.writeJSONToResponse(http.StatusRequestEntityTooLarge, + ofrep.InternalError{ErrorDetails: "request body too large"}, w) + return true + } + h.writeJSONToResponse(http.StatusBadRequest, errorPayload, w) + return true +} + func extractOfrepRequest(req *http.Request) (ofrep.Request, error) { request := ofrep.Request{} err := json.NewDecoder(req.Body).Decode(&request) - if err != nil && err.Error() != "EOF" { - return request, fmt.Errorf("decode error: %w", err) + if err != nil { + // Propagate MaxBytesError so callers can return 413. + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + return request, err + } + if err.Error() != "EOF" { + return request, fmt.Errorf("decode error: %w", err) + } } return request, nil } -// flagdContext returns combined context values from headers, static context (from cli) and request context. -// highest priority > header-context-from-cli > static-context-from-cli > request-context > lowest priority func flagdContext( log *logger.Logger, requestID string, request ofrep.Request, staticContextValues map[string]any, headers http.Header, headerToContextKeyMappings map[string]string, ) map[string]any { context := make(map[string]any) if res, ok := request.Context.(map[string]any); ok { - for k, v := range res { - context[k] = v - } + context = res } else { log.WarnWithID(requestID, "provided context does not comply with flagd, continuing ignoring the context") } - for k, v := range staticContextValues { - context[k] = v - } - - for header, contextKey := range headerToContextKeyMappings { - if values, ok := headers[header]; ok { - context[contextKey] = values[0] - } - } - - return context + return evalservice.MergeContextsAndHeaders(context, staticContextValues, headers, headerToContextKeyMappings) } diff --git a/flagd/pkg/service/flag-evaluation/ofrep/handler_test.go b/flagd/pkg/service/flag-evaluation/ofrep/handler_test.go index 2ef114d41..3ae4d4635 100644 --- a/flagd/pkg/service/flag-evaluation/ofrep/handler_test.go +++ b/flagd/pkg/service/flag-evaluation/ofrep/handler_test.go @@ -109,6 +109,22 @@ func Test_handler_HandleFlagEvaluation(t *testing.T) { expectedStatus: http.StatusBadRequest, expectedResponseType: ofrep.EvaluationError{}, }, + { + name: "code default - flag without defaultVariant", + method: http.MethodPost, + path: "/ofrep/v1/evaluate/flags/featureNoDefault", + input: bytes.NewReader([]byte{}), + mockAnyResponse: &evaluator.AnyValue{ + Value: false, // code default (no defaultVariant) + Variant: "", + Reason: model.FallbackReason, + FlagKey: "featureNoDefault", + Metadata: nil, + Error: nil, + }, + expectedStatus: http.StatusOK, + expectedResponseType: ofrep.EvaluationSuccess{}, + }, } for _, test := range tests { @@ -202,6 +218,30 @@ func Test_handler_HandleBulkEvaluation(t *testing.T) { input: bytes.NewReader([]byte("{some invalid context}")), expectedStatus: http.StatusBadRequest, }, + { + name: "bulk evaluation with code defaults", + method: http.MethodPost, + input: bytes.NewReader([]byte{}), + mockAnyResponse: []evaluator.AnyValue{ + { + Value: true, + Variant: "on", + Reason: model.StaticReason, + FlagKey: "featureWithDefault", + Metadata: nil, + Error: nil, + }, + { + Value: false, // code default (no defaultVariant) + Variant: "", + Reason: model.FallbackReason, + FlagKey: "featureNoDefault", + Metadata: map[string]interface{}{}, + Error: nil, + }, + }, + expectedStatus: http.StatusOK, + }, } for _, test := range tests { @@ -290,3 +330,47 @@ func TestWriteJSONResponse(t *testing.T) { }) } } +func TestFlagdContextInvalidContextType(t *testing.T) { + log := logger.NewLogger(nil, false) + + result := flagdContext( + log, + "test-request-id", + ofrep.Request{Context: "not a map"}, // invalid: string instead of map + map[string]any{"staticKey": "staticValue"}, + http.Header{}, + map[string]string{}, + ) + + if val, exists := result["staticKey"]; !exists || val != "staticValue" { + t.Errorf("expected static context to be included even with invalid request context") + } +} + +func TestFlagdContextDelegatesContextMerging(t *testing.T) { + log := logger.NewLogger(nil, false) + + h := http.Header{} + h.Set("X-User-Tier", "premium") + + result := flagdContext( + log, + "test-request-id", + ofrep.Request{Context: map[string]any{"requestKey": "requestValue"}}, + map[string]any{"staticKey": "staticValue"}, + h, + map[string]string{"X-User-Tier": "userTier"}, + ) + + expected := map[string]any{ + "requestKey": "requestValue", + "staticKey": "staticValue", + "userTier": "premium", + } + + for k, v := range expected { + if result[k] != v { + t.Errorf("expected key '%s' to have value '%s', but got '%v'", k, v, result[k]) + } + } +} diff --git a/flagd/pkg/service/flag-evaluation/ofrep/ofrep_service.go b/flagd/pkg/service/flag-evaluation/ofrep/ofrep_service.go index bc5689ee4..6bd1346fd 100644 --- a/flagd/pkg/service/flag-evaluation/ofrep/ofrep_service.go +++ b/flagd/pkg/service/flag-evaluation/ofrep/ofrep_service.go @@ -10,7 +10,7 @@ import ( "github.com/open-feature/flagd/core/pkg/evaluator" "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/telemetry" - "github.com/rs/cors" + corsmw "github.com/open-feature/flagd/flagd/pkg/service/middleware/cors" "golang.org/x/sync/errgroup" ) @@ -20,10 +20,12 @@ type IOfrepService interface { } type SvcConfiguration struct { - Logger *logger.Logger - Port uint16 - ServiceName string - MetricsRecorder telemetry.IMetricsRecorder + Logger *logger.Logger + Port uint16 + ServiceName string + MetricsRecorder telemetry.IMetricsRecorder + MaxRequestBodyBytes int64 + MaxRequestHeaderBytes int64 } type Service struct { @@ -35,24 +37,26 @@ type Service struct { func NewOfrepService( evaluator evaluator.IEvaluator, origins []string, cfg SvcConfiguration, contextValues map[string]any, headerToContextKeyMappings map[string]string, ) (*Service, error) { - corsMW := cors.New(cors.Options{ - AllowedOrigins: origins, - AllowedMethods: []string{http.MethodPost}, - }) + corsMiddleware := corsmw.New(origins) - h := corsMW.Handler(NewOfrepHandler( + var h http.Handler = NewOfrepHandler( cfg.Logger, evaluator, contextValues, headerToContextKeyMappings, cfg.MetricsRecorder, cfg.ServiceName, - )) + ) + if cfg.MaxRequestBodyBytes > 0 { + h = http.MaxBytesHandler(h, cfg.MaxRequestBodyBytes) + } + h = corsMiddleware.Handler(h) server := http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), Handler: h, ReadHeaderTimeout: 3 * time.Second, + MaxHeaderBytes: int(cfg.MaxRequestHeaderBytes), } return &Service{ diff --git a/flagd/pkg/service/flag-evaluation/ofrep/ofrep_service_test.go b/flagd/pkg/service/flag-evaluation/ofrep/ofrep_service_test.go index ee30780f9..90682a4c4 100644 --- a/flagd/pkg/service/flag-evaluation/ofrep/ofrep_service_test.go +++ b/flagd/pkg/service/flag-evaluation/ofrep/ofrep_service_test.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "net/http" + "strings" "testing" "time" @@ -17,7 +18,12 @@ import ( "golang.org/x/sync/errgroup" ) -func Test_OfrepServiceStartStop(t *testing.T) { +const ( + testServiceName = "test-service" + errCreateOfrepService = "error creating the ofrep service: %v" +) + +func TestOfrepServiceStartStop(t *testing.T) { port := 18282 eval := mock.NewMockIEvaluator(gomock.NewController(t)) @@ -25,15 +31,15 @@ func Test_OfrepServiceStartStop(t *testing.T) { Return([]evaluator.AnyValue{}, model.Metadata{}, nil) cfg := SvcConfiguration{ - Logger: logger.NewLogger(nil, false), - Port: uint16(port), - ServiceName: "test-service", + Logger: logger.NewLogger(nil, false), + Port: uint16(port), + ServiceName: testServiceName, MetricsRecorder: &telemetry.NoopMetricsRecorder{}, } service, err := NewOfrepService(eval, []string{"*"}, cfg, nil, nil) if err != nil { - t.Fatalf("error creating the ofrep service: %v", err) + t.Fatalf(errCreateOfrepService, err) } ctx, cancelFunc := context.WithCancel(context.Background()) @@ -69,6 +75,10 @@ func Test_OfrepServiceStartStop(t *testing.T) { } func tryResponse(method string, uri string, payload []byte) (int, error) { + return tryResponseWithHeaders(method, uri, payload, nil) +} + +func tryResponseWithHeaders(method string, uri string, payload []byte, headers map[string]string) (int, error) { client := http.Client{ Timeout: 3 * time.Second, } @@ -78,9 +88,128 @@ func tryResponse(method string, uri string, payload []byte) (int, error) { return 0, fmt.Errorf("error forming the request: %w", err) } + for k, v := range headers { + request.Header.Set(k, v) + } + rsp, err := client.Do(request) if err != nil { return 0, fmt.Errorf("error from the request: %w", err) } return rsp.StatusCode, nil } + +func TestOfrepServiceCORSPreflightAllowsFlagdSelector(t *testing.T) { + svc, port := startOfrepService(t, SvcConfiguration{ + Logger: logger.NewLogger(nil, false), + Port: 18285, + ServiceName: testServiceName, + MetricsRecorder: &telemetry.NoopMetricsRecorder{}, + }) + _ = svc + + path := fmt.Sprintf("http://localhost:%d/ofrep/v1/evaluate/flags/myFlag", port) + + // Simulate a CORS preflight request from a browser that wants to send the Flagd-Selector header + client := http.Client{Timeout: 3 * time.Second} + req, err := http.NewRequest(http.MethodOptions, path, nil) + if err != nil { + t.Fatalf("error creating request: %v", err) + } + req.Header.Set("Origin", "http://example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Headers", "Flagd-Selector") + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("preflight request failed: %v", err) + } + + // rs/cors should echo back the allowed headers when the preflight is accepted + allowedHeaders := resp.Header.Get("Access-Control-Allow-Headers") + if allowedHeaders == "" { + t.Fatal("expected Access-Control-Allow-Headers to be set, got empty string โ€” preflight was likely rejected") + } + + allowedOrigin := resp.Header.Get("Access-Control-Allow-Origin") + if allowedOrigin == "" { + t.Fatal("expected Access-Control-Allow-Origin to be set, got empty string") + } +} + +func TestOfrepServiceRequestBodySizeLimit(t *testing.T) { + svc, port := startOfrepService(t, SvcConfiguration{ + Logger: logger.NewLogger(nil, false), + Port: 18283, + ServiceName: testServiceName, + MetricsRecorder: &telemetry.NoopMetricsRecorder{}, + MaxRequestBodyBytes: 10, // allow only 10 bytes + }) + _ = svc // kept alive by deferred cleanup + + path := fmt.Sprintf("http://localhost:%d/ofrep/v1/evaluate/flags/myFlag", port) + // Valid JSON whose size exceeds the 10-byte limit, so MaxBytesReader triggers mid-parse. + largeBody := []byte(`{"context":{"k":"` + strings.Repeat("a", 100) + `"}}`) + + status, err := tryResponse(http.MethodPost, path, largeBody) + if err != nil { + t.Fatalf("unexpected request error: %v", err) + } + + if status != http.StatusRequestEntityTooLarge { + t.Errorf("expected HTTP 413, got %d", status) + } +} + +func TestOfrepServiceRequestHeaderSizeLimit(t *testing.T) { + svc, port := startOfrepService(t, SvcConfiguration{ + Logger: logger.NewLogger(nil, false), + Port: 18284, + ServiceName: testServiceName, + MetricsRecorder: &telemetry.NoopMetricsRecorder{}, + MaxRequestHeaderBytes: 100, // 10000-byte test header value easily exceeds 100 + slop + }) + _ = svc // kept alive by deferred cleanup + + path := fmt.Sprintf("http://localhost:%d/ofrep/v1/evaluate/flags/myFlag", port) + // The header value must exceed MaxHeaderBytes + Go's ~4096-byte read buffer slop. + largeHeaderValue := string(bytes.Repeat([]byte("a"), 10000)) + + status, err := tryResponseWithHeaders(http.MethodPost, path, []byte{}, map[string]string{ + "X-Large-Header": largeHeaderValue, + }) + if err != nil { + t.Fatalf("unexpected request error: %v", err) + } + + if status != http.StatusRequestHeaderFieldsTooLarge { + t.Errorf("expected HTTP 431, got %d", status) + } +} + +// startOfrepService creates, starts and returns an OFREP service with the given config. +// It registers cleanup to stop the service when the test finishes. +func startOfrepService(t *testing.T, cfg SvcConfiguration) (*Service, uint16) { + t.Helper() + + eval := mock.NewMockIEvaluator(gomock.NewController(t)) + service, err := NewOfrepService(eval, []string{"*"}, cfg, nil, nil) + if err != nil { + t.Fatalf(errCreateOfrepService, err) + } + + ctx, cancelFunc := context.WithCancel(context.Background()) + group, gCtx := errgroup.WithContext(ctx) + group.Go(func() error { + return service.Start(gCtx) + }) + t.Cleanup(func() { + cancelFunc() + _ = group.Wait() + }) + + // wait for server startup + <-time.After(2 * time.Second) + + return service, cfg.Port +} diff --git a/flagd/pkg/service/flag-sync/handler.go b/flagd/pkg/service/flag-sync/handler.go index 51aecc94f..559a33f44 100644 --- a/flagd/pkg/service/flag-sync/handler.go +++ b/flagd/pkg/service/flag-sync/handler.go @@ -9,6 +9,8 @@ import ( "time" "github.com/open-feature/flagd/core/pkg/model" + "github.com/open-feature/flagd/core/pkg/telemetry" + "go.opentelemetry.io/otel/attribute" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -30,11 +32,36 @@ type syncHandler struct { contextValues map[string]any deadline time.Duration disableSyncMetadata bool + metricsRecorder telemetry.IMetricsRecorder } func (s syncHandler) SyncFlags(req *syncv1.SyncFlagsRequest, server syncv1grpc.FlagSyncService_SyncFlagsServer) error { - watcher := make(chan store.FlagQueryResult, 1) + startTime := time.Now() selectorExpression := s.getSelectorExpression(server.Context(), req) + + // Build metric attributes + attrs := []attribute.KeyValue{} + if selectorExpression != "" { + attrs = append(attrs, attribute.String("selector", selectorExpression)) + } + if req.GetProviderId() != "" { + attrs = append(attrs, attribute.String("provider_id", req.GetProviderId())) + } + + // Record stream start + s.metricsRecorder.SyncStreamStart(server.Context(), attrs) + + // Track exit reason for duration metric + var exitReason string + defer func() { + duration := time.Since(startTime) + reasonAttrs := append([]attribute.KeyValue{}, attrs...) + reasonAttrs = append(reasonAttrs, attribute.String("reason", exitReason)) + s.metricsRecorder.SyncStreamEnd(server.Context(), attrs) + s.metricsRecorder.SyncStreamDuration(server.Context(), duration, reasonAttrs) + }() + + watcher := make(chan store.FlagQueryResult, 1) selector := store.NewSelector(selectorExpression) ctx := server.Context() @@ -42,6 +69,7 @@ func (s syncHandler) SyncFlags(req *syncv1.SyncFlagsRequest, server syncv1grpc.F maps.Copy(syncContextMap, s.contextValues) syncContext, err := structpb.NewStruct(syncContextMap) if err != nil { + exitReason = "error" return status.Error(codes.DataLoss, "error constructing sync context") } @@ -58,35 +86,41 @@ func (s syncHandler) SyncFlags(req *syncv1.SyncFlagsRequest, server syncv1grpc.F for { select { case payload := <-watcher: - if err != nil { - s.log.Error(fmt.Sprintf("error from struct creation: %v", err)) - return fmt.Errorf("error constructing metadata response") - } - - flagMap := s.convertMap(payload.Flags) - - flags, err := json.Marshal(flagMap) + flags, err := s.generateResponse(payload.Flags) if err != nil { s.log.Error(fmt.Sprintf("error retrieving flags from store: %v", err)) + exitReason = "error" return status.Error(codes.DataLoss, "error marshalling flags") } err = server.Send(&syncv1.SyncFlagsResponse{FlagConfiguration: string(flags), SyncContext: syncContext}) if err != nil { s.log.Debug(fmt.Sprintf("error sending stream response: %v", err)) + exitReason = "client_disconnect" return fmt.Errorf("error sending stream response: %w", err) } case <-ctx.Done(): if errors.Is(ctx.Err(), context.DeadlineExceeded) { s.log.Debug(fmt.Sprintf("server-side deadline of %s exceeded, exiting stream request with grpc error code 4", s.deadline.String())) + exitReason = "deadline_exceeded" return status.Error(codes.DeadlineExceeded, "stream closed due to server-side timeout") } s.log.Debug("context complete and exiting stream request") + exitReason = "normal_close" return nil } } } +func (s syncHandler) generateResponse(payload []model.Flag) ([]byte, error) { + flagConfig := map[string]interface{}{ + "flags": s.convertMap(payload), + } + + flags, err := json.Marshal(flagConfig) + return flags, err +} + // getSelectorExpression extracts the selector expression from the request. // It first checks the Flagd-Selector header (metadata), then falls back to the request body selector. // @@ -139,7 +173,7 @@ func (s syncHandler) FetchAllFlags(ctx context.Context, req *syncv1.FetchAllFlag return nil, status.Error(codes.Internal, "error retrieving flags from store") } - flagsString, err := json.Marshal(s.convertMap(flags)) + flagsString, err := s.generateResponse(flags) if err != nil { return nil, err @@ -152,6 +186,7 @@ func (s syncHandler) FetchAllFlags(ctx context.Context, req *syncv1.FetchAllFlag // Deprecated - GetMetadata is deprecated and will be removed in a future release. // Use the sync_context field in syncv1.SyncFlagsResponse, providing same info. +//nolint:staticcheck // SA1019 temporarily suppress deprecation warning func (s syncHandler) GetMetadata(_ context.Context, _ *syncv1.GetMetadataRequest) ( *syncv1.GetMetadataResponse, error, ) { diff --git a/flagd/pkg/service/flag-sync/handler_test.go b/flagd/pkg/service/flag-sync/handler_test.go index 6bbd5d04d..7621be845 100644 --- a/flagd/pkg/service/flag-sync/handler_test.go +++ b/flagd/pkg/service/flag-sync/handler_test.go @@ -12,6 +12,7 @@ import ( "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/model" "github.com/open-feature/flagd/core/pkg/store" + "github.com/open-feature/flagd/core/pkg/telemetry" flagdService "github.com/open-feature/flagd/flagd/pkg/service" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -64,6 +65,7 @@ func TestSyncHandler_SyncFlags(t *testing.T) { contextValues: tt.contextValues, log: logger.NewLogger(nil, false), disableSyncMetadata: disableSyncMetadata, + metricsRecorder: &telemetry.NoopMetricsRecorder{}, } // Test getting metadata from `GetMetadata` (deprecated) @@ -201,14 +203,15 @@ func TestSyncHandler_SelectorLocationPrecedence(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { flagStore, err := store.NewStore(logger.NewLogger(nil, false), []string{}) - flagStore.Update("header-source", headerFlags, nil) - flagStore.Update("body-source", bodyFlags, nil) + flagStore.Update("header-source", headerFlags, nil, false) + flagStore.Update("body-source", bodyFlags, nil, false) require.NoError(t, err) handler := syncHandler{ - store: flagStore, - log: logger.NewLogger(nil, false), - contextValues: map[string]any{}, + store: flagStore, + log: logger.NewLogger(nil, false), + contextValues: map[string]any{}, + metricsRecorder: &telemetry.NoopMetricsRecorder{}, } // Create context with or without header metadata diff --git a/flagd/pkg/service/flag-sync/sync_service.go b/flagd/pkg/service/flag-sync/sync_service.go index 22f6c0ce6..49d1e8e06 100644 --- a/flagd/pkg/service/flag-sync/sync_service.go +++ b/flagd/pkg/service/flag-sync/sync_service.go @@ -11,6 +11,8 @@ import ( "buf.build/gen/go/open-feature/flagd/grpc/go/flagd/sync/v1/syncv1grpc" "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/store" + "github.com/open-feature/flagd/core/pkg/telemetry" + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "golang.org/x/sync/errgroup" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -35,6 +37,7 @@ type SvcConfigurations struct { SocketPath string StreamDeadline time.Duration DisableSyncMetadata bool + MetricsRecorder telemetry.IMetricsRecorder } type Service struct { @@ -65,9 +68,6 @@ func loadTLSCredentials(certPath string, keyPath string) (credentials.TransportC func NewSyncService(cfg SvcConfigurations) (*Service, error) { var err error l := cfg.Logger - if err != nil { - return nil, fmt.Errorf("error initializing multiplexer: %w", err) - } var server *grpc.Server if cfg.CertPath != "" && cfg.KeyPath != "" { @@ -75,9 +75,19 @@ func NewSyncService(cfg SvcConfigurations) (*Service, error) { if err != nil { return nil, fmt.Errorf("failed to load TLS cert and key: %w", err) } - server = grpc.NewServer(grpc.Creds(tlsCredentials)) + server = grpc.NewServer( + grpc.Creds(tlsCredentials), + grpc.StatsHandler(otelgrpc.NewServerHandler()), + ) } else { - server = grpc.NewServer() + server = grpc.NewServer( + grpc.StatsHandler(otelgrpc.NewServerHandler()), + ) + } + + metricsRecorder := cfg.MetricsRecorder + if metricsRecorder == nil { + metricsRecorder = &telemetry.NoopMetricsRecorder{} } syncv1grpc.RegisterFlagSyncServiceServer(server, &syncHandler{ @@ -86,6 +96,7 @@ func NewSyncService(cfg SvcConfigurations) (*Service, error) { contextValues: cfg.ContextValues, deadline: cfg.StreamDeadline, disableSyncMetadata: cfg.DisableSyncMetadata, + metricsRecorder: metricsRecorder, }) var lis net.Listener diff --git a/flagd/pkg/service/flag-sync/sync_service_test.go b/flagd/pkg/service/flag-sync/sync_service_test.go index efffb786e..9db2b007e 100644 --- a/flagd/pkg/service/flag-sync/sync_service_test.go +++ b/flagd/pkg/service/flag-sync/sync_service_test.go @@ -111,7 +111,7 @@ func TestSyncServiceEndToEnd(t *testing.T) { flagStore.Update(testSource1, testSource1Flags, model.Metadata{ "keyDuped": "value", "keyA": "valueA", - }) + }, false) select { case <-dataReceived: diff --git a/flagd/pkg/service/flag-sync/util_test.go b/flagd/pkg/service/flag-sync/util_test.go index d50199d2a..db4ac56e8 100644 --- a/flagd/pkg/service/flag-sync/util_test.go +++ b/flagd/pkg/service/flag-sync/util_test.go @@ -50,12 +50,12 @@ func getSimpleFlagStore(t testing.TB) (store.IStore, []string) { flagStore.Update(testSource1, testSource1Flags, model.Metadata{ "keyDuped": "value", "keyA": "valueA", - }) + }, false) flagStore.Update(testSource2, testSource2Flags, model.Metadata{ "keyDuped": "value", "keyB": "valueB", - }) + }, false) return flagStore, sources } diff --git a/flagd/profile.Dockerfile b/flagd/profile.Dockerfile index b414126bf..8c9f97687 100644 --- a/flagd/profile.Dockerfile +++ b/flagd/profile.Dockerfile @@ -1,6 +1,6 @@ # Dockerfile with pprof profiler # Build the manager binary -FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder WORKDIR /src diff --git a/mkdocs.yml b/mkdocs.yml index 641f133f8..97707cbab 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,7 +78,11 @@ nav: - 'Concepts': - 'Feature Flagging': 'concepts/feature-flagging.md' - 'Syncs': 'concepts/syncs.md' + - 'Selectors': 'concepts/selectors.md' + - 'Metadata': 'concepts/metadata.md' - 'Architecture': 'architecture.md' + - 'Guides': + - 'Migrating to Flag Sets': 'guides/migrating-to-flag-sets.md' - 'OpenFeature Providers': - 'providers/index.md' - 'Go': 'providers/go.md' @@ -90,11 +94,13 @@ nav: - 'Rust': 'providers/rust.md' - 'Web': 'providers/web.md' - 'Reference': + - 'Cheat sheet': 'reference/cheat-sheet.md' - 'CLI': - 'Overview': 'reference/flagd-cli/flagd.md' - 'Start': 'reference/flagd-cli/flagd_start.md' - 'Version': 'reference/flagd-cli/flagd_version.md' - 'Sync Configuration': 'reference/sync-configuration.md' + - 'Selector Syntax': 'reference/selector-syntax.md' - 'gRPC sync service': 'reference/grpc-sync-service.md' - 'OFREP service': 'reference/flagd-ofrep.md' - 'Flag Definitions': diff --git a/playground-app/package-lock.json b/playground-app/package-lock.json index 8000c9cb8..0523ee279 100644 --- a/playground-app/package-lock.json +++ b/playground-app/package-lock.json @@ -9,12 +9,14 @@ "version": "0.0.0", "dependencies": { "@monaco-editor/react": "^4.7.0-rc.0", - "@openfeature/flagd-core": "^1.0.0", + "@openfeature/flagd-core": "^3.0.0", + "js-yaml": "^4.1.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-use": "^17.6.0" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/react": "^19.0.3", "@types/react-dom": "^19.0.2", "@typescript-eslint/eslint-plugin": "^8.19.1", @@ -24,7 +26,7 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.16", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.4.2" } }, "node_modules/@ampproject/remapping": { @@ -41,14 +43,15 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -183,19 +186,21 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -210,25 +215,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.26.3" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -268,25 +275,24 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -311,26 +317,28 @@ } }, "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" @@ -340,13 +348,14 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -356,13 +365,14 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -372,13 +382,14 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -388,13 +399,14 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -404,13 +416,14 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -420,13 +433,14 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -436,13 +450,14 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -452,13 +467,14 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -468,13 +484,14 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -484,13 +501,14 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -500,13 +518,14 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -516,13 +535,14 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -532,13 +552,14 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -548,13 +569,14 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -564,13 +586,14 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -580,13 +603,14 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -596,13 +620,14 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -612,13 +637,14 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -628,13 +654,14 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -644,13 +671,14 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -659,14 +687,32 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -676,13 +722,14 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -692,13 +739,14 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -708,13 +756,14 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -724,10 +773,11 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -751,34 +801,37 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", - "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.5", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -786,11 +839,25 @@ "node": "*" } }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/core": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz", - "integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -799,19 +866,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", - "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, + "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -821,27 +889,12 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -852,6 +905,7 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -859,17 +913,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -878,29 +927,36 @@ } }, "node_modules/@eslint/js": { - "version": "9.17.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", - "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", - "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.4.tgz", - "integrity": "sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, + "license": "Apache-2.0", "dependencies": { + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -956,10 +1012,11 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", - "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=18.18" }, @@ -1081,263 +1138,365 @@ "peer": true }, "node_modules/@openfeature/flagd-core": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@openfeature/flagd-core/-/flagd-core-1.0.0.tgz", - "integrity": "sha512-JoaiDfQHgD15shkD5i/I+bpssvqqIwu2dkXMgQ8PfG/keYITCvNFIbxyqPKn+nAX9DR0Zp0P+spJTXtyxLMikw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@openfeature/flagd-core/-/flagd-core-3.0.0.tgz", + "integrity": "sha512-K8QFN+7lMUTqHX1nK/1YiS0CR7NX9UyrfFu9MI1hFGzPtJKf0ohFad4q8buwEre0e3jIWBDbxKqFIRBrtr4qLw==", + "license": "Apache-2.0", "dependencies": { - "ajv": "^8.12.0", - "imurmurhash": "0.1.4", - "json-logic-engine": "4.0.2", - "object-hash": "3.0.0", - "semver": "7.5.3", - "tslib": "^2.3.0" + "imurmurhash": "^0.1.4", + "json-logic-engine": "^4.0.2", + "object-hash": "^3.0.0", + "semver": "^7.6.3" }, "peerDependencies": { "@openfeature/core": ">=1.6.0" } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.30.1.tgz", - "integrity": "sha512-pSWY+EVt3rJ9fQ3IqlrEUtXh3cGqGtPDH1FQlNZehO2yYxCHEX1SPsz1M//NXwYfbTlcKr9WObLnJX9FsS9K1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.30.1.tgz", - "integrity": "sha512-/NA2qXxE3D/BRjOJM8wQblmArQq1YoBVJjrjoTSBS09jgUisq7bqxNHJ8kjCHeV21W/9WDGwJEWSN0KQ2mtD/w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.30.1.tgz", - "integrity": "sha512-r7FQIXD7gB0WJ5mokTUgUWPl0eYIH0wnxqeSAhuIwvnnpjdVB8cRRClyKLQr7lgzjctkbp5KmswWszlwYln03Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.30.1.tgz", - "integrity": "sha512-x78BavIwSH6sqfP2xeI1hd1GpHL8J4W2BXcVM/5KYKoAD3nNsfitQhvWSw+TFtQTLZ9OmlF+FEInEHyubut2OA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.30.1.tgz", - "integrity": "sha512-HYTlUAjbO1z8ywxsDFWADfTRfTIIy/oUlfIDmlHYmjUP2QRDTzBuWXc9O4CXM+bo9qfiCclmHk1x4ogBjOUpUQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.30.1.tgz", - "integrity": "sha512-1MEdGqogQLccphhX5myCJqeGNYTNcmTyaic9S7CG3JhwuIByJ7J05vGbZxsizQthP1xpVx7kd3o31eOogfEirw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.30.1.tgz", - "integrity": "sha512-PaMRNBSqCx7K3Wc9QZkFx5+CX27WFpAMxJNiYGAXfmMIKC7jstlr32UhTgK6T07OtqR+wYlWm9IxzennjnvdJg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.30.1.tgz", - "integrity": "sha512-B8Rcyj9AV7ZlEFqvB5BubG5iO6ANDsRKlhIxySXcF1axXYUyqwBok+XZPgIYGBgs7LDXfWfifxhw0Ik57T0Yug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.30.1.tgz", - "integrity": "sha512-hqVyueGxAj3cBKrAI4aFHLV+h0Lv5VgWZs9CUGqr1z0fZtlADVV1YPOij6AhcK5An33EXaxnDLmJdQikcn5NEw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.30.1.tgz", - "integrity": "sha512-i4Ab2vnvS1AE1PyOIGp2kXni69gU2DAUVt6FSXeIqUCPIR3ZlheMW3oP2JkukDfu3PsexYRbOiJrY+yVNSk9oA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.30.1.tgz", - "integrity": "sha512-fARcF5g296snX0oLGkVxPmysetwUk2zmHcca+e9ObOovBR++9ZPOhqFUM61UUZ2EYpXVPN1redgqVoBB34nTpQ==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.30.1.tgz", - "integrity": "sha512-GLrZraoO3wVT4uFXh67ElpwQY0DIygxdv0BNW9Hkm3X34wu+BkqrDrkcsIapAY+N2ATEbvak0XQ9gxZtCIA5Rw==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.30.1.tgz", - "integrity": "sha512-0WKLaAUUHKBtll0wvOmh6yh3S0wSU9+yas923JIChfxOaaBarmb/lBKPF0w/+jTVozFnOXJeRGZ8NvOxvk/jcw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.30.1.tgz", - "integrity": "sha512-GWFs97Ruxo5Bt+cvVTQkOJ6TIx0xJDD/bMAOXWJg8TCSTEK8RnFeOeiFTxKniTc4vMIaWvCplMAFBt9miGxgkA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.30.1.tgz", - "integrity": "sha512-UtgGb7QGgXDIO+tqqJ5oZRGHsDLO8SlpE4MhqpY9Llpzi5rJMvrK6ZGhsRCST2abZdBqIBeXW6WPD5fGK5SDwg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.30.1.tgz", - "integrity": "sha512-V9U8Ey2UqmQsBT+xTOeMzPzwDzyXmnAoO4edZhL7INkwQcaW1Ckv3WJX3qrrp/VHaDkEWIBWhRwP47r8cdrOow==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.30.1.tgz", - "integrity": "sha512-WabtHWiPaFF47W3PkHnjbmWawnX/aE57K47ZDT1BXTS5GgrBUEpvOzq0FI0V/UYzQJgdb8XlhVNH8/fwV8xDjw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.30.1.tgz", - "integrity": "sha512-pxHAU+Zv39hLUTdQQHUVHf4P+0C47y/ZloorHpzs2SXMRqeAWmGghzAhfOlzFHHwjvgokdFAhC4V+6kC1lRRfw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.30.1.tgz", - "integrity": "sha512-D6qjsXGcvhTjv0kI4fU8tUuBDF/Ueee4SVX79VfNDXZa64TfCW1Slkb6Z7O1p7vflqZjcmOVdZlqf8gvJxc6og==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1385,21 +1544,30 @@ } }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" }, "node_modules/@types/js-cookie": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz", "integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/react": { "version": "19.0.3", @@ -1551,18 +1719,6 @@ "typescript": ">=4.8.4 <5.8.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.19.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.19.1.tgz", @@ -1640,10 +1796,11 @@ "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==" }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -1656,19 +1813,22 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { "type": "github", @@ -1693,8 +1853,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/balanced-match": { "version": "1.0.2", @@ -1703,10 +1862,11 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -1760,6 +1920,7 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -1822,7 +1983,8 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -1915,11 +2077,12 @@ } }, "node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -1927,31 +2090,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escalade": { @@ -1976,31 +2140,32 @@ } }, "node_modules/eslint": { - "version": "9.17.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", - "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.9.0", - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.17.0", - "@eslint/plugin-kit": "^0.2.3", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.1", + "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -2012,7 +2177,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2056,10 +2221,11 @@ } }, "node_modules/eslint-scope": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", - "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -2083,37 +2249,23 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2121,17 +2273,12 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2140,14 +2287,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2157,10 +2305,11 @@ } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2185,6 +2334,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -2247,7 +2397,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -2260,21 +2411,6 @@ "resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz", "integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==" }, - "node_modules/fast-uri": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.5.tgz", - "integrity": "sha512-5JnBCWpFlMo0a3ciDy/JckMzzv1U9coZrIhedq+HXxxUfDTAiS0LA8OKVao4G9BxmCVck/jtA5r3KAtRWEyD8Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ] - }, "node_modules/fastest-stable-stringify": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz", @@ -2343,10 +2479,11 @@ } }, "node_modules/flatted": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", - "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", - "dev": true + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", @@ -2354,6 +2491,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2422,10 +2560,11 @@ } }, "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, + "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2501,10 +2640,10 @@ "dev": true }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -2539,9 +2678,11 @@ } }, "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -2641,12 +2782,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2687,9 +2829,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -2697,6 +2839,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -2776,6 +2919,7 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, + "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -2808,10 +2952,11 @@ "dev": true }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -2820,9 +2965,9 @@ } }, "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -2838,8 +2983,9 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2861,6 +3007,7 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -2947,19 +3094,6 @@ "react-dom": "*" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -2970,6 +3104,7 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -2985,12 +3120,13 @@ } }, "node_modules/rollup": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.30.1.tgz", - "integrity": "sha512-mlJ4glW020fPuLi7DkM/lN97mYEZGWeqBnrljzN0gs7GLctqX3lNWxKQ7Gl712UAX+6fog/L3jh4gb7R6aVi3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -3000,25 +3136,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.30.1", - "@rollup/rollup-android-arm64": "4.30.1", - "@rollup/rollup-darwin-arm64": "4.30.1", - "@rollup/rollup-darwin-x64": "4.30.1", - "@rollup/rollup-freebsd-arm64": "4.30.1", - "@rollup/rollup-freebsd-x64": "4.30.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.30.1", - "@rollup/rollup-linux-arm-musleabihf": "4.30.1", - "@rollup/rollup-linux-arm64-gnu": "4.30.1", - "@rollup/rollup-linux-arm64-musl": "4.30.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.30.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.30.1", - "@rollup/rollup-linux-riscv64-gnu": "4.30.1", - "@rollup/rollup-linux-s390x-gnu": "4.30.1", - "@rollup/rollup-linux-x64-gnu": "4.30.1", - "@rollup/rollup-linux-x64-musl": "4.30.1", - "@rollup/rollup-win32-arm64-msvc": "4.30.1", - "@rollup/rollup-win32-ia32-msvc": "4.30.1", - "@rollup/rollup-win32-x64-msvc": "4.30.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -3070,12 +3212,10 @@ } }, "node_modules/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -3083,22 +3223,6 @@ "node": ">=10" } }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/set-harmonic-interval": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz", @@ -3141,6 +3265,7 @@ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -3195,6 +3320,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -3227,6 +3353,54 @@ "node": ">=10" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3326,19 +3500,24 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } }, "node_modules/vite": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz", - "integrity": "sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, + "license": "MIT", "dependencies": { - "esbuild": "^0.24.2", - "postcss": "^8.4.49", - "rollup": "^4.23.0" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -3401,6 +3580,37 @@ } } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/playground-app/package.json b/playground-app/package.json index 23f8f2744..61b7e6d94 100644 --- a/playground-app/package.json +++ b/playground-app/package.json @@ -11,12 +11,14 @@ }, "dependencies": { "@monaco-editor/react": "^4.7.0-rc.0", - "@openfeature/flagd-core": "^1.0.0", + "@openfeature/flagd-core": "^3.0.0", + "js-yaml": "^4.1.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-use": "^17.6.0" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/react": "^19.0.3", "@types/react-dom": "^19.0.2", "@typescript-eslint/eslint-plugin": "^8.19.1", @@ -26,6 +28,6 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.16", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.4.2" } } diff --git a/playground-app/src/App.tsx b/playground-app/src/App.tsx index 72d29cbfa..dd0bfb3a0 100644 --- a/playground-app/src/App.tsx +++ b/playground-app/src/App.tsx @@ -3,7 +3,7 @@ import { useMedia } from "react-use"; import { FlagdCore, MemoryStorage } from "@openfeature/flagd-core"; import { ScenarioName, scenarios } from "./scenarios"; import type { FlagValueType } from "@openfeature/core"; -import { getString, isValidJson } from "./utils"; +import { getString, isValidYaml, yamlToCompactJson, yamlToPrettyJson, jsonToYaml } from "./utils"; import { BeforeMount, Editor } from "@monaco-editor/react"; import { Observable } from "react-use/lib/useObservable"; @@ -11,6 +11,10 @@ declare global { var component$: Observable<{ ref: HTMLElement }>; } +const LANG_JSON = "json" as const; +const LANG_YAML = "yaml" as const; +type DefinitionLanguage = typeof LANG_JSON | typeof LANG_YAML; + // see: https://github.com/squidfunk/mkdocs-material/discussions/3429 const BODY_COLOR_SCHEME_ATTR = "data-md-color-scheme"; const PALETTE_SWITCH_SELECTOR = "[data-md-component=palette]"; @@ -44,11 +48,6 @@ const monacoBeforeMount: BeforeMount = (monaco) => { }); }; -function shortenJson(formattedString: string) { - const object = JSON.parse(formattedString); - return JSON.stringify(object); -}; - function formatJson(shortenedString: string) { const object = JSON.parse(shortenedString); return JSON.stringify(object, null, 2); @@ -64,6 +63,8 @@ function App() { const [returnType, setReturnType] = useState( scenarios[selectedTemplate].returnType ); + const [codeDefault, setCodeDefault] = useState(scenarios[selectedTemplate].codeDefault); + const [outputCodeDefault, setOutputCodeDefault] = useState(null); const [evaluationContext, setEvaluationContext] = useState( getString(scenarios[selectedTemplate].context) ); @@ -82,20 +83,43 @@ function App() { const [editorTheme, updateEditorTheme] = useState<"custom" | "custom-dark">( getPalette() ); + const [featureDefinitionLanguage, setFeatureDefinitionLanguage] = useState(LANG_JSON); + const [isCustomScenario, setIsCustomScenario] = useState(false); + + const handleLanguageSwitch = useCallback(() => { + try { + const newLang = featureDefinitionLanguage === LANG_JSON ? LANG_YAML : LANG_JSON; + if (newLang === LANG_YAML) { + setFeatureDefinition(jsonToYaml(featureDefinition)); + } else { + setFeatureDefinition(yamlToPrettyJson(featureDefinition)); + } + setFeatureDefinitionLanguage(newLang); + setIsCustomScenario(true); + const url = new URL(window.location.href); + url.searchParams.set('lang', newLang); + window.history.replaceState({}, '', url.href); + } catch (e) { + console.error("Failed to convert", e); + } + }, [featureDefinition, featureDefinitionLanguage]); const resetInputs = useCallback(() => { setOutput(""); setShowOutput(false); const template = scenarios[selectedTemplate]; setFeatureDefinition(template.flagDefinition); + setFeatureDefinitionLanguage(LANG_JSON); setFlagKey(template.flagKey); setReturnType(template.returnType); + setCodeDefault(template.codeDefault); setEvaluationContext(getString(template.context)); setDescription(template.description); setValidFeatureDefinition(true); setValidEvaluationContext(true); - setShowCopyNotification(false) + setShowCopyNotification(false); setStatus("success"); + setIsCustomScenario(false); }, [selectedTemplate]); useEffect(() => { @@ -109,9 +133,10 @@ function App() { ); useEffect(() => { - if (isValidJson(featureDefinition)) { + if (isValidYaml(featureDefinition)) { try { - flagdCore.setConfigurations(featureDefinition); + const jsonConfig = yamlToCompactJson(featureDefinition); + flagdCore.setConfigurations(jsonConfig); setAutocompleteFlagKeys(Array.from(flagdCore.getFlags().keys())); setValidFeatureDefinition(true); } catch (err) { @@ -151,37 +176,69 @@ function App() { const flagsParam = urlParams.get('flags'); const flagKeyParam = urlParams.get('flag-key'); const returnTypeParam = urlParams.get('return-type'); + const codeDefaultParam = urlParams.get('code-default'); const evalContextParam = urlParams.get('eval-context'); + const langParam = urlParams.get('lang'); const scenarioParam = urlParams.get('scenario-name'); if (flagsParam) { try { - const formattedFeatureDefinition = formatJson(flagsParam); + let formattedFeatureDefinition = formatJson(flagsParam); + let lang: DefinitionLanguage = LANG_JSON; + if (langParam === LANG_YAML) { + formattedFeatureDefinition = jsonToYaml(formattedFeatureDefinition); + lang = LANG_YAML; + } setFeatureDefinition(formattedFeatureDefinition); + setFeatureDefinitionLanguage(lang); + setIsCustomScenario(true); if (flagKeyParam) setFlagKey(flagKeyParam); if (returnTypeParam) setReturnType(returnTypeParam as FlagValueType); + if (codeDefaultParam) setCodeDefault(codeDefaultParam); if (evalContextParam) { const formattedEvaluationContext = formatJson(evalContextParam); setEvaluationContext(formattedEvaluationContext); + // evaluation context is always JSON } } catch (error) { console.error("Error decoding URL parameters: ", error); } - } else if (scenarioParam && scenarios[scenarioParam as keyof typeof scenarios]) { - setSelectedTemplate(scenarioParam as keyof typeof scenarios); - setFeatureDefinition(scenarios[scenarioParam as keyof typeof scenarios].flagDefinition); + } else if (scenarioParam && scenarios[decodeURIComponent(scenarioParam) as keyof typeof scenarios]) { + setSelectedTemplate(decodeURIComponent(scenarioParam) as keyof typeof scenarios); + setFeatureDefinition(scenarios[decodeURIComponent(scenarioParam) as keyof typeof scenarios].flagDefinition); } }, []); +// Helper function to parse codeDefault string to the appropriate type based on returnType +function parseCodeDefault(codeDefault: string, returnType: FlagValueType): any { + switch (returnType) { + case "boolean": + return codeDefault === "true" || codeDefault === "True" || codeDefault === "TRUE"; + case "number": + const num = parseFloat(codeDefault); + return isNaN(num) ? 0 : num; + case "object": + try { + return JSON.parse(codeDefault); + } catch { + return {}; + } + case "string": + default: + return codeDefault; + } +} + const evaluate = () => { setShowOutput(true); try { let result; const context = JSON.parse(evaluationContext); + const parsedCodeDefault = parseCodeDefault(codeDefault, returnType); switch (returnType) { case "boolean": result = flagdCore.resolveBooleanEvaluation( flagKey, - false, + parsedCodeDefault, context, console ); @@ -189,7 +246,7 @@ function App() { case "string": result = flagdCore.resolveStringEvaluation( flagKey, - "", + parsedCodeDefault, context, console ); @@ -197,7 +254,7 @@ function App() { case "number": result = flagdCore.resolveNumberEvaluation( flagKey, - 0, + parsedCodeDefault, context, console ); @@ -205,13 +262,20 @@ function App() { case "object": result = flagdCore.resolveObjectEvaluation( flagKey, - {}, + parsedCodeDefault, context, console ); break; } + + // If there's no variant, set value to undefined and use codeDefault + if (!result.variant) { + result.value = undefined; + } + setStatus("success"); + setOutputCodeDefault(codeDefault); setOutput(JSON.stringify(result, null, 2)); } catch (error) { console.error("Invalid JSON input", error); @@ -241,18 +305,19 @@ function App() { const copyUrl = () => { const baseUrl = window.location.origin + window.location.pathname; const newUrl = new URL(baseUrl) - const encodedFeatureDefinition = shortenJson(featureDefinition); - const encodedEvaluationContext = shortenJson(evaluationContext); + const encodedFeatureDefinition = yamlToCompactJson(featureDefinition); + const encodedEvaluationContext = yamlToCompactJson(evaluationContext); - if (Object.keys(scenarios).includes(selectedTemplate) && - scenarios[selectedTemplate].flagDefinition === featureDefinition) { + if (Object.keys(scenarios).includes(selectedTemplate) && !isCustomScenario) { newUrl.searchParams.set('scenario-name', selectedTemplate); } else { newUrl.searchParams.delete('scenario-name'); newUrl.searchParams.set('flags', encodedFeatureDefinition); newUrl.searchParams.set('flag-key', flagKey); newUrl.searchParams.set('return-type', returnType); + newUrl.searchParams.set('code-default', codeDefault); newUrl.searchParams.set('eval-context', encodedEvaluationContext); + newUrl.searchParams.set('lang', featureDefinitionLanguage); } window.history.pushState({}, '', newUrl.href); @@ -267,17 +332,29 @@ function App() { }); }; + const handleButtonClick = (e: React.MouseEvent) => { + + // resize and darken the button briefly to give click feedback + const button = e.currentTarget; + const originalBg = button.style.backgroundColor; + button.style.backgroundColor = 'rgba(0, 0, 0, 0.2)'; + button.style.transform = 'scale(0.95)'; + setTimeout(() => { + button.style.backgroundColor = originalBg; + button.style.transform = ''; + }, 150); + }; + return (
+ Back to docs

-

Feature definition

+
+

Feature definition

+ +
{ if (value) { setFeatureDefinition(value); + setIsCustomScenario(true); } }} /> @@ -384,7 +475,10 @@ function App() { name="flag-key" list="flag-keys" value={flagKey} - onChange={(e) => setFlagKey(e.target.value)} + onChange={(e) => { + setFlagKey(e.target.value); + setIsCustomScenario(true); + }} /> {autocompleteFlagKeys.map((key, index) => ( @@ -401,7 +495,10 @@ function App() { ...codeStyle, }} value={returnType} - onChange={(e) => setReturnType(e.target.value as FlagValueType)} + onChange={(e) => { + setReturnType(e.target.value as FlagValueType); + setIsCustomScenario(true); + }} > @@ -409,6 +506,27 @@ function App() {
+
+

Code default

+ { + setCodeDefault(e.target.value); + setIsCustomScenario(true); + }} + /> +

+ The default value to use when defaultVariant is null/omitted, or when errors occur during evaluation. +

+

Evaluation context

@@ -416,6 +534,7 @@ function App() { theme={editorTheme} width="100%" height="80px" + // evaluation context is always JSON, even if the flag definition is in YAML language="json" options={{ minimap: { enabled: false }, @@ -427,6 +546,7 @@ function App() { onChange={(value) => { if (value) { setEvaluationContext(value); + setIsCustomScenario(true); } }} /> @@ -435,17 +555,26 @@ function App() {
-
))} + {outputCodeDefault && parsedOutput.value === undefined && ( +
+ value: {outputCodeDefault} +
+ )}
) : (

{parsedOutput}

diff --git a/playground-app/src/main.tsx b/playground-app/src/main.tsx index d45b03089..bf6f9c8b0 100644 --- a/playground-app/src/main.tsx +++ b/playground-app/src/main.tsx @@ -7,3 +7,14 @@ ReactDOM.createRoot(document.getElementById("playground")!).render( ); + +// MkDocs "navigation.instant" swaps DOM without re-executing