Compare commits

..

No commits in common. "main" and "flagd-proxy/v0.4.2" have entirely different histories.

220 changed files with 13050 additions and 18406 deletions

View File

@ -15,6 +15,9 @@ on:
- "README.md"
- "docs/**"
env:
GO_VERSION: '1.20'
jobs:
lint:
runs-on: ubuntu-latest
@ -27,7 +30,7 @@ jobs:
- name: Setup go
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5
with:
go-version-file: 'flagd/go.mod'
go-version: ${{ env.GO_VERSION }}
- run: make workspace-init
- run: make lint
@ -39,7 +42,7 @@ jobs:
- name: Setup go
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5
with:
go-version-file: 'flagd/go.mod'
go-version: ${{ env.GO_VERSION }}
- run: make workspace-init
- run: make generate-docs
- name: Check no diff
@ -57,7 +60,7 @@ jobs:
- name: Setup go
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5
with:
go-version-file: 'flagd/go.mod'
go-version: ${{ env.GO_VERSION }}
- run: make workspace-init
- run: make test
- name: Upload coverage to Codecov
@ -75,7 +78,7 @@ jobs:
- name: Setup go
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5
with:
go-version-file: 'flagd/go.mod'
go-version: ${{ env.GO_VERSION }}
- name: Set up QEMU
uses: docker/setup-qemu-action@master
@ -95,15 +98,13 @@ jobs:
tags: flagd-local:test
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.28.0
uses: aquasecurity/trivy-action@master
with:
input: ${{ github.workspace }}/flagd-local.tar
format: "sarif"
input: /github/workspace/flagd-local.tar
format: "template"
template: "@/contrib/sarif.tpl"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH"
env:
# use an alternative trivvy db to avoid rate limits
TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3
@ -122,15 +123,7 @@ jobs:
- name: Setup go
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5
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
go-version: ${{ env.GO_VERSION }}
- name: Workspace init
run: make workspace-init
@ -144,12 +137,7 @@ jobs:
-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 &
-f file:${{ github.workspace }}/test-harness/flags/zero-flags.json &
- name: Run evaluation test suite
run: go clean -testcache && go test -cover ./test/integration

View File

@ -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.20'
PUBLIC_KEY_FILE: publicKey.pub
GOPRIVATE: buf.build/gen/go
@ -63,7 +63,6 @@ jobs:
container-release:
name: Build and push containers to GHCR
needs: release-please
environment: publish
runs-on: ubuntu-latest
if: ${{ needs.release-please.outputs.items_to_publish != '' && toJson(fromJson(needs.release-please.outputs.items_to_publish)) != '[]' }}
strategy:
@ -109,8 +108,6 @@ jobs:
context: .
file: ./${{ matrix.path }}/build.Dockerfile
platforms: linux/amd64,linux/arm64
provenance: mode=max
sbom: true
push: true
tags: |
${{ env.REGISTRY }}/${{ env.REPO_OWNER }}/${{ matrix.path }}:latest
@ -131,12 +128,24 @@ jobs:
COSIGN_PRIVATE_KEY: ${{secrets.COSIGN_PRIVATE_KEY}}
COSIGN_PASSWORD: ${{secrets.COSIGN_PASSWORD}}
- name: Generate image SBOM file name
id: image-sbom-file-gen
run: echo "IMG_SBOM_FILE=${{ format('{0}-{1}-sbom.spdx', matrix.path, env.VERSION) }}" >> $GITHUB_OUTPUT
- name: SBOM for latest image
uses: anchore/sbom-action@b6a39da80722a2cb0ef5d197531764a89b5d48c3 # v0
with:
image: ${{ env.REGISTRY }}/${{ env.REPO_OWNER }}/${{ matrix.path }}:${{ env.VERSION }}
artifact-name: ${{ steps.image-sbom-file-gen.outputs.IMG_SBOM_FILE }}
output-file: ${{ steps.image-sbom-file-gen.outputs.IMG_SBOM_FILE }}
- name: Bundle release assets
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
tag_name: ${{ env.TAG }}
files: |
${{ env.PUBLIC_KEY_FILE }}
${{ steps.image-sbom-file-gen.outputs.IMG_SBOM_FILE }}
release-go-binaries:
name: Create and publish binaries to GitHub
@ -202,6 +211,19 @@ jobs:
run: |
env CGO_ENABLED=0 GOOS=windows GOARCH=386 go build ${{ env.BUILD_ARGS }} -o ./${{ matrix.path }}_windows_i386 ./${{ matrix.path }}/main.go
zip -r ${{ matrix.path }}_${{ env.VERSION_NO_PREFIX }}_Windows_i386.zip ./${{ matrix.path }}_windows_i386 ./LICENSE ./CHANGELOG.md ./README.md ./sbom.xml
# Bundle licenses
- name: Install go-licenses
run: go install github.com/google/go-licenses@latest
- name: Build license extraction locations
id: license-files
run: |
echo "LICENSE_FOLDER=${{ format('{0}-third-party-license', matrix.path) }}" >> $GITHUB_OUTPUT
echo "LICENSE_ERROR_FILE=${{ format('{0}-license-errors.txt', matrix.path) }}" >> $GITHUB_OUTPUT
- name: Run go-licenses for module ${{ matrix.path }}
run: go-licenses save ./${{ matrix.path }} --save_path=./${{ steps.license-files.outputs.LICENSE_FOLDER }} --force --logtostderr=false 2> ./${{ steps.license-files.outputs.LICENSE_ERROR_FILE }}
continue-on-error: true # tool set stderr which can be ignored and referred through error artefact
- name: Bundle license extracts
run: tar czf ./${{ steps.license-files.outputs.LICENSE_FOLDER }}.tar.gz ./${{ steps.license-files.outputs.LICENSE_FOLDER }}
# Bundle release artifacts
- name: Bundle release assets
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
@ -211,6 +233,7 @@ jobs:
./sbom.xml
./*.tar.gz
./*.zip
./${{ steps.license-files.outputs.LICENSE_ERROR_FILE }}
homebrew:
name: Bump homebrew-core formula
needs: release-please

8
.gitignore vendored
View File

@ -17,10 +17,4 @@ node_modules/
# built documentation
site
.cache/
# coverage results
*coverage.out
# benchmark results
benchmark.txt
.cache/

2
.gitmodules vendored
View File

@ -6,4 +6,4 @@
url = https://github.com/open-feature/spec.git
[submodule "schemas"]
path = schemas
url = https://github.com/open-feature/flagd-schemas.git
url = https://github.com/open-feature/schemas.git

View File

@ -1,30 +0,0 @@
run:
timeout: 3m
linters-settings:
funlen:
statements: 50
golint:
min-confidence: 0.6
enable-all: true
issues:
exclude:
- pkg/generated
exclude-rules:
- path: _test.go
linters:
- funlen
- maligned
- noctx
- scopelint
- bodyclose
- lll
- goconst
- gocognit
- gocyclo
- dupl
- staticcheck
exclude-dirs:
- (^|/)bin($|/)
- (^|/)examples($|/)
- (^|/)schemas($|/)
- (^|/)test-harness($|/)

View File

@ -1,43 +1,67 @@
version: "2"
run:
skip-dirs:
- (^|/)bin($|/)
- (^|/)examples($|/)
- (^|/)schemas($|/)
- (^|/)test-harness($|/)
linters:
settings:
funlen:
statements: 50
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- bodyclose
- dupl
- funlen
- gocognit
- goconst
- gocyclo
- lll
- maligned
- noctx
- scopelint
- staticcheck
path: _test.go
- path: (.+)\.go$
text: pkg/generated
paths:
- (^|/)bin($|/)
- (^|/)examples($|/)
- (^|/)schemas($|/)
- (^|/)test-harness($|/)
- third_party$
- builtin$
- examples$
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
enable:
- asciicheck
- asasalint
- bidichk
- bodyclose
- contextcheck
- dogsled
- dupl
- dupword
- durationcheck
- errchkjson
- exhaustive
- funlen
- gci
- goconst
- gocritic
- gocyclo
- interfacebloat
- gosec
- lll
- misspell
- nakedret
- nilerr
- nilnil
- noctx
- nosprintfhostport
- prealloc
- promlinter
- revive
- rowserrcheck
- exportloopref
- stylecheck
- unconvert
- unparam
- whitespace
- wrapcheck
- gofumpt
- tenv
linters-settings:
funlen:
statements: 50
golint:
min-confidence: 0.6
issues:
exclude:
- pkg/generated
exclude-rules:
- path: _test.go
linters:
- funlen
- maligned
- noctx
- scopelint
- bodyclose
- lll
- goconst
- gocognit
- gocyclo
- dupl
- staticcheck

View File

@ -13,9 +13,6 @@ config:
max-one-sentence-per-line: true
code-block-style: false # not compatible with mkdocs "details" panes
no-alt-text: false
descriptive-link-text: false
MD007:
indent: 4
ignores:
- "**/CHANGELOG.md"

View File

@ -1,5 +1,5 @@
{
"flagd": "0.12.9",
"flagd-proxy": "0.8.0",
"core": "0.12.1"
"flagd": "0.8.2",
"flagd-proxy": "0.4.2",
"core": "0.7.5"
}

View File

@ -3,4 +3,4 @@
#
# Managed by Peribolos: https://github.com/open-feature/community/blob/main/config/open-feature/cloud-native/workgroup.yaml
#
* @open-feature/flagd-maintainers @open-feature/maintainers
* @open-feature/cloud-native-maintainers

View File

@ -8,24 +8,6 @@ TLDR: be respectful.
Any contributions are expected to include unit tests.
These can be validated with `make test` or the automated github workflow will run them on PR creation.
## Development
### Prerequisites
You'll need:
- Go
- make
- docker
You'll want:
- curl (for calling HTTP endpoints)
- [grpcurl](https://github.com/fullstorydev/grpcurl) (for making gRPC calls)
- jq (for pretty printing responses)
### Workspace Initialization
This project uses a go workspace, to setup the project run
```shell
@ -40,70 +22,6 @@ The project uses remote buf packages, changing the remote generation source will
export GOPRIVATE=buf.build/gen/go
```
### Manual testing
flagd has a number of interfaces (you can read more about them at [flagd.dev](https://flagd.dev/)) which can be used to evaluate flags, or deliver flag configurations so that they can be evaluated by _in-process_ providers.
You can manually test this functionality by starting flagd (from the flagd/ directory) with `go run main.go start -f file:../config/samples/example_flags.flagd.json`.
NOTE: you will need `go, curl`
#### Remote single flag evaluation via HTTP1.1/Connect
```sh
# evaluates a single boolean flag
curl -X POST -d '{"flagKey":"myBoolFlag","context":{}}' -H "Content-Type: application/json" "http://localhost:8013/flagd.evaluation.v1.Service/ResolveBoolean" | jq
```
#### Remote single flag evaluation via HTTP1.1/OFREP
```sh
# evaluates a single boolean flag
curl -X POST -d '{"context":{}}' 'http://localhost:8016/ofrep/v1/evaluate/flags/myBoolFlag' | jq
```
#### Remote single flag evaluation via gRPC
```sh
# evaluates a single boolean flag
grpcurl -import-path schemas/protobuf/flagd/evaluation/v1/ -proto evaluation.proto -plaintext -d '{"flagKey":"myBoolFlag"}' localhost:8013 flagd.evaluation.v1.Service/ResolveBoolean | jq
```
#### Remote bulk evaluation via HTTP1.1/OFREP
```sh
# evaluates flags in bulk
curl -X POST -d '{"context":{}}' 'http://localhost:8016/ofrep/v1/evaluate/flags' | jq
```
#### Remote bulk evaluation via gRPC
```sh
# evaluates flags in bulk
grpcurl -import-path schemas/protobuf/flagd/evaluation/v1/ -proto evaluation.proto -plaintext -d '{}' localhost:8013 flagd.evaluation.v1.Service/ResolveAll | jq
```
#### Remote event streaming via gRPC
```sh
# notifies of flag changes (but does not evaluate)
grpcurl -import-path schemas/protobuf/flagd/evaluation/v1/ -proto evaluation.proto -plaintext -d '{}' localhost:8013 flagd.evaluation.v1.Service/EventStream
```
#### Flag configuration fetch via gRPC
```sh
# sends back a representation of all flags
grpcurl -import-path schemas/protobuf/flagd/sync/v1/ -proto sync.proto -plaintext localhost:8015 flagd.sync.v1.FlagSyncService/FetchAllFlags | jq
```
#### Flag synchronization stream via gRPC
```sh
# will open a persistent stream which sends flag changes when the watched source is modified
grpcurl -import-path schemas/protobuf/flagd/sync/v1/ -proto sync.proto -plaintext localhost:8015 flagd.sync.v1.FlagSyncService/SyncFlags | jq
```
## DCO Sign-Off
A DCO (Developer Certificate of Origin) sign-off is a line placed at the end of

View File

@ -1,2 +1,2 @@
FROM squidfunk/mkdocs-material:9.5
RUN pip install mkdocs-include-markdown-plugin
RUN pip install mkdocs-include-markdown-plugin

View File

@ -40,26 +40,16 @@ build: workspace-init # default to flagd
build-flagd:
go build -ldflags "-X main.version=dev -X main.commit=$$(git rev-parse --short HEAD) -X main.date=$$(date +%FT%TZ)" -o ./bin/flagd ./flagd
.PHONY: test
test: test-core test-flagd test-flagd-proxy
test: # default to core
make test-core
test-core:
go test -race -covermode=atomic -cover -short ./core/pkg/... -coverprofile=core-coverage.out
test-flagd:
go test -race -covermode=atomic -cover -short ./flagd/pkg/... -coverprofile=flagd-coverage.out
test-flagd-proxy:
go test -race -covermode=atomic -cover -short ./flagd-proxy/pkg/... -coverprofile=flagd-proxy-coverage.out
flagd-benchmark-test:
go test -bench=Bench -short -benchtime=5s -benchmem ./core/... | tee benchmark.txt
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: # dependent on ./bin/flagd 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
go test -cover ./test/integration $(ARGS)
run: # default to flagd
make run-flagd
run-flagd:
cd flagd; go run main.go start -f file:../config/samples/example_flags.flagd.json
run-flagd-selector-demo:
cd flagd; go run main.go start -f file:../config/samples/example_flags.flagd.json -f file:../config/samples/example_flags.flagd.2.json
cd flagd; go run main.go start -f file:../config/samples/example_flags.flagd.json
install:
cp systemd/flagd.service /etc/systemd/system/flagd.service
mkdir -p /etc/flagd
@ -73,20 +63,17 @@ 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
$(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
$(foreach module, $(ALL_GO_MOD_DIRS), ${GOPATH}/bin/golangci-lint run --fix $(module)/...;)
go install -v github.com/golangci/golangci-lint/cmd/golangci-lint@latest
$(foreach module, $(ALL_GO_MOD_DIRS), ${GOPATH}/bin/golangci-lint run --deadline=5m --timeout=5m $(module)/... || exit;)
install-mockgen:
go install go.uber.org/mock/mockgen@v0.4.0
go install github.com/golang/mock/mockgen@v1.6.0
mockgen: install-mockgen
cd core; mockgen -source=pkg/sync/http/http_sync.go -destination=pkg/sync/http/mock/http.go -package=syncmock
cd core; mockgen -source=pkg/sync/grpc/grpc_sync.go -destination=pkg/sync/grpc/mock/grpc.go -package=grpcmock
cd core; mockgen -source=pkg/sync/grpc/credentials/builder.go -destination=pkg/sync/grpc/credentials/mock/builder.go -package=credendialsmock
cd core; mockgen -source=pkg/evaluator/ievaluator.go -destination=pkg/evaluator/mock/ievaluator.go -package=evalmock
cd core; mockgen -source=pkg/eval/ievaluator.go -destination=pkg/eval/mock/ievaluator.go -package=evalmock
cd core; mockgen -source=pkg/service/middleware/interface.go -destination=pkg/service/middleware/mock/interface.go -package=middlewaremock
cd core; mockgen -source=pkg/sync/builder/syncbuilder.go -destination=pkg/sync/builder/mock/syncbuilder.go -package=middlewaremocksyncbuildermock
cd flagd; mockgen -source=pkg/service/middleware/interface.go -destination=pkg/service/middleware/mock/interface.go -package=middlewaremock
generate-docs:
cd flagd; go run ./cmd/doc/main.go
@ -130,7 +117,7 @@ pull-schemas-submodule:
.PHONY: generate-proto-docs
generate-proto-docs: pull-schemas-submodule
docker run --rm -v ${PWD}/$(DOCS_DIR)/reference/specifications:/out -v ${PWD}/schemas/protobuf:/protos pseudomuto/protoc-gen-doc --doc_opt=markdown,protos-with-toc.md flagd/evaluation/v1/evaluation.proto flagd/sync/v1/sync.proto \
docker run --rm -v ${PWD}/$(DOCS_DIR)/reference/specifications:/out -v ${PWD}/schemas/protobuf:/protos pseudomuto/protoc-gen-doc --doc_opt=markdown,protos-with-toc.md schema/v1/schema.proto sync/v1/sync_service.proto \
&& echo '<!-- WARNING: THIS DOC IS AUTO-GENERATED. DO NOT EDIT! -->' > ${PWD}/$(DOCS_DIR)/reference/specifications/protos.md \
&& sed '/^## Table of Contents/,/#top/d' ${PWD}/$(DOCS_DIR)/reference/specifications/protos-with-toc.md >> ${PWD}/$(DOCS_DIR)/reference/specifications/protos.md \
&& rm -f ${PWD}/$(DOCS_DIR)/reference/specifications/protos-with-toc.md
@ -144,8 +131,8 @@ update-public-schema: pull-schemas-submodule
.PHONY: run-web-docs
run-web-docs: generate-docs generate-proto-docs
docker build -t flag-docs:latest . --load \
&& docker run --rm -it -p 8000:8000 -v ${PWD}:/docs flag-docs:latest
docker build -t squidfunk/mkdocs-material . \
&& docker run --rm -it -p 8000:8000 -v ${PWD}:/docs squidfunk/mkdocs-material
# Run the playground app in dev mode
# See the readme in the playground-app folder for more details

View File

@ -21,11 +21,11 @@
## What's flagd?
flagd is a feature flag daemon with a Unix philosophy. Think of it as a ready-made, open source, OpenFeature-compliant feature flag backend system.
Flagd is a feature flag daemon with a Unix philosophy. Think of it as a ready-made, open source, OpenFeature-compliant feature flag backend system.
## Features
- 🌐 OpenFeature compliant and [speaks your language](https://openfeature.dev/ecosystem?instant_search%5BrefinementList%5D%5Bvendor%5D%5B0%5D=flagd).
- 🌐 OpenFeature compliant and [speaks your language](https://openfeature.dev/ecosystem?instant_search%5BrefinementList%5D%5Bvendor%5D%5B0%5D=FlagD).
- 🆕 Easy to [extend to new languages](https://flagd.dev/reference/providers/).
- 🔄 Supports multiple data sources simultaneously.
- 🕒 Feature Flag updates occur in near real-time.

View File

@ -1,72 +0,0 @@
PASS
ok github.com/open-feature/flagd/core/pkg/certreloader 15.986s
goos: linux
goarch: amd64
pkg: github.com/open-feature/flagd/core/pkg/evaluator
cpu: 11th Gen Intel(R) Core(TM) i9-11950H @ 2.60GHz
BenchmarkFractionalEvaluation/test_a@faas.com-16 423930 13316 ns/op 7229 B/op 135 allocs/op
BenchmarkFractionalEvaluation/test_b@faas.com-16 469594 13677 ns/op 7229 B/op 135 allocs/op
BenchmarkFractionalEvaluation/test_c@faas.com-16 569103 13286 ns/op 7229 B/op 135 allocs/op
BenchmarkFractionalEvaluation/test_d@faas.com-16 412386 13023 ns/op 7229 B/op 135 allocs/op
BenchmarkResolveBooleanValue/test_staticBoolFlag-16 3106903 1792 ns/op 1008 B/op 11 allocs/op
BenchmarkResolveBooleanValue/test_targetingBoolFlag-16 448164 11250 ns/op 6065 B/op 87 allocs/op
BenchmarkResolveBooleanValue/test_staticObjectFlag-16 3958750 1476 ns/op 1008 B/op 11 allocs/op
BenchmarkResolveBooleanValue/test_missingFlag-16 5331808 1353 ns/op 784 B/op 12 allocs/op
BenchmarkResolveBooleanValue/test_disabledFlag-16 4530751 1301 ns/op 1072 B/op 13 allocs/op
BenchmarkResolveStringValue/test_staticStringFlag-16 4583056 1525 ns/op 1040 B/op 13 allocs/op
BenchmarkResolveStringValue/test_targetingStringFlag-16 839954 10388 ns/op 6097 B/op 89 allocs/op
BenchmarkResolveStringValue/test_staticObjectFlag-16 4252830 1677 ns/op 1008 B/op 11 allocs/op
BenchmarkResolveStringValue/test_missingFlag-16 3743324 1495 ns/op 784 B/op 12 allocs/op
BenchmarkResolveStringValue/test_disabledFlag-16 3495699 1709 ns/op 1072 B/op 13 allocs/op
BenchmarkResolveFloatValue/test:_staticFloatFlag-16 4382868 1511 ns/op 1024 B/op 13 allocs/op
BenchmarkResolveFloatValue/test:_targetingFloatFlag-16 867987 10344 ns/op 6081 B/op 89 allocs/op
BenchmarkResolveFloatValue/test:_staticObjectFlag-16 3913120 1695 ns/op 1008 B/op 11 allocs/op
BenchmarkResolveFloatValue/test:_missingFlag-16 3910468 1349 ns/op 784 B/op 12 allocs/op
BenchmarkResolveFloatValue/test:_disabledFlag-16 3642919 1666 ns/op 1072 B/op 13 allocs/op
BenchmarkResolveIntValue/test_staticIntFlag-16 4077288 1349 ns/op 1008 B/op 11 allocs/op
BenchmarkResolveIntValue/test_targetingNumberFlag-16 922383 7601 ns/op 6065 B/op 87 allocs/op
BenchmarkResolveIntValue/test_staticObjectFlag-16 4995128 1229 ns/op 1008 B/op 11 allocs/op
BenchmarkResolveIntValue/test_missingFlag-16 5574153 1274 ns/op 768 B/op 12 allocs/op
BenchmarkResolveIntValue/test_disabledFlag-16 3633708 1734 ns/op 1072 B/op 13 allocs/op
BenchmarkResolveObjectValue/test_staticObjectFlag-16 1624102 4559 ns/op 2243 B/op 37 allocs/op
BenchmarkResolveObjectValue/test_targetingObjectFlag-16 443880 11995 ns/op 7283 B/op 109 allocs/op
BenchmarkResolveObjectValue/test_staticBoolFlag-16 3462445 1665 ns/op 1008 B/op 11 allocs/op
BenchmarkResolveObjectValue/test_missingFlag-16 4207567 1458 ns/op 784 B/op 12 allocs/op
BenchmarkResolveObjectValue/test_disabledFlag-16 3407262 1848 ns/op 1072 B/op 13 allocs/op
PASS
ok github.com/open-feature/flagd/core/pkg/evaluator 239.506s
? github.com/open-feature/flagd/core/pkg/evaluator/mock [no test files]
PASS
ok github.com/open-feature/flagd/core/pkg/logger 0.003s
? github.com/open-feature/flagd/core/pkg/model [no test files]
? github.com/open-feature/flagd/core/pkg/service [no test files]
PASS
ok github.com/open-feature/flagd/core/pkg/service/ofrep 0.002s
PASS
ok github.com/open-feature/flagd/core/pkg/store 0.003s
? github.com/open-feature/flagd/core/pkg/sync [no test files]
PASS
ok github.com/open-feature/flagd/core/pkg/sync/blob 0.016s
PASS
ok github.com/open-feature/flagd/core/pkg/sync/builder 0.018s
? github.com/open-feature/flagd/core/pkg/sync/builder/mock [no test files]
PASS
ok github.com/open-feature/flagd/core/pkg/sync/file 1.007s
PASS
ok github.com/open-feature/flagd/core/pkg/sync/grpc 8.011s
PASS
ok github.com/open-feature/flagd/core/pkg/sync/grpc/credentials 0.008s
? github.com/open-feature/flagd/core/pkg/sync/grpc/credentials/mock [no test files]
? github.com/open-feature/flagd/core/pkg/sync/grpc/mock [no test files]
PASS
ok github.com/open-feature/flagd/core/pkg/sync/grpc/nameresolvers 0.002s
PASS
ok github.com/open-feature/flagd/core/pkg/sync/http 4.006s
? github.com/open-feature/flagd/core/pkg/sync/http/mock [no test files]
PASS
ok github.com/open-feature/flagd/core/pkg/sync/kubernetes 0.016s
? github.com/open-feature/flagd/core/pkg/sync/testing [no test files]
PASS
ok github.com/open-feature/flagd/core/pkg/telemetry 0.016s
PASS
ok github.com/open-feature/flagd/core/pkg/utils 0.002s

View File

@ -1,17 +0,0 @@
{
"$schema": "https://flagd.dev/schema/v0/flags.json",
"metadata": {
"flagSetId": "other",
"version": "v1"
},
"flags": {
"myStringFlag": {
"state": "ENABLED",
"variants": {
"dupe1": "dupe1",
"dupe2": "dupe2"
},
"defaultVariant": "dupe1"
}
}
}

View File

@ -1,9 +1,5 @@
{
"$schema": "https://flagd.dev/schema/v0/flags.json",
"metadata": {
"flagSetId": "example",
"version": "v1"
},
"flags": {
"myBoolFlag": {
"state": "ENABLED",
@ -11,10 +7,7 @@
"on": true,
"off": false
},
"defaultVariant": "on",
"metadata": {
"version": "v2"
}
"defaultVariant": "on"
},
"myStringFlag": {
"state": "ENABLED",
@ -108,10 +101,8 @@
"$ref": "emailWithFaas"
},
{
"fractional": [
{
"var": "email"
},
"fractionalEvaluation": [
"email",
[
"red",
25
@ -142,17 +133,17 @@
"defaultVariant": "first",
"state": "ENABLED",
"targeting": {
"if": [
"if": [{
"in": ["@openfeature.dev", {
"var": "email"
}]
}, "second",
{
"in": [
"@openfeature.dev",
{
"var": "email"
}
]
},
"second",
"first"
"in": ["Chrome", {
"var": "userAgent"
}]
}, "third",
null
]
}
}

View File

@ -70,8 +70,8 @@ flags:
targeting:
if:
- "$ref": emailWithFaas
- fractional:
- var: email
- fractionalEvaluation:
- email
- - red
- 25
- - blue
@ -89,13 +89,16 @@ flags:
defaultVariant: first
state: ENABLED
targeting:
if:
- in:
- "@openfeature.dev"
- var: email
- second
- first
if:
- in:
- "@openfeature.dev"
- var: email
- second
- in:
- Chrome
- var: userAgent
- third
-
"$evaluators":
emailWithFaas:
in:

View File

@ -102,9 +102,7 @@
},
{
"fractionalEvaluation": [
{
"var": "email"
},
"email",
[
"red",
25

View File

@ -71,7 +71,7 @@ flags:
if:
- "$ref": emailWithFaas
- fractionalEvaluation:
- var: email
- email
- - red
- 25
- - blue

View File

@ -1,8 +1,5 @@
{
"$schema": "https://flagd.dev/schema/v0/flags.json",
"metadata": {
"version": "v2"
},
"flags": {
"myBoolFlag": {
"state": "ENABLED",

View File

@ -1,425 +1,5 @@
# Changelog
## [0.12.1](https://github.com/open-feature/flagd/compare/core/v0.12.0...core/v0.12.1) (2025-07-28)
### 🧹 Chore
* add back file-delete test ([#1694](https://github.com/open-feature/flagd/issues/1694)) ([750aa17](https://github.com/open-feature/flagd/commit/750aa176b5a8dd24a9daaff985ff6efeb084c758))
* fix benchmark ([#1698](https://github.com/open-feature/flagd/issues/1698)) ([5e2d7d7](https://github.com/open-feature/flagd/commit/5e2d7d7176ba05e667cd92acd7decb531a8de2f6))
## [0.12.0](https://github.com/open-feature/flagd/compare/core/v0.11.8...core/v0.12.0) (2025-07-21)
### ⚠ BREAKING CHANGES
* remove sync.Type ([#1691](https://github.com/open-feature/flagd/issues/1691))
### 🐛 Bug Fixes
* update to latest otel semconv ([#1668](https://github.com/open-feature/flagd/issues/1668)) ([81855d7](https://github.com/open-feature/flagd/commit/81855d76f94a09251a19a05f830cc1d11ab6b566))
### ✨ New Features
* Add support for HTTP eTag header and 304 no change response ([#1645](https://github.com/open-feature/flagd/issues/1645)) ([ea3be4f](https://github.com/open-feature/flagd/commit/ea3be4f9010644132795bb60b36fb7705f901b62))
* remove sync.Type ([#1691](https://github.com/open-feature/flagd/issues/1691)) ([ac647e0](https://github.com/open-feature/flagd/commit/ac647e065636071f5bc065a9a084461cea692166))
## [0.11.8](https://github.com/open-feature/flagd/compare/core/v0.11.7...core/v0.11.8) (2025-07-15)
### 🧹 Chore
* **deps:** update github.com/open-feature/flagd-schemas digest to 08b4c52 ([#1682](https://github.com/open-feature/flagd/issues/1682)) ([68d04e2](https://github.com/open-feature/flagd/commit/68d04e21e63c63d6054fcd6aebfb864e8b3a597e))
## [0.11.7](https://github.com/open-feature/flagd/compare/core/v0.11.6...core/v0.11.7) (2025-07-15)
### 🐛 Bug Fixes
* general err if targeting variant not in variants ([#1680](https://github.com/open-feature/flagd/issues/1680)) ([6cabfc8](https://github.com/open-feature/flagd/commit/6cabfc8ff3bd4ad69699a72724495e84cdec0cc3))
## [0.11.6](https://github.com/open-feature/flagd/compare/core/v0.11.5...core/v0.11.6) (2025-07-10)
### ✨ New Features
* add sync_context to SyncFlags ([#1642](https://github.com/open-feature/flagd/issues/1642)) ([07a45d9](https://github.com/open-feature/flagd/commit/07a45d9b2275584fa92ff33cbe5e5c7d7864db38))
* allowing null/missing defaultValue ([#1659](https://github.com/open-feature/flagd/issues/1659)) ([3f6b78c](https://github.com/open-feature/flagd/commit/3f6b78c8ccab75e9c07d26741c4b206fd0b722ee))
## [0.11.5](https://github.com/open-feature/flagd/compare/core/v0.11.4...core/v0.11.5) (2025-06-13)
### ✨ New Features
* add server-side deadline to sync service ([#1638](https://github.com/open-feature/flagd/issues/1638)) ([b70fa06](https://github.com/open-feature/flagd/commit/b70fa06b66e1fe8a28728441a7ccd28c6fe6a0c6))
* updating context using headers ([#1641](https://github.com/open-feature/flagd/issues/1641)) ([ba34815](https://github.com/open-feature/flagd/commit/ba348152b6e7b6bd7473bb11846aac7db316c88e))
## [0.11.4](https://github.com/open-feature/flagd/compare/core/v0.11.3...core/v0.11.4) (2025-05-28)
### 🐛 Bug Fixes
* incorrect comparison used for time ([#1608](https://github.com/open-feature/flagd/issues/1608)) ([8c5ac2f](https://github.com/open-feature/flagd/commit/8c5ac2f2c31e092cbe6ddb4d3c1adeeeb04e9ef9))
### 🧹 Chore
* **deps:** update dependency go to v1.24.1 ([#1559](https://github.com/open-feature/flagd/issues/1559)) ([cd46044](https://github.com/open-feature/flagd/commit/cd4604471bba0a1df67bf87653a38df3caf9d20f))
* **security:** upgrade dependency versions ([#1632](https://github.com/open-feature/flagd/issues/1632)) ([761d870](https://github.com/open-feature/flagd/commit/761d870a3c563b8eb1b83ee543b41316c98a1d48))
### 🔄 Refactoring
* Refactor the cron function in http sync ([#1600](https://github.com/open-feature/flagd/issues/1600)) ([babcacf](https://github.com/open-feature/flagd/commit/babcacfe4dd1244dda954823d8a3ed2019c8752b))
* removed hardcoded metric export interval and use otel default ([#1621](https://github.com/open-feature/flagd/issues/1621)) ([81c66eb](https://github.com/open-feature/flagd/commit/81c66ebf2b82fc6874ab325569f52801d5ab8e5e))
## [0.11.3](https://github.com/open-feature/flagd/compare/core/v0.11.2...core/v0.11.3) (2025-03-25)
### 🐛 Bug Fixes
* **deps:** update github.com/open-feature/flagd-schemas digest to 9b0ee43 ([#1598](https://github.com/open-feature/flagd/issues/1598)) ([0587ce4](https://github.com/open-feature/flagd/commit/0587ce44e60b643ff6960c1eaf4461f933ea95b7))
* **deps:** update github.com/open-feature/flagd-schemas digest to e840a03 ([#1587](https://github.com/open-feature/flagd/issues/1587)) ([9ee0c57](https://github.com/open-feature/flagd/commit/9ee0c573d6dbfa0c4e9b18c9da7313094ea56916))
* **deps:** update module connectrpc.com/otelconnect to v0.7.2 ([#1574](https://github.com/open-feature/flagd/issues/1574)) ([6094dce](https://github.com/open-feature/flagd/commit/6094dce5c0472f593b79d6d40e080f9b8d6503e5))
* **deps:** update module github.com/google/go-cmp to v0.7.0 ([#1569](https://github.com/open-feature/flagd/issues/1569)) ([6e9dbd2](https://github.com/open-feature/flagd/commit/6e9dbd2dbf8365f839e353f53cb638847a1f05d6))
* **deps:** update module github.com/prometheus/client_golang to v1.21.1 ([#1576](https://github.com/open-feature/flagd/issues/1576)) ([cd95193](https://github.com/open-feature/flagd/commit/cd95193f71fd465ffd1b177fa492aa84d8414a87))
* **deps:** update module google.golang.org/grpc to v1.71.0 ([#1578](https://github.com/open-feature/flagd/issues/1578)) ([5c2c64f](https://github.com/open-feature/flagd/commit/5c2c64f878b8603dd37cbfd79b0e1588e4b5a3c6))
* incorrect metadata returned per source ([#1599](https://github.com/open-feature/flagd/issues/1599)) ([b333e11](https://github.com/open-feature/flagd/commit/b333e11ecfe54f72c44ee61b3dcb1f2a487c94d4))
### ✨ New Features
* accept version numbers which are not strings ([#1589](https://github.com/open-feature/flagd/issues/1589)) ([6a13796](https://github.com/open-feature/flagd/commit/6a137967a258e799cbac9e3bb3927a07412c2a7b))
## [0.11.2](https://github.com/open-feature/flagd/compare/core/v0.11.1...core/v0.11.2) (2025-02-21)
### 🐛 Bug Fixes
* **deps:** update golang.org/x/exp digest to 939b2ce ([#1555](https://github.com/open-feature/flagd/issues/1555)) ([23afa9c](https://github.com/open-feature/flagd/commit/23afa9c18c27885bdae0f5c4ebdc30e780e9da71))
* **deps:** update golang.org/x/exp digest to f9890c6 ([#1551](https://github.com/open-feature/flagd/issues/1551)) ([02c4b42](https://github.com/open-feature/flagd/commit/02c4b4250131ca819c85dcf10c2d78e0c218469f))
* **deps:** update module buf.build/gen/go/open-feature/flagd/protocolbuffers/go to v1.36.5-20250127221518-be6d1143b690.1 ([#1549](https://github.com/open-feature/flagd/issues/1549)) ([d3eb44e](https://github.com/open-feature/flagd/commit/d3eb44ed45a54bd9152b7477cce17be90016683c))
* **deps:** update module github.com/diegoholiveira/jsonlogic/v3 to v3.7.4 ([#1556](https://github.com/open-feature/flagd/issues/1556)) ([0dfa799](https://github.com/open-feature/flagd/commit/0dfa79956695849f3a703554525759093931a01d))
* **deps:** update module github.com/prometheus/client_golang to v1.21.0 ([#1568](https://github.com/open-feature/flagd/issues/1568)) ([a3d4162](https://github.com/open-feature/flagd/commit/a3d41625a2b79452c0732af29d0b4f320e74fe8b))
* **deps:** update module golang.org/x/crypto to v0.33.0 ([#1552](https://github.com/open-feature/flagd/issues/1552)) ([7cef153](https://github.com/open-feature/flagd/commit/7cef153a275a4fac5099f5a52013dcd227a79bb3))
* **deps:** update module golang.org/x/mod to v0.23.0 ([#1544](https://github.com/open-feature/flagd/issues/1544)) ([6fe7bd2](https://github.com/open-feature/flagd/commit/6fe7bd2a3e82dfc81068d9d95d8c3a4acc16456c))
### ✨ New Features
* Adding gRPC dial option override to grpc_sync.go ([#1563](https://github.com/open-feature/flagd/issues/1563)) ([1a97ca5](https://github.com/open-feature/flagd/commit/1a97ca5f81582e6d1f139a61e0e49007ad173d3f))
## [0.11.1](https://github.com/open-feature/flagd/compare/core/v0.11.0...core/v0.11.1) (2025-02-04)
### 🐛 Bug Fixes
* **deps:** update module golang.org/x/sync to v0.11.0 ([#1543](https://github.com/open-feature/flagd/issues/1543)) ([7d6c0dc](https://github.com/open-feature/flagd/commit/7d6c0dc6e6e6955af1e5225807deeb2b6797900b))
## [0.11.0](https://github.com/open-feature/flagd/compare/core/v0.10.8...core/v0.11.0) (2025-01-31)
### ⚠ BREAKING CHANGES
* flagSetMetadata in OFREP/ResolveAll, core refactors ([#1540](https://github.com/open-feature/flagd/issues/1540))
### 🐛 Bug Fixes
* **deps:** update github.com/open-feature/flagd-schemas digest to bb76343 ([#1534](https://github.com/open-feature/flagd/issues/1534)) ([8303353](https://github.com/open-feature/flagd/commit/8303353a1b503ef34b8e46d9bf77ce53c067ef3b))
* **deps:** update golang.org/x/exp digest to 3edf0e9 ([#1538](https://github.com/open-feature/flagd/issues/1538)) ([7a06567](https://github.com/open-feature/flagd/commit/7a0656713a8c2ac3d456a3a300fe137debee0edd))
* **deps:** update golang.org/x/exp digest to e0ece0d ([#1539](https://github.com/open-feature/flagd/issues/1539)) ([4281c6e](https://github.com/open-feature/flagd/commit/4281c6e80b233a162436fea3640bf5d061d40b96))
* **deps:** update module buf.build/gen/go/open-feature/flagd/grpc/go to v1.5.1-20250127221518-be6d1143b690.2 ([#1536](https://github.com/open-feature/flagd/issues/1536)) ([e23060f](https://github.com/open-feature/flagd/commit/e23060f24b2a714ae748e6b37d0d06b7caa1c95c))
* **deps:** update module buf.build/gen/go/open-feature/flagd/protocolbuffers/go to v1.36.4-20241220192239-696330adaff0.1 ([#1529](https://github.com/open-feature/flagd/issues/1529)) ([8881a80](https://github.com/open-feature/flagd/commit/8881a804b4055da0127a16b8fc57022d24906e1b))
* **deps:** update module buf.build/gen/go/open-feature/flagd/protocolbuffers/go to v1.36.4-20250127221518-be6d1143b690.1 ([#1537](https://github.com/open-feature/flagd/issues/1537)) ([f74207b](https://github.com/open-feature/flagd/commit/f74207bc13b75bae4275bc486df51e2da569dd41))
* **deps:** update module google.golang.org/grpc to v1.70.0 ([#1528](https://github.com/open-feature/flagd/issues/1528)) ([79b2b0a](https://github.com/open-feature/flagd/commit/79b2b0a6bbd48676dcbdd2393feb8247529bf29c))
### ✨ New Features
* flagSetMetadata in OFREP/ResolveAll, core refactors ([#1540](https://github.com/open-feature/flagd/issues/1540)) ([b49abf9](https://github.com/open-feature/flagd/commit/b49abf95069da93bdf8369c8aa0ae40e698df760))
* support yaml in blob, file, and http syncs ([#1522](https://github.com/open-feature/flagd/issues/1522)) ([76d673a](https://github.com/open-feature/flagd/commit/76d673ae8f765512270e6498569c0ce3d54a60bf))
## [0.10.8](https://github.com/open-feature/flagd/compare/core/v0.10.7...core/v0.10.8) (2025-01-19)
### 🐛 Bug Fixes
* **deps:** update module github.com/diegoholiveira/jsonlogic/v3 to v3.7.3 ([#1520](https://github.com/open-feature/flagd/issues/1520)) ([db2f990](https://github.com/open-feature/flagd/commit/db2f99021dfd676d2fd0c6af6af7e77783ee31ce))
* **deps:** update opentelemetry-go monorepo ([#1524](https://github.com/open-feature/flagd/issues/1524)) ([eeae9a6](https://github.com/open-feature/flagd/commit/eeae9a64caf93356fd663cc735cc422edcf9e132))
## [0.10.7](https://github.com/open-feature/flagd/compare/core/v0.10.6...core/v0.10.7) (2025-01-16)
### 🐛 Bug Fixes
* **deps:** update module buf.build/gen/go/open-feature/flagd/protocolbuffers/go to v1.36.3-20241220192239-696330adaff0.1 ([#1513](https://github.com/open-feature/flagd/issues/1513)) ([64c5787](https://github.com/open-feature/flagd/commit/64c57875b032edcef2e2d230e7735990e01b72b8))
## [0.10.6](https://github.com/open-feature/flagd/compare/core/v0.10.5...core/v0.10.6) (2025-01-15)
### 🐛 Bug Fixes
* **deps:** update github.com/open-feature/flagd-schemas digest to 37baa2c ([#1499](https://github.com/open-feature/flagd/issues/1499)) ([1a853f7](https://github.com/open-feature/flagd/commit/1a853f79dc41523fd6dcb1ae6ca9745947955cbc))
* **deps:** update github.com/open-feature/flagd-schemas digest to b81a56e ([#1391](https://github.com/open-feature/flagd/issues/1391)) ([6a3d8ac](https://github.com/open-feature/flagd/commit/6a3d8ac2511c32bd0dc77bba0169679aa9bf6ca6))
* **deps:** update golang.org/x/exp digest to 7588d65 ([#1495](https://github.com/open-feature/flagd/issues/1495)) ([242e594](https://github.com/open-feature/flagd/commit/242e59450c71c682b56e554830ea3003bdbf9622))
* **deps:** update golang.org/x/exp digest to b2144cd ([#1320](https://github.com/open-feature/flagd/issues/1320)) ([a692b00](https://github.com/open-feature/flagd/commit/a692b009ae8e7dc928d0fd65236b404192c99562))
* **deps:** update module buf.build/gen/go/open-feature/flagd/grpc/go to v1.5.1-20241220192239-696330adaff0.1 ([#1489](https://github.com/open-feature/flagd/issues/1489)) ([53add83](https://github.com/open-feature/flagd/commit/53add83a491c6e00e0d9b1b64a9461e5973edca7))
* **deps:** update module buf.build/gen/go/open-feature/flagd/grpc/go to v1.5.1-20241220192239-696330adaff0.2 ([#1492](https://github.com/open-feature/flagd/issues/1492)) ([9f1d94a](https://github.com/open-feature/flagd/commit/9f1d94a42ac00ecf5fc58c07a76c350e2e4ec2f6))
* **deps:** update module buf.build/gen/go/open-feature/flagd/protocolbuffers/go to v1.36.0-20241220192239-696330adaff0.1 ([#1490](https://github.com/open-feature/flagd/issues/1490)) ([6edce72](https://github.com/open-feature/flagd/commit/6edce72e8cff01ea13cbd15d604b35ccc8337f50))
* **deps:** update module buf.build/gen/go/open-feature/flagd/protocolbuffers/go to v1.36.2-20241220192239-696330adaff0.1 ([#1502](https://github.com/open-feature/flagd/issues/1502)) ([426c36e](https://github.com/open-feature/flagd/commit/426c36e838b9ded3a23f933e66e963c8110c0ddb))
* **deps:** update module connectrpc.com/connect to v1.18.1 ([#1507](https://github.com/open-feature/flagd/issues/1507)) ([89d3259](https://github.com/open-feature/flagd/commit/89d32591db784458ce9b4cca36662ea502418bc5))
* **deps:** update module github.com/diegoholiveira/jsonlogic/v3 to v3.7.0 ([#1496](https://github.com/open-feature/flagd/issues/1496)) ([e1fe149](https://github.com/open-feature/flagd/commit/e1fe1490fd1c26b9c566ff5ddef666c0fa74b2d5))
* **deps:** update module github.com/diegoholiveira/jsonlogic/v3 to v3.7.1 ([#1509](https://github.com/open-feature/flagd/issues/1509)) ([9d06812](https://github.com/open-feature/flagd/commit/9d0681270f26bb91777fa2b8a792a4b0ccd07304))
* **deps:** update module golang.org/x/crypto to v0.32.0 ([#1497](https://github.com/open-feature/flagd/issues/1497)) ([63a34d2](https://github.com/open-feature/flagd/commit/63a34d23aedcd798ff9f4cd47cdaddca35416423))
* **deps:** update module google.golang.org/grpc to v1.69.2 ([#1484](https://github.com/open-feature/flagd/issues/1484)) ([6b40ad3](https://github.com/open-feature/flagd/commit/6b40ad34c83da4a3116e7cad4139a63a6c918097))
* **deps:** update module google.golang.org/grpc to v1.69.4 ([#1510](https://github.com/open-feature/flagd/issues/1510)) ([76d6353](https://github.com/open-feature/flagd/commit/76d6353840ab8e7c93bdb0802eb1c49fc6fe1dc0))
* **deps:** update opentelemetry-go monorepo ([#1470](https://github.com/open-feature/flagd/issues/1470)) ([26b0b1a](https://github.com/open-feature/flagd/commit/26b0b1af8bc4b3a393c3453784b50f167f13f743))
### ✨ New Features
* add ssl support to sync service ([#1479](https://github.com/open-feature/flagd/issues/1479)) ([#1501](https://github.com/open-feature/flagd/issues/1501)) ([d50fcc8](https://github.com/open-feature/flagd/commit/d50fcc821c1ae043cb8cf77e464f7b738e2ff755))
* support flag metadata ([#1476](https://github.com/open-feature/flagd/issues/1476)) ([13fbbad](https://github.com/open-feature/flagd/commit/13fbbad4d849b35884f429c0e74a71ece9cce2c9))
## [0.10.5](https://github.com/open-feature/flagd/compare/core/v0.10.4...core/v0.10.5) (2024-12-17)
### 🐛 Bug Fixes
* **deps:** update kubernetes packages to v0.31.2 ([#1430](https://github.com/open-feature/flagd/issues/1430)) ([0df8622](https://github.com/open-feature/flagd/commit/0df862215563545c33f518ab7a5ad42a19bf6adb))
* **deps:** update kubernetes packages to v0.31.3 ([#1454](https://github.com/open-feature/flagd/issues/1454)) ([f56d7b0](https://github.com/open-feature/flagd/commit/f56d7b043c2d80ae4fe27e996c05a7cc1c2c1b28))
* **deps:** update kubernetes packages to v0.31.4 ([#1461](https://github.com/open-feature/flagd/issues/1461)) ([431fbb4](https://github.com/open-feature/flagd/commit/431fbb49513bcdb21b09845f47c26e51e7e9f21b))
* **deps:** update module buf.build/gen/go/open-feature/flagd/protocolbuffers/go to v1.35.2-20240906125204-0a6a901b42e8.1 ([#1451](https://github.com/open-feature/flagd/issues/1451)) ([8c6d91d](https://github.com/open-feature/flagd/commit/8c6d91d538d226b10cb954c23409902e9d245cda))
* **deps:** update module buf.build/gen/go/open-feature/flagd/protocolbuffers/go to v1.36.0-20240906125204-0a6a901b42e8.1 ([#1475](https://github.com/open-feature/flagd/issues/1475)) ([0b11c6c](https://github.com/open-feature/flagd/commit/0b11c6cf612b244bda6bab119814647f3ce8de2e))
* **deps:** update module github.com/diegoholiveira/jsonlogic/v3 to v3.6.0 ([#1460](https://github.com/open-feature/flagd/issues/1460)) ([dbc1da4](https://github.com/open-feature/flagd/commit/dbc1da4ba984c06972b57cf990d1d31c4b8323df))
* **deps:** update module github.com/diegoholiveira/jsonlogic/v3 to v3.6.1 ([#1473](https://github.com/open-feature/flagd/issues/1473)) ([a3d899c](https://github.com/open-feature/flagd/commit/a3d899c5f8952181a6a987436e2255c2ab9176c5))
* **deps:** update module github.com/fsnotify/fsnotify to v1.8.0 ([#1438](https://github.com/open-feature/flagd/issues/1438)) ([949c73b](https://github.com/open-feature/flagd/commit/949c73bd6ebadb30cfa3b7573b43d722f8d2a93d))
* **deps:** update module github.com/stretchr/testify to v1.10.0 ([#1455](https://github.com/open-feature/flagd/issues/1455)) ([8c843df](https://github.com/open-feature/flagd/commit/8c843df7714b1f2d120c5cac8e40c7220cc0c05b))
* **deps:** update module golang.org/x/crypto to v0.29.0 ([#1443](https://github.com/open-feature/flagd/issues/1443)) ([db96dd5](https://github.com/open-feature/flagd/commit/db96dd57b9de032fc4d15931bf907a7ed962f81b))
* **deps:** update module golang.org/x/crypto to v0.30.0 ([#1457](https://github.com/open-feature/flagd/issues/1457)) ([dbdaa19](https://github.com/open-feature/flagd/commit/dbdaa199f0667f16d2a3b91867535ce93e63373c))
* **deps:** update module golang.org/x/crypto to v0.31.0 ([#1463](https://github.com/open-feature/flagd/issues/1463)) ([b2245d7](https://github.com/open-feature/flagd/commit/b2245d7f73f1bde859b9627d337dd09ecd2f1a31))
* **deps:** update module golang.org/x/mod to v0.22.0 ([#1444](https://github.com/open-feature/flagd/issues/1444)) ([ed064e1](https://github.com/open-feature/flagd/commit/ed064e134fb3a5edb0ec2d976f136af7e94d7f6d))
* **deps:** update module google.golang.org/grpc to v1.68.0 ([#1442](https://github.com/open-feature/flagd/issues/1442)) ([cd27d09](https://github.com/open-feature/flagd/commit/cd27d098e6d8d8b0f681ef42d26dba1ebac67d12))
* **deps:** update module google.golang.org/grpc to v1.68.1 ([#1456](https://github.com/open-feature/flagd/issues/1456)) ([0b6e2a1](https://github.com/open-feature/flagd/commit/0b6e2a1cd64910226d348c921b08a6de8013ac90))
* **deps:** update module google.golang.org/grpc to v1.69.0 ([#1469](https://github.com/open-feature/flagd/issues/1469)) ([dd4869f](https://github.com/open-feature/flagd/commit/dd4869f5e095066f80c9d82d1be83155e7504d88))
* **deps:** update opentelemetry-go monorepo ([#1447](https://github.com/open-feature/flagd/issues/1447)) ([68b5794](https://github.com/open-feature/flagd/commit/68b5794180da84af9adc1f2cd80f929489969c1c))
### ✨ New Features
* add context-value flag ([#1448](https://github.com/open-feature/flagd/issues/1448)) ([7ca092e](https://github.com/open-feature/flagd/commit/7ca092e478c937eca0c91357394499763545dc1c))
* s3 support for the blob sync ([#1449](https://github.com/open-feature/flagd/issues/1449)) ([a9f7261](https://github.com/open-feature/flagd/commit/a9f7261e75bc064947ae14900e5c4edc4b49bec4))
## [0.10.4](https://github.com/open-feature/flagd/compare/core/v0.10.3...core/v0.10.4) (2024-10-28)
### 🐛 Bug Fixes
* **deps:** update module buf.build/gen/go/open-feature/flagd/protocolbuffers/go to v1.35.1-20240906125204-0a6a901b42e8.1 ([#1420](https://github.com/open-feature/flagd/issues/1420)) ([1f06d5a](https://github.com/open-feature/flagd/commit/1f06d5a1837ea2b753974e96c2a1154d6cb3e582))
* **deps:** update module github.com/prometheus/client_golang to v1.20.5 ([#1425](https://github.com/open-feature/flagd/issues/1425)) ([583ba89](https://github.com/open-feature/flagd/commit/583ba894f2de794b36b6a1cc3bfceb9c46dc9d96))
* **deps:** update module go.uber.org/mock to v0.5.0 ([#1427](https://github.com/open-feature/flagd/issues/1427)) ([0c6fd7f](https://github.com/open-feature/flagd/commit/0c6fd7fa688db992d4e58a202889cbfea07eebf6))
* **deps:** update module gocloud.dev to v0.40.0 ([#1422](https://github.com/open-feature/flagd/issues/1422)) ([e0e4709](https://github.com/open-feature/flagd/commit/e0e4709243d8301bcbb0aaaa309be66944c1d9ed))
* **deps:** update module golang.org/x/crypto to v0.28.0 ([#1416](https://github.com/open-feature/flagd/issues/1416)) ([fb272da](https://github.com/open-feature/flagd/commit/fb272da56e0eba12245309899888c18920b9a200))
* **deps:** update module google.golang.org/grpc to v1.67.1 ([#1415](https://github.com/open-feature/flagd/issues/1415)) ([85a3a6b](https://github.com/open-feature/flagd/commit/85a3a6b46233fcc7cf71a0292b46c82ac8e66d7b))
### ✨ New Features
* added custom grpc resolver ([#1424](https://github.com/open-feature/flagd/issues/1424)) ([e5007e2](https://github.com/open-feature/flagd/commit/e5007e2bcb6f049a3c54e09331065bb9abe215be))
* support azure blob sync ([#1428](https://github.com/open-feature/flagd/issues/1428)) ([5c39cfe](https://github.com/open-feature/flagd/commit/5c39cfe30a3dead4f6db2c6f9ee4c12193cd479b))
## [0.10.3](https://github.com/open-feature/flagd/compare/core/v0.10.2...core/v0.10.3) (2024-09-23)
### 🐛 Bug Fixes
* **deps:** update kubernetes package and controller runtime, fix proto lint ([#1290](https://github.com/open-feature/flagd/issues/1290)) ([94860d6](https://github.com/open-feature/flagd/commit/94860d6ceabe9eb7c1e5dd8ea139a796710d6d8b))
* **deps:** update module buf.build/gen/go/open-feature/flagd/grpc/go to v1.5.1-20240906125204-0a6a901b42e8.1 ([#1400](https://github.com/open-feature/flagd/issues/1400)) ([954d972](https://github.com/open-feature/flagd/commit/954d97238210f90b650493ae76277d4a8d80788a))
* **deps:** update module connectrpc.com/connect to v1.17.0 ([#1408](https://github.com/open-feature/flagd/issues/1408)) ([e7eb691](https://github.com/open-feature/flagd/commit/e7eb691094dfbf02e37d79c41f60f556415e7640))
* **deps:** update module github.com/prometheus/client_golang to v1.20.3 ([#1384](https://github.com/open-feature/flagd/issues/1384)) ([8fd16b2](https://github.com/open-feature/flagd/commit/8fd16b23b1fa8517128af36b3068ca18ebbad6c3))
* **deps:** update module github.com/prometheus/client_golang to v1.20.4 ([#1406](https://github.com/open-feature/flagd/issues/1406)) ([a0a6426](https://github.com/open-feature/flagd/commit/a0a64269b08251317676075fdea7bc65bea8a8dc))
* **deps:** update module gocloud.dev to v0.39.0 ([#1404](https://github.com/open-feature/flagd/issues/1404)) ([a3184d6](https://github.com/open-feature/flagd/commit/a3184d68413749808709baac47df3bf7400f9cdc))
* **deps:** update module golang.org/x/crypto to v0.27.0 ([#1396](https://github.com/open-feature/flagd/issues/1396)) ([f9a7d10](https://github.com/open-feature/flagd/commit/f9a7d10590d3191ea8eba0dbb340fa94d07026a4))
* **deps:** update module golang.org/x/mod to v0.21.0 ([#1397](https://github.com/open-feature/flagd/issues/1397)) ([1507e19](https://github.com/open-feature/flagd/commit/1507e19e9304bcebfbbe4376f45e9f2e82135fd2))
* **deps:** update module google.golang.org/grpc to v1.66.0 ([#1393](https://github.com/open-feature/flagd/issues/1393)) ([c96e9d7](https://github.com/open-feature/flagd/commit/c96e9d764aa51caf00fbde07cdc7d2de55b98b9e))
* **deps:** update module google.golang.org/grpc to v1.66.1 ([#1402](https://github.com/open-feature/flagd/issues/1402)) ([50c9cd3](https://github.com/open-feature/flagd/commit/50c9cd3ada2f470a22374392a5a152a487636645))
* **deps:** update module google.golang.org/grpc to v1.66.2 ([#1405](https://github.com/open-feature/flagd/issues/1405)) ([69ec28f](https://github.com/open-feature/flagd/commit/69ec28fceb597bdaad63b184943b66ccdb4af0b7))
* **deps:** update module google.golang.org/grpc to v1.67.0 ([#1407](https://github.com/open-feature/flagd/issues/1407)) ([1ad6480](https://github.com/open-feature/flagd/commit/1ad6480a0f37c4677e53065ef455f615b26b1f17))
* **deps:** update opentelemetry-go monorepo ([#1387](https://github.com/open-feature/flagd/issues/1387)) ([22aef5b](https://github.com/open-feature/flagd/commit/22aef5bbf030c619e48fbe22a16d83e071b11902))
* **deps:** update opentelemetry-go monorepo ([#1403](https://github.com/open-feature/flagd/issues/1403)) ([fc4cd3e](https://github.com/open-feature/flagd/commit/fc4cd3e547f4826ea0bb8cc1bb2304807932b4e6))
* remove dep cycle with certreloader ([#1410](https://github.com/open-feature/flagd/issues/1410)) ([5244f6f](https://github.com/open-feature/flagd/commit/5244f6f6c94f310fd80c7ab84942103cc8c18a39))
### ✨ New Features
* add mTLS support to otel exporter ([#1389](https://github.com/open-feature/flagd/issues/1389)) ([8737f53](https://github.com/open-feature/flagd/commit/8737f53444016b114ee4ae52eead0b835af0e200))
## [0.10.2](https://github.com/open-feature/flagd/compare/core/v0.10.1...core/v0.10.2) (2024-08-22)
### 🐛 Bug Fixes
* **deps:** update module buf.build/gen/go/open-feature/flagd/grpc/go to v1.5.1-20240215170432-1e611e2999cc.1 ([#1372](https://github.com/open-feature/flagd/issues/1372)) ([ae24595](https://github.com/open-feature/flagd/commit/ae2459504f7eccafebccec83fa1f72b08f41a978))
* **deps:** update module connectrpc.com/otelconnect to v0.7.1 ([#1367](https://github.com/open-feature/flagd/issues/1367)) ([184915b](https://github.com/open-feature/flagd/commit/184915b31726729e8ed2f7999f338bf4ed684809))
* **deps:** update module github.com/open-feature/open-feature-operator/apis to v0.2.44 ([#1368](https://github.com/open-feature/flagd/issues/1368)) ([0c68726](https://github.com/open-feature/flagd/commit/0c68726bed1cdae07f1b90447818ebbc9dc45caf))
* **deps:** update module golang.org/x/crypto to v0.26.0 ([#1379](https://github.com/open-feature/flagd/issues/1379)) ([05f6658](https://github.com/open-feature/flagd/commit/05f6658e3dc72182adbff9197c8980641af8c53f))
* **deps:** update module golang.org/x/mod to v0.20.0 ([#1377](https://github.com/open-feature/flagd/issues/1377)) ([797d7a4](https://github.com/open-feature/flagd/commit/797d7a4bbafc73e6882e5998df500ae4fe98fbbc))
* **deps:** update module golang.org/x/sync to v0.8.0 ([#1378](https://github.com/open-feature/flagd/issues/1378)) ([4804c17](https://github.com/open-feature/flagd/commit/4804c17a67ea9761079ecade34ccb3446643050b))
### ✨ New Features
* add 'watcher' interface to file sync ([#1365](https://github.com/open-feature/flagd/issues/1365)) ([61fff43](https://github.com/open-feature/flagd/commit/61fff43e288daac88efb127ada20276c01ed5928))
* added new grpc sync config option to allow setting max receive message size. ([#1358](https://github.com/open-feature/flagd/issues/1358)) ([bed077b](https://github.com/open-feature/flagd/commit/bed077bac9da3b6e3bd45ca54046e40a595fcba6))
* Support blob type sources and GCS as an example of such source. ([#1366](https://github.com/open-feature/flagd/issues/1366)) ([21f2c9a](https://github.com/open-feature/flagd/commit/21f2c9a5d64cbfe2fc841080850a2c582e8f4ba6))
### 🧹 Chore
* **deps:** update dependency go to v1.22.6 ([#1297](https://github.com/open-feature/flagd/issues/1297)) ([50b92c1](https://github.com/open-feature/flagd/commit/50b92c17cfd872d3e6b95fef3b3d96444e563715))
## [0.10.1](https://github.com/open-feature/flagd/compare/core/v0.10.0...core/v0.10.1) (2024-07-08)
### 🐛 Bug Fixes
* **deps:** update module buf.build/gen/go/open-feature/flagd/grpc/go to v1.4.0-20240215170432-1e611e2999cc.2 ([#1342](https://github.com/open-feature/flagd/issues/1342)) ([efdd921](https://github.com/open-feature/flagd/commit/efdd92139903b89ac986a62ff2cf4f5cfef91cde))
* **deps:** update module golang.org/x/crypto to v0.25.0 ([#1351](https://github.com/open-feature/flagd/issues/1351)) ([450cbc8](https://github.com/open-feature/flagd/commit/450cbc84ca55eef3fccc768003e358a8e589668e))
* **deps:** update module golang.org/x/mod to v0.19.0 ([#1349](https://github.com/open-feature/flagd/issues/1349)) ([6ee89b4](https://github.com/open-feature/flagd/commit/6ee89b44ca4aca8f6236603fc3f969e814907bd6))
* **deps:** update module google.golang.org/grpc to v1.65.0 ([#1346](https://github.com/open-feature/flagd/issues/1346)) ([72a6b87](https://github.com/open-feature/flagd/commit/72a6b876e880ff0b43440d9b63710c7a87536988))
* **deps:** update opentelemetry-go monorepo ([#1347](https://github.com/open-feature/flagd/issues/1347)) ([37fb3cd](https://github.com/open-feature/flagd/commit/37fb3cd81d5436e9d8cd3ea490a3951ae5794130))
## [0.10.0](https://github.com/open-feature/flagd/compare/core/v0.9.3...core/v0.10.0) (2024-06-27)
### ⚠ BREAKING CHANGES
* support emitting errors from the bulk evaluator ([#1338](https://github.com/open-feature/flagd/issues/1338))
### 🐛 Bug Fixes
* **deps:** update module buf.build/gen/go/open-feature/flagd/grpc/go to v1.4.0-20240215170432-1e611e2999cc.1 ([#1333](https://github.com/open-feature/flagd/issues/1333)) ([494062f](https://github.com/open-feature/flagd/commit/494062fed891fab0fb659352142dbbc97c8f1492))
* **deps:** update module buf.build/gen/go/open-feature/flagd/protocolbuffers/go to v1.34.2-20240215170432-1e611e2999cc.2 ([#1330](https://github.com/open-feature/flagd/issues/1330)) ([32291ad](https://github.com/open-feature/flagd/commit/32291ad93d25d79299a7a02381df70e2719c4fbc))
* **deps:** update module connectrpc.com/connect to v1.16.2 ([#1289](https://github.com/open-feature/flagd/issues/1289)) ([8bacb7c](https://github.com/open-feature/flagd/commit/8bacb7c59c17956dda3cf9d2a7bc6f139885a656))
* **deps:** update module github.com/open-feature/open-feature-operator/apis to v0.2.43 ([#1331](https://github.com/open-feature/flagd/issues/1331)) ([fecd769](https://github.com/open-feature/flagd/commit/fecd769e5f2c4aa7bf1a0fb13f0543e7b8045af8))
* **deps:** update module golang.org/x/crypto to v0.24.0 ([#1335](https://github.com/open-feature/flagd/issues/1335)) ([2a31a17](https://github.com/open-feature/flagd/commit/2a31a1740303991412e0169e50a064823cce0560))
* **deps:** update module golang.org/x/mod to v0.18.0 ([#1336](https://github.com/open-feature/flagd/issues/1336)) ([5fa83f7](https://github.com/open-feature/flagd/commit/5fa83f7ab266320d8d8f1388a6c2f2cac922275a))
* **deps:** update opentelemetry-go monorepo ([#1314](https://github.com/open-feature/flagd/issues/1314)) ([e9f1a7a](https://github.com/open-feature/flagd/commit/e9f1a7a04828f36691e694375b3c665140bc7dee))
* readable error messages ([#1325](https://github.com/open-feature/flagd/issues/1325)) ([7ff33ef](https://github.com/open-feature/flagd/commit/7ff33effcc47e31c5b7fdc33385d8128db2163fc))
### ✨ New Features
* add mandatory flags property in bulk response ([#1339](https://github.com/open-feature/flagd/issues/1339)) ([b20266e](https://github.com/open-feature/flagd/commit/b20266ed5e0c16bf14769f300297f7f2b0ab2dcd))
* support emitting errors from the bulk evaluator ([#1338](https://github.com/open-feature/flagd/issues/1338)) ([b9c099c](https://github.com/open-feature/flagd/commit/b9c099cb7fa002a509a82c81b467f5e784c27e82))
* support relative weighting for fractional evaluation ([#1313](https://github.com/open-feature/flagd/issues/1313)) ([f82c094](https://github.com/open-feature/flagd/commit/f82c094f5c47f99ef37b7392bfd39cec3ec7ba51))
## [0.9.3](https://github.com/open-feature/flagd/compare/core/v0.9.2...core/v0.9.3) (2024-06-06)
### 🐛 Bug Fixes
* fixes store merge when selector is used ([#1322](https://github.com/open-feature/flagd/issues/1322)) ([ed5025d](https://github.com/open-feature/flagd/commit/ed5025d8f28fdf92a5c7dceaec7fd6df7f979e3b))
### 🧹 Chore
* adapt telemetry setup error handling ([#1315](https://github.com/open-feature/flagd/issues/1315)) ([20bcb78](https://github.com/open-feature/flagd/commit/20bcb78d11dbb16aab2b14d5869bb990a0f7bca5))
## [0.9.2](https://github.com/open-feature/flagd/compare/core/v0.9.1...core/v0.9.2) (2024-05-10)
### ✨ New Features
* improve error log and add flag disabled handling for ofrep ([#1306](https://github.com/open-feature/flagd/issues/1306)) ([39ae4fe](https://github.com/open-feature/flagd/commit/39ae4fe11380af5c6e23c4aaae45b5ec17cf32d6))
### 🧹 Chore
* bump go deps to latest ([#1307](https://github.com/open-feature/flagd/issues/1307)) ([004ad08](https://github.com/open-feature/flagd/commit/004ad083dc01538791148d6233e453d2a3009fcd))
## [0.9.1](https://github.com/open-feature/flagd/compare/core/v0.9.0...core/v0.9.1) (2024-04-19)
### 🐛 Bug Fixes
* missing/nil custom variables in fractional operator ([#1295](https://github.com/open-feature/flagd/issues/1295)) ([418c5cd](https://github.com/open-feature/flagd/commit/418c5cd7c07fad61674a751872a1256b5062799c))
### ✨ New Features
* move json logic operator registration to resolver ([#1291](https://github.com/open-feature/flagd/issues/1291)) ([b473457](https://github.com/open-feature/flagd/commit/b473457ddff28789fee1eeb6704491b6aa3525e3))
## [0.9.0](https://github.com/open-feature/flagd/compare/core/v0.8.2...core/v0.9.0) (2024-04-10)
### ⚠ BREAKING CHANGES
* allow custom seed when using targetingKey override for fractional op ([#1266](https://github.com/open-feature/flagd/issues/1266))
* This is a breaking change only to the extent that it changes the assignment of evaluated flag values.
Previously, flagd's `fractional` op would internally concatenate any specified bucketing property with the `flag-key`.
This improved apparent "randomness" by reducing the chances that users were assigned a bucket of the same ordinality across multiple flags.
However, sometimes it's desireable to have such predictibility, so now **flagd will use the bucketing value as is**.
If you are specifying a bucketing value in a `fractional` rule, and want to maintain the previous assignments, you can do this concatenation manually:
`{ "var": "user.name" }` => `{"cat": [{ "var": "$flagd.flagKey" }, { "var": "user.name" }]}`.
This will result in the same assignment as before.
Please note, that if you do not specify a bucketing key at all (the shorthand version of the `fractional` op), flagd still uses a concatentation of the `flag-key` and `targetingKey` as before; this behavior has not changed.
### ✨ New Features
* allow custom seed when using targetingKey override for fractional op ([#1266](https://github.com/open-feature/flagd/issues/1266)) ([f62bc72](https://github.com/open-feature/flagd/commit/f62bc721e8ebc07e27fbe7b9ca085a8771295d65))
### 🧹 Chore
* refactor evaluation core ([#1259](https://github.com/open-feature/flagd/issues/1259)) ([0e6604c](https://github.com/open-feature/flagd/commit/0e6604cd038dc13d7d40e622523320bf03efbcd0))
* update go deps ([#1279](https://github.com/open-feature/flagd/issues/1279)) ([219789f](https://github.com/open-feature/flagd/commit/219789fca8a929d552e4e8d1f6b6d5cd44505f43))
* wire evaluation ctx to store methods ([#1273](https://github.com/open-feature/flagd/issues/1273)) ([0075932](https://github.com/open-feature/flagd/commit/00759322594f309ca9236156f296805a09f5f9fe))
## [0.8.2](https://github.com/open-feature/flagd/compare/core/v0.8.1...core/v0.8.2) (2024-03-27)
### ✨ New Features
* OFREP support for flagd ([#1247](https://github.com/open-feature/flagd/issues/1247)) ([9d12fc2](https://github.com/open-feature/flagd/commit/9d12fc20702a86e8385564659be88f07ad36d9e5))
## [0.8.1](https://github.com/open-feature/flagd/compare/core/v0.8.0...core/v0.8.1) (2024-03-15)
### 🐛 Bug Fixes
* occasional panic when watched YAML files change ([#1246](https://github.com/open-feature/flagd/issues/1246)) ([6249d12](https://github.com/open-feature/flagd/commit/6249d12ec452073ed881b6e5faf716332c7f132a))
* update protobuff CVE-2024-24786 ([#1249](https://github.com/open-feature/flagd/issues/1249)) ([fd81c23](https://github.com/open-feature/flagd/commit/fd81c235fb4a09dfc42289ac316ac3a1d7eff58c))
### 🧹 Chore
* move packaging & isolate service implementations ([#1234](https://github.com/open-feature/flagd/issues/1234)) ([b58fab3](https://github.com/open-feature/flagd/commit/b58fab3df030ef7e9e10eafa7a0141c05aa05bbd))
## [0.8.0](https://github.com/open-feature/flagd/compare/core/v0.7.5...core/v0.8.0) (2024-02-20)
### ⚠ BREAKING CHANGES
* new proto (flagd.sync.v1) for sync sources ([#1214](https://github.com/open-feature/flagd/issues/1214))
### 🐛 Bug Fixes
* **deps:** update github.com/open-feature/flagd-schemas digest to 8c72c14 ([#1212](https://github.com/open-feature/flagd/issues/1212)) ([4add9fd](https://github.com/open-feature/flagd/commit/4add9fd1c47e3e3dea818a6b262273fadb7edb81))
* **deps:** update kubernetes packages to v0.29.2 ([#1213](https://github.com/open-feature/flagd/issues/1213)) ([b0c805f](https://github.com/open-feature/flagd/commit/b0c805f7f58979f927e60c22c94c0448af459c7d))
* **deps:** update module buf.build/gen/go/open-feature/flagd/connectrpc/go to v1.15.0-20240215170432-1e611e2999cc.1 ([#1219](https://github.com/open-feature/flagd/issues/1219)) ([4c4f08a](https://github.com/open-feature/flagd/commit/4c4f08afabf7d646973768500db028ab0b4c7d68))
* **deps:** update module golang.org/x/crypto to v0.19.0 ([#1203](https://github.com/open-feature/flagd/issues/1203)) ([f0ff317](https://github.com/open-feature/flagd/commit/f0ff3177f67c832d62694cdf44b766344da5483f))
* **deps:** update module golang.org/x/mod to v0.15.0 ([#1202](https://github.com/open-feature/flagd/issues/1202)) ([6ca8e6d](https://github.com/open-feature/flagd/commit/6ca8e6d33f6646698605fb4b5b99f8a3ee1ddbed))
* **deps:** update module golang.org/x/net to v0.21.0 ([#1204](https://github.com/open-feature/flagd/issues/1204)) ([bccf365](https://github.com/open-feature/flagd/commit/bccf365fa2e5f443208ec70b1244bdb4f07ced04))
* **deps:** update module google.golang.org/grpc to v1.61.1 ([#1210](https://github.com/open-feature/flagd/issues/1210)) ([10cc63e](https://github.com/open-feature/flagd/commit/10cc63e7992b4ae8d861f5296afcc78417e645cb))
* **deps:** update opentelemetry-go monorepo ([#1199](https://github.com/open-feature/flagd/issues/1199)) ([422ebaa](https://github.com/open-feature/flagd/commit/422ebaa30b8bb0246bcdf8c0cc2be0a5870eb9e9))
### ✨ New Features
* new proto (flagd.sync.v1) for sync sources ([#1214](https://github.com/open-feature/flagd/issues/1214)) ([544234e](https://github.com/open-feature/flagd/commit/544234ebd9f9be5f54c2865a866575a7869a56c0))
## [0.7.5](https://github.com/open-feature/flagd/compare/core/v0.7.4...core/v0.7.5) (2024-02-05)

View File

@ -1,167 +1,108 @@
module github.com/open-feature/flagd/core
go 1.24.0
toolchain go1.24.4
go 1.20
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
connectrpc.com/otelconnect v0.7.2
github.com/diegoholiveira/jsonlogic/v3 v3.8.4
github.com/fsnotify/fsnotify v1.9.0
github.com/google/go-cmp v0.7.0
github.com/open-feature/flagd-schemas v0.2.9-0.20250707123415-08b4c52d3b86
github.com/open-feature/open-feature-operator/apis v0.2.45
github.com/prometheus/client_golang v1.22.0
buf.build/gen/go/open-feature/flagd/connectrpc/go v1.14.0-20231031123731-ac2ec0f39838.1
buf.build/gen/go/open-feature/flagd/grpc/go v1.3.0-20231031123731-ac2ec0f39838.2
buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.32.0-20231031123731-ac2ec0f39838.1
connectrpc.com/connect v1.14.0
connectrpc.com/otelconnect v0.7.0
github.com/diegoholiveira/jsonlogic/v3 v3.4.0
github.com/fsnotify/fsnotify v1.7.0
github.com/golang/mock v1.6.0
github.com/open-feature/flagd-schemas v0.2.9-0.20240118204143-b98a826737c8
github.com/open-feature/open-feature-operator/apis v0.2.38-0.20231117101310-726a7f714906
github.com/prometheus/client_golang v1.18.0
github.com/robfig/cron v1.2.0
github.com/stretchr/testify v1.10.0
github.com/rs/cors v1.10.1
github.com/rs/xid v1.5.0
github.com/stretchr/testify v1.8.4
github.com/twmb/murmur3 v1.1.8
github.com/xeipuuv/gojsonschema v1.2.0
github.com/zeebo/xxh3 v1.0.2
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.uber.org/mock v0.5.2
go.uber.org/zap v1.27.0
gocloud.dev v0.42.0
golang.org/x/crypto v0.39.0
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac
golang.org/x/mod v0.25.0
golang.org/x/sync v0.15.0
google.golang.org/grpc v1.73.0
google.golang.org/protobuf v1.36.6
go.opentelemetry.io/otel v1.22.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.45.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.22.0
go.opentelemetry.io/otel/exporters/prometheus v0.45.0
go.opentelemetry.io/otel/metric v1.22.0
go.opentelemetry.io/otel/sdk v1.22.0
go.opentelemetry.io/otel/sdk/metric v1.22.0
go.opentelemetry.io/otel/trace v1.22.0
go.uber.org/zap v1.26.0
golang.org/x/crypto v0.18.0
golang.org/x/exp v0.0.0-20231127185646-65229373498e
golang.org/x/mod v0.14.0
golang.org/x/net v0.20.0
golang.org/x/sync v0.5.0
google.golang.org/grpc v1.61.0
google.golang.org/protobuf v1.32.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.33.2
k8s.io/client-go v0.33.2
k8s.io/apimachinery v0.29.1
k8s.io/client-go v0.29.1
)
require (
cel.dev/expr v0.23.0 // 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/iam v1.5.2 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
cloud.google.com/go/storage v1.55.0 // 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
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0 // indirect
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/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/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/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/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/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // 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/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-logr/logr v1.4.3 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/evanphx/json-patch v5.6.0+incompatible // indirect
github.com/go-logr/logr v1.4.1 // 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/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/go-task/slim-sprig v2.20.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // 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/uuid v1.6.0 // 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/jmespath/go-jmespath v0.4.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect
github.com/huandu/xstrings v1.4.0 // indirect
github.com/imdario/mergo v0.3.13 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
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/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/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.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/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/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.41.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/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
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
golang.org/x/oauth2 v0.14.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/term v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.3.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // 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
sigs.k8s.io/gateway-api v1.2.1 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // 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
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/api v0.29.1 // indirect
k8s.io/klog/v2 v2.110.1 // indirect
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
sigs.k8s.io/controller-runtime v0.16.3 // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@ -1,69 +0,0 @@
package certreloader
import (
"crypto/tls"
"fmt"
"sync"
"time"
)
type Config struct {
KeyPath string
CertPath string
ReloadInterval time.Duration
}
type CertReloader struct {
cert *tls.Certificate
mu sync.RWMutex
nextReload time.Time
Config
}
func NewCertReloader(config Config) (*CertReloader, error) {
reloader := CertReloader{
Config: config,
}
reloader.mu.Lock()
defer reloader.mu.Unlock()
cert, err := reloader.loadCertificate()
if err != nil {
return nil, fmt.Errorf("failed to load initial certificate: %w", err)
}
reloader.cert = &cert
return &reloader, nil
}
func (r *CertReloader) GetCertificate() (*tls.Certificate, error) {
now := time.Now()
// Read locking here before we do the time comparison
// If a reload is in progress this will block and we will skip reloading in the current
// call once we can continue
r.mu.RLock()
shouldReload := r.ReloadInterval != 0 && r.nextReload.Before(now)
r.mu.RUnlock()
if shouldReload {
// Need to release the read lock, otherwise we deadlock
r.mu.Lock()
defer r.mu.Unlock()
cert, err := r.loadCertificate()
if err != nil {
return nil, fmt.Errorf("failed to load TLS cert and key: %w", err)
}
r.cert = &cert
r.nextReload = now.Add(r.ReloadInterval)
return r.cert, nil
}
return r.cert, nil
}
func (r *CertReloader) loadCertificate() (tls.Certificate, error) {
newCert, err := tls.LoadX509KeyPair(r.CertPath, r.KeyPath)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to load key pair: %w", err)
}
return newCert, nil
}

View File

@ -1,306 +0,0 @@
package certreloader
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"os"
"testing"
"time"
)
func TestNewCertReloader(t *testing.T) {
cert1, key1, cleanup := generateValidCertificateFiles(t)
defer cleanup()
_, key2, cleanup := generateValidCertificateFiles(t)
defer cleanup()
tcs := []struct {
name string
config Config
err error
}{
{
name: "no config set",
config: Config{},
err: fmt.Errorf("failed to load initial certificate: failed to load key pair: open : no such file or directory"),
},
{
name: "invalid certs",
config: Config{CertPath: cert1, KeyPath: key2},
err: fmt.Errorf("failed to load initial certificate: failed to load key pair: tls: private key does not match public key"),
},
{
name: "valid certs",
config: Config{CertPath: cert1, KeyPath: key1},
err: nil,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
reloader, err := NewCertReloader(tc.config)
if err != nil {
if tc.err == nil {
t.Fatalf("NewCertReloader returned error when no error was expected: %s", err)
} else if tc.err.Error() != err.Error() {
t.Fatalf("expected error did not matched received error. expected: %v, received: %v", tc.err, err)
}
} else {
if reloader == nil {
t.Fatal("expected reloader to not be nil")
}
}
})
}
}
func TestCertificateReload(t *testing.T) {
newCert, newKey, cleanup := generateValidCertificateFiles(t)
defer cleanup()
tcs := []struct {
name string
waitInterval time.Duration
reloadInterval time.Duration
newCert string
newKey string
shouldRotate bool
err error
}{
{
name: "reloads after interval",
waitInterval: time.Microsecond * 200,
reloadInterval: time.Microsecond * 100,
newCert: newCert,
newKey: newKey,
shouldRotate: true,
err: nil,
},
{
name: "doesnt reload before interval",
waitInterval: time.Microsecond * 50,
reloadInterval: time.Microsecond * 100,
newCert: newCert,
newKey: newKey,
shouldRotate: false,
err: nil,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
cert, key, cleanup := generateValidCertificateFiles(t)
defer cleanup()
reloader, err := NewCertReloader(Config{
CertPath: cert,
KeyPath: key,
ReloadInterval: tc.reloadInterval,
})
if err != nil {
t.Fatal(err)
}
if err := copyFile(tc.newCert, cert); err != nil {
t.Fatalf("failed to move %s -> %s: %s", newCert, cert, err)
}
if err := copyFile(tc.newKey, key); err != nil {
t.Fatalf("failed to move %s -> %s: %s", newKey, key, err)
}
time.Sleep(tc.waitInterval)
actualCert, err := reloader.GetCertificate()
if err != nil {
t.Fatal(err)
}
actualCertParsed, err := x509.ParseCertificate(actualCert.Certificate[0])
if err != nil {
t.Fatal(err)
}
var expectedCert tls.Certificate
if tc.shouldRotate {
expectedCert, err = tls.LoadX509KeyPair(tc.newCert, tc.newKey)
if err != nil {
t.Fatal(err)
}
} else {
expectedCert, err = tls.LoadX509KeyPair(cert, key)
if err != nil {
t.Fatal(err)
}
}
expectedCertParsed, err := x509.ParseCertificate(expectedCert.Certificate[0])
if err != nil {
t.Fatal(err)
}
if expectedCertParsed.DNSNames[0] != actualCertParsed.DNSNames[0] {
t.Fatalf("expected certificate was not returned by GetCertificate. expectedCert: %v, actualCert: %v", expectedCertParsed.DNSNames[0], actualCertParsed.DNSNames[0])
}
})
}
}
func generateValidCertificate(t *testing.T) (*bytes.Buffer, *bytes.Buffer) {
t.Helper()
// set up our CA certificate
ca := &x509.Certificate{
SerialNumber: big.NewInt(2019),
Subject: pkix.Name{
Organization: []string{"Company, INC."},
Country: []string{"US"},
Province: []string{""},
Locality: []string{"San Francisco"},
StreetAddress: []string{"Golden Gate Bridge"},
PostalCode: []string{"94016"},
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(10, 0, 0),
IsCA: true,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
// create our private and public key
caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
t.Fatal(err)
}
// create the CA
caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey)
if err != nil {
t.Fatal(err)
}
// pem encode
caPEM := new(bytes.Buffer)
err = pem.Encode(caPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: caBytes,
})
if err != nil {
t.Fatal(err)
}
caPrivKeyPEM := new(bytes.Buffer)
err = pem.Encode(caPrivKeyPEM, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(caPrivKey),
})
if err != nil {
t.Fatal(err)
}
// set up our server certificate
cert := &x509.Certificate{
SerialNumber: big.NewInt(2019),
Subject: pkix.Name{
Organization: []string{"Company, INC."},
Country: []string{"US"},
Province: []string{""},
Locality: []string{"San Francisco"},
StreetAddress: []string{"Golden Gate Bridge"},
PostalCode: []string{"94016"},
},
DNSNames: []string{randString(8)},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(10, 0, 0),
SubjectKeyId: []byte{1, 2, 3, 4, 6},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature,
}
certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
t.Fatalf("failed to create private key: %s", err)
}
certBytes, err := x509.CreateCertificate(rand.Reader, cert, ca, &certPrivKey.PublicKey, caPrivKey)
if err != nil {
t.Fatalf("failed to create certificate: %s", err)
}
certPEM := new(bytes.Buffer)
err = pem.Encode(certPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
if err != nil {
t.Fatal(err)
}
certPrivKeyPEM := new(bytes.Buffer)
err = pem.Encode(certPrivKeyPEM, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey),
})
if err != nil {
t.Fatal(err)
}
return certPEM, certPrivKeyPEM
}
func generateValidCertificateFiles(t *testing.T) (string, string, func()) {
t.Helper()
certFile, err := os.CreateTemp("", "certreloader_cert")
if err != nil {
t.Fatalf("failed to create certFile: %s", err)
}
defer certFile.Close()
keyFile, err := os.CreateTemp("", "certreloader_key")
if err != nil {
t.Fatalf("failed to create keyFile: %s", err)
}
defer keyFile.Close()
certBytes, keyBytes := generateValidCertificate(t)
if _, err := io.Copy(certFile, certBytes); err != nil {
t.Fatalf("failed to copy certBytes into %s: %s", certFile.Name(), err)
}
if _, err := io.Copy(keyFile, keyBytes); err != nil {
t.Fatalf("failed to copy keyBytes into %s: %s", keyFile.Name(), err)
}
return certFile.Name(), keyFile.Name(), func() {
os.Remove(certFile.Name())
os.Remove(keyFile.Name())
}
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("failed to load key pair: %w", err)
}
err = os.WriteFile(dst, data, 0o0600)
if err != nil {
return fmt.Errorf("failed to load key pair: %w", err)
}
return nil
}
func randString(n int) string {
const alphanum = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
bytes := make([]byte, n)
//nolint:errcheck
rand.Read(bytes)
for i, b := range bytes {
bytes[i] = alphanum[b%byte(len(alphanum))]
}
return string(bytes)
}

View File

@ -16,21 +16,8 @@ type Fractional struct {
}
type fractionalEvaluationDistribution struct {
totalWeight int
weightedVariants []fractionalEvaluationVariant
}
type fractionalEvaluationVariant struct {
variant string
weight int
}
func (v fractionalEvaluationVariant) getPercentage(totalWeight int) float64 {
if totalWeight == 0 {
return 0
}
return 100 * float64(v.weight) / float64(totalWeight)
variant string
percentage int
}
func NewFractional(logger *logger.Logger) *Fractional {
@ -40,14 +27,14 @@ func NewFractional(logger *logger.Logger) *Fractional {
func (fe *Fractional) Evaluate(values, data any) any {
valueToDistribute, feDistributions, err := parseFractionalEvaluationData(values, data)
if err != nil {
fe.Logger.Warn(fmt.Sprintf("parse fractional evaluation data: %v", err))
fe.Logger.Error(fmt.Sprintf("parse fractional evaluation data: %v", err))
return nil
}
return distributeValue(valueToDistribute, feDistributions)
}
func parseFractionalEvaluationData(values, data any) (string, *fractionalEvaluationDistribution, error) {
func parseFractionalEvaluationData(values, data any) (string, []fractionalEvaluationDistribution, error) {
valuesArray, ok := values.([]any)
if !ok {
return "", nil, errors.New("fractional evaluation data is not an array")
@ -69,17 +56,10 @@ func parseFractionalEvaluationData(values, data any) (string, *fractionalEvaluat
if ok {
valuesArray = valuesArray[1:]
} else {
// check for nil here as custom property could be nil/missing
if valuesArray[0] == nil {
valuesArray = valuesArray[1:]
}
targetingKey, ok := dataMap[targetingKeyKey].(string)
bucketBy, ok = dataMap[targetingKeyKey].(string)
if !ok {
return "", nil, errors.New("bucketing value not supplied and no targetingKey in context")
}
bucketBy = fmt.Sprintf("%s%s", properties.FlagKey, targetingKey)
}
feDistributions, err := parseFractionalEvaluationDistributions(valuesArray)
@ -87,23 +67,20 @@ func parseFractionalEvaluationData(values, data any) (string, *fractionalEvaluat
return "", nil, err
}
return bucketBy, feDistributions, nil
return fmt.Sprintf("%s%s", properties.FlagKey, bucketBy), feDistributions, nil
}
func parseFractionalEvaluationDistributions(values []any) (*fractionalEvaluationDistribution, error) {
feDistributions := &fractionalEvaluationDistribution{
totalWeight: 0,
weightedVariants: make([]fractionalEvaluationVariant, len(values)),
}
func parseFractionalEvaluationDistributions(values []any) ([]fractionalEvaluationDistribution, error) {
sumOfPercentages := 0
var feDistributions []fractionalEvaluationDistribution
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, errors.New("distribution elements aren't of type []any")
}
if len(distributionArray) == 0 {
return nil, errors.New("distribution element needs at least one element")
if len(distributionArray) != 2 {
return nil, errors.New("distribution element isn't length 2")
}
variant, ok := distributionArray[0].(string)
@ -111,36 +88,37 @@ func parseFractionalEvaluationDistributions(values []any) (*fractionalEvaluation
return nil, errors.New("first element of distribution element isn't string")
}
weight := 1.0
if len(distributionArray) >= 2 {
distributionWeight, ok := distributionArray[1].(float64)
if ok {
// default the weight to 1 if not specified explicitly
weight = distributionWeight
}
percentage, ok := distributionArray[1].(float64)
if !ok {
return nil, errors.New("second element of distribution element isn't float")
}
feDistributions.totalWeight += int(weight)
feDistributions.weightedVariants[i] = fractionalEvaluationVariant{
variant: variant,
weight: int(weight),
}
sumOfPercentages += int(percentage)
feDistributions = append(feDistributions, fractionalEvaluationDistribution{
variant: variant,
percentage: int(percentage),
})
}
if sumOfPercentages != 100 {
return nil, fmt.Errorf("percentages must sum to 100, got: %d", sumOfPercentages)
}
return feDistributions, nil
}
// distributeValue calculate hash for given hash key and find the bucket distributions belongs to
func distributeValue(value string, feDistribution *fractionalEvaluationDistribution) string {
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]
bucket := int(hashRatio * 100) // in range [0, 100]
rangeEnd := float64(0)
for _, weightedVariant := range feDistribution.weightedVariants {
rangeEnd += weightedVariant.getPercentage(feDistribution.totalWeight)
rangeEnd := 0
for _, dist := range feDistribution {
rangeEnd += dist.percentage
if bucket < rangeEnd {
return weightedVariant.variant
return dist.variant
}
}

View File

@ -1,21 +1,15 @@
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"
)
func TestFractionalEvaluation(t *testing.T) {
const source = "testSource"
var sources = []string{source}
ctx := context.Background()
commonFlags := Flags{
flags := Flags{
Flags: map[string]model.Flag{
"headerColor": {
State: "ENABLED",
@ -35,7 +29,7 @@ func TestFractionalEvaluation(t *testing.T) {
},
{
"fractional": [
{"cat": [{"var": "$flagd.flagKey"}, {"var": "email"}]},
{"var": "email"},
[
"red",
25
@ -57,34 +51,6 @@ func TestFractionalEvaluation(t *testing.T) {
]
}`),
},
"customSeededHeaderColor": {
State: "ENABLED",
DefaultVariant: "red",
Variants: map[string]any{
"red": "#FF0000",
"blue": "#0000FF",
"green": "#00FF00",
"yellow": "#FFFF00",
},
Targeting: []byte(`{
"if": [
{
"in": ["@faas.com", {
"var": ["email"]
}]
},
{
"fractional": [
{"cat": ["my-seed", {"var": "email"}]},
["red",25],
["blue",25],
["green",25],
["yellow",25]
]
}, null
]
}`),
},
},
}
@ -98,7 +64,7 @@ func TestFractionalEvaluation(t *testing.T) {
expectedErrorCode string
}{
"rachel@faas.com": {
flags: commonFlags,
flags: flags,
flagKey: "headerColor",
context: map[string]any{
"email": "rachel@faas.com",
@ -108,7 +74,7 @@ func TestFractionalEvaluation(t *testing.T) {
expectedReason: model.TargetingMatchReason,
},
"monica@faas.com": {
flags: commonFlags,
flags: flags,
flagKey: "headerColor",
context: map[string]any{
"email": "monica@faas.com",
@ -118,7 +84,7 @@ func TestFractionalEvaluation(t *testing.T) {
expectedReason: model.TargetingMatchReason,
},
"joey@faas.com": {
flags: commonFlags,
flags: flags,
flagKey: "headerColor",
context: map[string]any{
"email": "joey@faas.com",
@ -128,7 +94,7 @@ func TestFractionalEvaluation(t *testing.T) {
expectedReason: model.TargetingMatchReason,
},
"ross@faas.com": {
flags: commonFlags,
flags: flags,
flagKey: "headerColor",
context: map[string]any{
"email": "ross@faas.com",
@ -137,46 +103,6 @@ func TestFractionalEvaluation(t *testing.T) {
expectedValue: "#00FF00",
expectedReason: model.TargetingMatchReason,
},
"rachel@faas.com with custom seed": {
flags: commonFlags,
flagKey: "customSeededHeaderColor",
context: map[string]any{
"email": "rachel@faas.com",
},
expectedVariant: "green",
expectedValue: "#00FF00",
expectedReason: model.TargetingMatchReason,
},
"monica@faas.com with custom seed": {
flags: commonFlags,
flagKey: "customSeededHeaderColor",
context: map[string]any{
"email": "monica@faas.com",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"joey@faas.com with custom seed": {
flags: commonFlags,
flagKey: "customSeededHeaderColor",
context: map[string]any{
"email": "joey@faas.com",
},
expectedVariant: "green",
expectedValue: "#00FF00",
expectedReason: model.TargetingMatchReason,
},
"ross@faas.com with custom seed": {
flags: commonFlags,
flagKey: "customSeededHeaderColor",
context: map[string]any{
"email": "ross@faas.com",
},
expectedVariant: "green",
expectedValue: "#00FF00",
expectedReason: model.TargetingMatchReason,
},
"ross@faas.com with different flag key": {
flags: Flags{
Flags: map[string]model.Flag{
@ -321,7 +247,7 @@ func TestFractionalEvaluation(t *testing.T) {
expectedValue: "#FF0000",
expectedReason: model.DefaultReason,
},
"get variant for non-percentage weight values": {
"fallback to default variant if percentages don't sum to 100": {
flags: Flags{
Flags: map[string]model.Flag{
"headerColor": {
@ -355,41 +281,7 @@ func TestFractionalEvaluation(t *testing.T) {
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"get variant for non-specified weight values": {
flags: Flags{
Flags: map[string]model.Flag{
"headerColor": {
State: "ENABLED",
DefaultVariant: "red",
Variants: map[string]any{
"red": "#FF0000",
"blue": "#0000FF",
"green": "#00FF00",
"yellow": "#FFFF00",
},
Targeting: []byte(`{
"fractional": [
{"var": "email"},
[
"red"
],
[
"blue"
]
]
}`),
},
},
},
flagKey: "headerColor",
context: map[string]any{
"email": "foo@foo.com",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
expectedReason: model.DefaultReason,
},
"default to targetingKey if no bucket key provided": {
flags: Flags{
@ -426,49 +318,22 @@ func TestFractionalEvaluation(t *testing.T) {
expectedValue: "#0000FF",
expectedReason: model.TargetingMatchReason,
},
"missing email - parser should ignore nil/missing custom variables and continue": {
flags: Flags{
Flags: map[string]model.Flag{
"headerColor": {
State: "ENABLED",
DefaultVariant: "red",
Variants: map[string]any{
"red": "#FF0000",
"blue": "#0000FF",
},
Targeting: []byte(
`{
"fractional": [
{"var": "email"},
["red",50],
["blue",50]
]
}`),
},
},
},
flagKey: "headerColor",
context: map[string]any{
"targetingKey": "foo@foo.com",
},
expectedVariant: "red",
expectedValue: "#FF0000",
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)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
je := NewJSON(
log,
store.NewFlags(),
WithEvaluator(
FractionEvaluationName,
NewFractional(log).Evaluate,
),
)
je.store.Flags = tt.flags.Flags
je := NewJSON(log, s)
je.store.Update(source, tt.flags.Flags, model.Metadata{})
value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant)
value, variant, reason, _, err := resolve[string](reqID, tt.flagKey, tt.context, je.evaluateVariant)
if value != tt.expectedValue {
t.Errorf("expected value '%s', got '%s'", tt.expectedValue, value)
@ -493,10 +358,6 @@ func TestFractionalEvaluation(t *testing.T) {
}
func BenchmarkFractionalEvaluation(b *testing.B) {
const source = "testSource"
var sources = []string{source}
ctx := context.Background()
flags := Flags{
Flags: map[string]model.Flag{
"headerColor": {
@ -517,7 +378,7 @@ func BenchmarkFractionalEvaluation(b *testing.B) {
},
{
"fractional": [
{"var": "email"},
"email",
[
"red",
25
@ -551,41 +412,41 @@ func BenchmarkFractionalEvaluation(b *testing.B) {
expectedReason string
expectedErrorCode string
}{
"test_a@faas.com": {
"test@faas.com": {
flags: flags,
flagKey: "headerColor",
context: map[string]any{
"email": "test_a@faas.com",
},
expectedVariant: "blue",
expectedValue: "#0000FF",
expectedReason: model.TargetingMatchReason,
},
"test_b@faas.com": {
flags: flags,
flagKey: "headerColor",
context: map[string]any{
"email": "test_b@faas.com",
"email": "test@faas.com",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"test_c@faas.com": {
"test2@faas.com": {
flags: flags,
flagKey: "headerColor",
context: map[string]any{
"email": "test_c@faas.com",
"email": "test2@faas.com",
},
expectedVariant: "green",
expectedValue: "#00FF00",
expectedVariant: "yellow",
expectedValue: "#FFFF00",
expectedReason: model.TargetingMatchReason,
},
"test_d@faas.com": {
"test3@faas.com": {
flags: flags,
flagKey: "headerColor",
context: map[string]any{
"email": "test_d@faas.com",
"email": "test3@faas.com",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"test4@faas.com": {
flags: flags,
flagKey: "headerColor",
context: map[string]any{
"email": "test4@faas.com",
},
expectedVariant: "blue",
expectedValue: "#0000FF",
@ -596,16 +457,16 @@ func BenchmarkFractionalEvaluation(b *testing.B) {
for name, tt := range tests {
b.Run(name, func(b *testing.B) {
log := logger.NewLogger(nil, false)
s, err := store.NewStore(log, sources)
if err != nil {
b.Fatalf("NewStore failed: %v", err)
}
je := NewJSON(log, s)
je.store.Update(source, tt.flags.Flags, model.Metadata{})
je := NewJSON(
log,
&store.Flags{Flags: tt.flags.Flags},
WithEvaluator(
FractionEvaluationName,
NewFractional(log).Evaluate,
),
)
for i := 0; i < b.N; i++ {
value, variant, reason, _, err := resolve[string](
ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant)
value, variant, reason, _, err := resolve[string](reqID, tt.flagKey, tt.context, je.evaluateVariant)
if value != tt.expectedValue {
b.Errorf("expected value '%s', got '%s'", tt.expectedValue, value)
@ -629,49 +490,3 @@ func BenchmarkFractionalEvaluation(b *testing.B) {
})
}
}
func Test_fractionalEvaluationVariant_getPercentage(t *testing.T) {
type fields struct {
variant string
weight int
}
type args struct {
totalWeight int
}
tests := []struct {
name string
fields fields
args args
want float64
}{
{
name: "get percentage",
fields: fields{
weight: 10,
},
args: args{
totalWeight: 20,
},
want: 50,
},
{
name: "total weight 0",
fields: fields{
weight: 10,
},
args: args{
totalWeight: 0,
},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := fractionalEvaluationVariant{
variant: tt.fields.variant,
weight: tt.fields.weight,
}
assert.Equalf(t, tt.want, v.getPercentage(tt.args.totalWeight), "getPercentage(%v)", tt.args.totalWeight)
})
}
}

View File

@ -3,7 +3,6 @@ package evaluator
import (
"context"
"github.com/open-feature/flagd/core/pkg/model"
"github.com/open-feature/flagd/core/pkg/sync"
)
@ -12,12 +11,12 @@ type AnyValue struct {
Variant string
Reason string
FlagKey string
Metadata model.Metadata
Metadata map[string]interface{}
Error error
}
func NewAnyValue(
value interface{}, variant string, reason string, flagKey string, metadata model.Metadata,
value interface{}, variant string, reason string, flagKey string, metadata map[string]interface{},
err error,
) AnyValue {
return AnyValue{
@ -31,52 +30,44 @@ func NewAnyValue(
}
/*
IEvaluator is an extension of IResolver, allowing storage updates and retrievals
IEvaluator implementations store the state of the flags,
do parsing and validation of the flag state and evaluate flags in response to handlers.
*/
type IEvaluator interface {
GetState() (string, error)
SetState(payload sync.DataSync) (map[string]interface{}, bool, error)
IResolver
}
// IResolver focuses on resolving of the known flags
type IResolver interface {
ResolveBooleanValue(
ctx context.Context,
reqID string,
flagKey string,
context map[string]any) (value bool, variant string, reason string, metadata model.Metadata, err error)
context map[string]any) (value bool, variant string, reason string, metadata map[string]interface{}, err error)
ResolveStringValue(
ctx context.Context,
reqID string,
flagKey string,
context map[string]any) (
value string, variant string, reason string, metadata model.Metadata, err error)
value string, variant string, reason string, metadata map[string]interface{}, err error)
ResolveIntValue(
ctx context.Context,
reqID string,
flagKey string,
context map[string]any) (
value int64, variant string, reason string, metadata model.Metadata, err error)
value int64, variant string, reason string, metadata map[string]interface{}, err error)
ResolveFloatValue(
ctx context.Context,
reqID string,
flagKey string,
context map[string]any) (
value float64, variant string, reason string, metadata model.Metadata, err error)
value float64, variant string, reason string, metadata map[string]interface{}, err error)
ResolveObjectValue(
ctx context.Context,
reqID string,
flagKey string,
context map[string]any) (
value map[string]any, variant string, reason string, metadata model.Metadata, err error)
ResolveAsAnyValue(
ctx context.Context,
reqID string,
flagKey string,
context map[string]any) AnyValue
value map[string]any, variant string, reason string, metadata map[string]interface{}, err error)
ResolveAllValues(
ctx context.Context,
reqID string,
context map[string]any) (resolutions []AnyValue, metadata model.Metadata, err error)
context map[string]any) (values []AnyValue)
}

View File

@ -28,60 +28,58 @@ import (
const (
SelectorMetadataKey = "scope"
flagdPropertiesKey = "$flagd"
flagdPropertiesKey = "$flagd"
// targetingKeyKey is used to extract the targetingKey to bucket on in fractional
// evaluation if the user did not supply the optional bucketing property.
targetingKeyKey = "targetingKey"
Disabled = "DISABLED"
)
var regBrace *regexp.Regexp
func init() {
regBrace = regexp.MustCompile("^[^{]*{|}[^}]*$")
}
type constraints interface {
bool | string | map[string]any | float64 | interface{}
}
type JSONEvaluatorOption func(je *JSON)
type flagdProperties struct {
FlagKey string `json:"flagKey"`
Timestamp int64 `json:"timestamp"`
}
type variantEvaluator func(context.Context, string, string, map[string]any) (
func init() {
regBrace = regexp.MustCompile("^[^{]*{|}[^}]*$")
}
type variantEvaluator func(string, string, map[string]any) (
variant string, variants map[string]interface{}, reason string, metadata map[string]interface{}, error error)
// Deprecated - this will be remove in the next release
type JSON struct {
store *store.Flags
Logger *logger.Logger
jsonEvalTracer trace.Tracer
}
type constraints interface {
bool | string | map[string]any | float64
}
const (
Disabled = "DISABLED"
)
type JSONEvaluatorOption func(je *JSON)
func WithEvaluator(name string, evalFunc func(interface{}, interface{}) interface{}) JSONEvaluatorOption {
return func(_ *JSON) {
jsonlogic.AddOperator(name, evalFunc)
}
}
// JSON evaluator
type JSON struct {
store *store.Store
Logger *logger.Logger
jsonEvalTracer trace.Tracer
Resolver
}
func NewJSON(logger *logger.Logger, s *store.Store, opts ...JSONEvaluatorOption) *JSON {
logger = logger.WithFields(
zap.String("component", "evaluator"),
zap.String("evaluator", "json"),
)
tracer := otel.Tracer("jsonEvaluator")
func NewJSON(logger *logger.Logger, s *store.Flags, opts ...JSONEvaluatorOption) *JSON {
ev := JSON{
Logger: logger.WithFields(
zap.String("component", "evaluator"),
zap.String("evaluator", "json"),
),
store: s,
Logger: logger,
jsonEvalTracer: tracer,
Resolver: NewResolver(s, logger, tracer),
jsonEvalTracer: otel.Tracer("jsonEvaluator"),
}
for _, o := range opts {
@ -103,12 +101,13 @@ func (je *JSON) SetState(payload sync.DataSync) (map[string]interface{}, bool, e
_, span := je.jsonEvalTracer.Start(
context.Background(),
"flagSync",
trace.WithAttributes(attribute.String("feature_flag.source", payload.Source)))
trace.WithAttributes(attribute.String("feature_flag.source", payload.Source)),
trace.WithAttributes(attribute.String("feature_flag.sync_type", payload.Type.String())))
defer span.End()
var definition Definition
var newFlags Flags
err := configToFlagDefinition(je.Logger, payload.FlagData, &definition)
err := je.configToFlags(payload.FlagData, &newFlags)
if err != nil {
span.SetStatus(codes.Error, "flagSync error")
span.RecordError(err)
@ -118,7 +117,18 @@ func (je *JSON) SetState(payload sync.DataSync) (map[string]interface{}, bool, e
var events map[string]interface{}
var reSync bool
events, reSync = je.store.Update(payload.Source, definition.Flags, definition.Metadata)
switch payload.Type {
case sync.ALL:
events, reSync = je.store.Merge(je.Logger, payload.Source, newFlags.Flags)
case sync.ADD:
events = je.store.Add(je.Logger, payload.Source, newFlags.Flags)
case sync.UPDATE:
events = je.store.Update(je.Logger, payload.Source, newFlags.Flags)
case sync.DELETE:
events = je.store.DeleteFlags(je.Logger, payload.Source, newFlags.Flags)
default:
return nil, false, fmt.Errorf("unsupported sync type: %d", payload.Type)
}
// Number of events correlates to the number of flags changed through this sync, record it
span.SetAttributes(attribute.Int("feature_flag.change_count", len(events)))
@ -126,45 +136,17 @@ func (je *JSON) SetState(payload sync.DataSync) (map[string]interface{}, bool, e
return events, reSync, nil
}
// Resolver implementation for flagd flags. This resolver should be kept reusable, hence must interact with interfaces.
type Resolver struct {
store store.IStore
Logger *logger.Logger
tracer trace.Tracer
}
func NewResolver(store store.IStore, logger *logger.Logger, jsonEvalTracer trace.Tracer) Resolver {
// register supported json logic custom operator implementations
jsonlogic.AddOperator(FractionEvaluationName, NewFractional(logger).Evaluate)
jsonlogic.AddOperator(StartsWithEvaluationName, NewStringComparisonEvaluator(logger).StartsWithEvaluation)
jsonlogic.AddOperator(EndsWithEvaluationName, NewStringComparisonEvaluator(logger).EndsWithEvaluation)
jsonlogic.AddOperator(SemVerEvaluationName, NewSemVerComparison(logger).SemVerEvaluation)
return Resolver{store: store, Logger: logger, tracer: jsonEvalTracer}
}
func (je *Resolver) ResolveAllValues(ctx context.Context, reqID string, context map[string]any) ([]AnyValue,
model.Metadata, error,
) {
_, span := je.tracer.Start(ctx, "resolveAll")
func (je *JSON) ResolveAllValues(ctx context.Context, reqID string, context map[string]any) []AnyValue {
_, span := je.jsonEvalTracer.Start(ctx, "resolveAll")
defer span.End()
var selector store.Selector
s := ctx.Value(store.SelectorContextKey{})
if s != nil {
selector = s.(store.Selector)
}
allFlags, flagSetMetadata, err := je.store.GetAll(ctx, &selector)
if err != nil {
return nil, flagSetMetadata, fmt.Errorf("error retreiving flags from the store: %w", err)
}
values := []AnyValue{}
var value interface{}
var variant string
var reason string
var metadata map[string]interface{}
var err error
allFlags := je.store.GetAll()
for flagKey, flag := range allFlags {
if flag.State == Disabled {
// ignore evaluation of disabled flag
@ -174,24 +156,43 @@ func (je *Resolver) ResolveAllValues(ctx context.Context, reqID string, context
defaultValue := flag.Variants[flag.DefaultVariant]
switch defaultValue.(type) {
case bool:
value, variant, reason, metadata, err = resolve[bool](ctx, reqID, flagKey, context, je.evaluateVariant)
value, variant, reason, metadata, err = resolve[bool](
reqID,
flagKey,
context,
je.evaluateVariant,
)
case string:
value, variant, reason, metadata, err = resolve[string](ctx, reqID, flagKey, context, je.evaluateVariant)
value, variant, reason, metadata, err = resolve[string](
reqID,
flagKey,
context,
je.evaluateVariant,
)
case float64:
value, variant, reason, metadata, err = resolve[float64](ctx, reqID, flagKey, context, je.evaluateVariant)
value, variant, reason, metadata, err = resolve[float64](
reqID,
flagKey,
context,
je.evaluateVariant,
)
case map[string]any:
value, variant, reason, metadata, err = resolve[map[string]any](ctx, reqID, flagKey, context, je.evaluateVariant)
value, variant, reason, metadata, err = resolve[map[string]any](
reqID,
flagKey,
context,
je.evaluateVariant,
)
}
if err != nil {
je.Logger.ErrorWithID(reqID, fmt.Sprintf("bulk evaluation: key: %s returned error: %s", flagKey, err.Error()))
}
values = append(values, NewAnyValue(value, variant, reason, flagKey, metadata, err))
}
return values, flagSetMetadata, nil
return values
}
func (je *Resolver) ResolveBooleanValue(
func (je *JSON) ResolveBooleanValue(
ctx context.Context, reqID string, flagKey string, context map[string]any) (
value bool,
variant string,
@ -199,14 +200,14 @@ func (je *Resolver) ResolveBooleanValue(
metadata map[string]interface{},
err error,
) {
_, span := je.tracer.Start(ctx, "resolveBoolean")
_, span := je.jsonEvalTracer.Start(ctx, "resolveBoolean")
defer span.End()
je.Logger.DebugWithID(reqID, fmt.Sprintf("evaluating boolean flag: %s", flagKey))
return resolve[bool](ctx, reqID, flagKey, context, je.evaluateVariant)
return resolve[bool](reqID, flagKey, context, je.evaluateVariant)
}
func (je *Resolver) ResolveStringValue(
func (je *JSON) ResolveStringValue(
ctx context.Context, reqID string, flagKey string, context map[string]any) (
value string,
variant string,
@ -214,14 +215,14 @@ func (je *Resolver) ResolveStringValue(
metadata map[string]interface{},
err error,
) {
_, span := je.tracer.Start(ctx, "resolveString")
_, span := je.jsonEvalTracer.Start(ctx, "resolveString")
defer span.End()
je.Logger.DebugWithID(reqID, fmt.Sprintf("evaluating string flag: %s", flagKey))
return resolve[string](ctx, reqID, flagKey, context, je.evaluateVariant)
return resolve[string](reqID, flagKey, context, je.evaluateVariant)
}
func (je *Resolver) ResolveFloatValue(
func (je *JSON) ResolveFloatValue(
ctx context.Context, reqID string, flagKey string, context map[string]any) (
value float64,
variant string,
@ -229,32 +230,32 @@ func (je *Resolver) ResolveFloatValue(
metadata map[string]interface{},
err error,
) {
_, span := je.tracer.Start(ctx, "resolveFloat")
_, span := je.jsonEvalTracer.Start(ctx, "resolveFloat")
defer span.End()
je.Logger.DebugWithID(reqID, fmt.Sprintf("evaluating float flag: %s", flagKey))
value, variant, reason, metadata, err = resolve[float64](ctx, reqID, flagKey, context, je.evaluateVariant)
value, variant, reason, metadata, err = resolve[float64](reqID, flagKey, context, je.evaluateVariant)
return
}
func (je *Resolver) ResolveIntValue(ctx context.Context, reqID string, flagKey string, context map[string]any) (
func (je *JSON) ResolveIntValue(ctx context.Context, reqID string, flagKey string, context map[string]any) (
value int64,
variant string,
reason string,
metadata map[string]interface{},
err error,
) {
_, span := je.tracer.Start(ctx, "resolveInt")
_, span := je.jsonEvalTracer.Start(ctx, "resolveInt")
defer span.End()
je.Logger.DebugWithID(reqID, fmt.Sprintf("evaluating int flag: %s", flagKey))
var val float64
val, variant, reason, metadata, err = resolve[float64](ctx, reqID, flagKey, context, je.evaluateVariant)
val, variant, reason, metadata, err = resolve[float64](reqID, flagKey, context, je.evaluateVariant)
value = int64(val)
return
}
func (je *Resolver) ResolveObjectValue(
func (je *JSON) ResolveObjectValue(
ctx context.Context, reqID string, flagKey string, context map[string]any) (
value map[string]any,
variant string,
@ -262,32 +263,17 @@ func (je *Resolver) ResolveObjectValue(
metadata map[string]interface{},
err error,
) {
_, span := je.tracer.Start(ctx, "resolveObject")
_, span := je.jsonEvalTracer.Start(ctx, "resolveObject")
defer span.End()
je.Logger.DebugWithID(reqID, fmt.Sprintf("evaluating object flag: %s", flagKey))
return resolve[map[string]any](ctx, reqID, flagKey, context, je.evaluateVariant)
return resolve[map[string]any](reqID, flagKey, context, je.evaluateVariant)
}
func (je *Resolver) ResolveAsAnyValue(
ctx context.Context,
reqID string,
flagKey string,
context map[string]any,
) AnyValue {
_, span := je.tracer.Start(ctx, "resolveAnyValue")
defer span.End()
je.Logger.DebugWithID(reqID, fmt.Sprintf("evaluating flag `%s` as a generic flag", flagKey))
value, variant, reason, meta, err := resolve[interface{}](ctx, reqID, flagKey, context, je.evaluateVariant)
return NewAnyValue(value, variant, reason, flagKey, meta, err)
}
// resolve is a helper for generic flag resolving
func resolve[T constraints](ctx context.Context, reqID string, key string, context map[string]any,
variantEval variantEvaluator) (value T, variant string, reason string, metadata map[string]interface{}, err error,
func resolve[T constraints](reqID string, key string, context map[string]any, variantEval variantEvaluator) (
value T, variant string, reason string, metadata map[string]interface{}, err error,
) {
variant, variants, reason, metadata, err := variantEval(ctx, reqID, key, context)
variant, variants, reason, metadata, err := variantEval(reqID, key, context)
if err != nil {
return value, variant, reason, metadata, err
}
@ -301,28 +287,24 @@ func resolve[T constraints](ctx context.Context, reqID string, key string, conte
return value, variant, reason, metadata, nil
}
// runs the rules (if defined) to determine the variant, otherwise falling through to the default
// nolint: funlen
func (je *Resolver) evaluateVariant(ctx context.Context, reqID string, flagKey string, evalCtx map[string]any) (
func (je *JSON) evaluateVariant(reqID string, flagKey string, context map[string]any) (
variant string, variants map[string]interface{}, reason string, metadata map[string]interface{}, err error,
) {
metadata = map[string]interface{}{}
var selector store.Selector
s := ctx.Value(store.SelectorContextKey{})
if s != nil {
selector = s.(store.Selector)
}
flag, metadata, err := je.store.Get(ctx, flagKey, &selector)
if err != nil {
flag, ok := je.store.Get(flagKey)
if !ok {
// flag not found
je.Logger.DebugWithID(reqID, fmt.Sprintf("requested flag could not be found: %s", flagKey))
return "", map[string]interface{}{}, model.ErrorReason, metadata, errors.New(model.FlagNotFoundErrorCode)
}
for key, value := range flag.Metadata {
// If value is not nil or empty, copy to metadata
if value != nil {
metadata[key] = value
}
// add selector to evaluation metadata
selector := je.store.SelectorForFlag(flag)
if selector != "" {
metadata[SelectorMetadataKey] = selector
}
if flag.State == Disabled {
@ -340,14 +322,14 @@ func (je *Resolver) evaluateVariant(ctx context.Context, reqID string, flagKey s
return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.ParseErrorCode)
}
evalCtx = setFlagdProperties(je.Logger, evalCtx, flagdProperties{
context = je.setFlagdProperties(context, flagdProperties{
FlagKey: flagKey,
Timestamp: time.Now().Unix(),
})
b, err := json.Marshal(evalCtx)
b, err := json.Marshal(context)
if err != nil {
je.Logger.ErrorWithID(reqID, fmt.Sprintf("error parsing context for flag: %s, %s, %v", flagKey, err, evalCtx))
je.Logger.ErrorWithID(reqID, fmt.Sprintf("error parsing context for flag: %s, %s, %v", flagKey, err, context))
return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.ErrorReason)
}
@ -356,18 +338,13 @@ func (je *Resolver) evaluateVariant(ctx context.Context, reqID string, flagKey s
// evaluate JsonLogic rules to determine the variant
err = jsonlogic.Apply(bytes.NewReader(targetingBytes), bytes.NewReader(b), &result)
if err != nil {
je.Logger.ErrorWithID(reqID, fmt.Sprintf("error applying targeting rules: %s", err))
je.Logger.ErrorWithID(reqID, fmt.Sprintf("error applying rules: %s", err))
return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.ParseErrorCode)
}
// check if string is "null" before we strip quotes, so we can differentiate between JSON null and "null"
trimmed := strings.TrimSpace(result.String())
if trimmed == "null" {
if flag.DefaultVariant == "" {
return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.FlagNotFoundErrorCode)
}
return flag.DefaultVariant, flag.Variants, model.DefaultReason, metadata, nil
}
@ -380,18 +357,12 @@ func (je *Resolver) evaluateVariant(ctx context.Context, reqID string, flagKey s
}
je.Logger.ErrorWithID(reqID,
fmt.Sprintf("invalid or missing variant: %s for flagKey: %s, variant is not valid", variant, flagKey))
return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.GeneralErrorCode)
return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.ParseErrorCode)
}
if flag.DefaultVariant == "" {
return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.FlagNotFoundErrorCode)
}
return flag.DefaultVariant, flag.Variants, model.StaticReason, metadata, nil
}
func setFlagdProperties(
log *logger.Logger,
func (je *JSON) setFlagdProperties(
context map[string]any,
properties flagdProperties,
) map[string]any {
@ -402,7 +373,7 @@ func setFlagdProperties(
newContext := maps.Clone(context)
if _, ok := newContext[flagdPropertiesKey]; ok {
log.Warn("overwriting $flagd properties in the context")
je.Logger.Warn("overwriting $flagd properties in the context")
}
newContext[flagdPropertiesKey] = properties
@ -429,61 +400,56 @@ func getFlagdProperties(context map[string]any) (flagdProperties, bool) {
return p, true
}
func loadAndCompileSchema(log *logger.Logger) *gojsonschema.Schema {
func (je *JSON) loadAndCompileSchema() *gojsonschema.Schema {
schemaLoader := gojsonschema.NewSchemaLoader()
// compile dependency schema
targetingSchemaLoader := gojsonschema.NewStringLoader(schema.TargetingSchema)
if err := schemaLoader.AddSchemas(targetingSchemaLoader); err != nil {
log.Warn(fmt.Sprintf("error adding Targeting schema: %s", err))
je.Logger.Warn(fmt.Sprintf("error adding Targeting schema: %s", err))
}
// compile root schema
flagdDefinitionsLoader := gojsonschema.NewStringLoader(schema.FlagSchema)
compiledSchema, err := schemaLoader.Compile(flagdDefinitionsLoader)
if err != nil {
log.Warn(fmt.Sprintf("error compiling FlagdDefinitions schema: %s", err))
je.Logger.Warn(fmt.Sprintf("error compiling FlagdDefinitions schema: %s", err))
}
return compiledSchema
}
// configToFlagDefinition convert string configurations to flags and store them to pointer newFlags
func configToFlagDefinition(log *logger.Logger, config string, definition *Definition) error {
compiledSchema := loadAndCompileSchema(log)
// configToFlags convert string configurations to flags and store them to pointer newFlags
func (je *JSON) configToFlags(config string, newFlags *Flags) error {
compiledSchema := je.loadAndCompileSchema()
flagStringLoader := gojsonschema.NewStringLoader(config)
result, err := compiledSchema.Validate(flagStringLoader)
if err != nil {
log.Logger.Warn(fmt.Sprintf("failed to execute JSON schema validation: %s", err))
je.Logger.Warn(fmt.Sprintf("failed to execute JSON schema validation: %s", err))
} else if !result.Valid() {
log.Logger.Warn(fmt.Sprintf(
je.Logger.Warn(fmt.Sprintf(
"flag definition does not conform to the schema; validation errors: %s", buildErrorString(result.Errors()),
))
}
transposedConfig, err := transposeEvaluators(config)
transposedConfig, err := je.transposeEvaluators(config)
if err != nil {
return fmt.Errorf("transposing evaluators: %w", err)
}
err = json.Unmarshal([]byte(transposedConfig), &definition)
err = json.Unmarshal([]byte(transposedConfig), &newFlags)
if err != nil {
return fmt.Errorf("unmarshalling provided configurations: %w", err)
}
return validateDefaultVariants(definition)
return validateDefaultVariants(newFlags)
}
// validateDefaultVariants returns an error if any of the default variants aren't valid
func validateDefaultVariants(flags *Definition) error {
func validateDefaultVariants(flags *Flags) error {
for name, flag := range flags.Flags {
// Default Variant is not provided in the config
if flag.DefaultVariant == "" {
continue
}
if _, ok := flag.Variants[flag.DefaultVariant]; !ok {
return fmt.Errorf(
"default variant: '%s' isn't a valid variant of flag: '%s'", flag.DefaultVariant, name,
@ -494,7 +460,7 @@ func validateDefaultVariants(flags *Definition) error {
return nil
}
func transposeEvaluators(state string) (string, error) {
func (je *JSON) transposeEvaluators(state string) (string, error) {
var evaluators Evaluators
if err := json.Unmarshal([]byte(state), &evaluators); err != nil {
return "", fmt.Errorf("unmarshal: %w", err)

View File

@ -10,11 +10,6 @@ type Evaluators struct {
Evaluators map[string]json.RawMessage `json:"$evaluators"`
}
type Definition struct {
Flags map[string]model.Flag `json:"flags"`
Metadata map[string]interface{} `json:"metadata"`
}
type Flags struct {
Flags map[string]model.Flag `json:"flags"`
}

View File

@ -44,90 +44,7 @@ const ValidFlags = `{
}
}`
const NullDefault = `{
"flags": {
"validFlag": {
"state": "ENABLED",
"variants": {
"on": true,
"off": false
},
"defaultVariant": null
}
}
}`
const UndefinedDefault = `{
"flags": {
"validFlag": {
"state": "ENABLED",
"variants": {
"on": true,
"off": false
}
}
}
}`
const NullDefaultWithTargetting = `{
"flags": {
"validFlag": {
"state": "ENABLED",
"variants": {
"on": true,
"off": false
},
"defaultVariant": null,
"targeting": {
"if": [
{
"==": [
{
"var": [
"key"
]
},
"value"
]
},
"on"
]
}
}
}
}`
const UndefinedDefaultWithTargetting = `{
"flags": {
"validFlag": {
"state": "ENABLED",
"variants": {
"on": true,
"off": false
},
"targeting": {
"if": [
{
"==": [
{
"var": [
"key"
]
},
"value"
]
},
"on"
]
}
}
}
}`
const (
FlagSetID = "testSetId"
Version = "v33"
ValidFlag = "validFlag"
MissingFlag = "missingFlag"
StaticBoolFlag = "staticBoolFlag"
StaticBoolValue = true
@ -152,15 +69,9 @@ const (
ColorProp = "color"
ColorValue = "yellow"
DisabledFlag = "disabledFlag"
MetadataFlag = "metadataFlag"
VersionOverride = "v66"
)
var Flags = fmt.Sprintf(`{
"metadata": {
"flagSetId": "%s",
"version": "%s"
},
"flags": {
"%s": {
"state": "ENABLED",
@ -331,22 +242,9 @@ var Flags = fmt.Sprintf(`{
"off": false
},
"defaultVariant": "on"
},
"%s": {
"state": "ENABLED",
"variants": {
"on": true,
"off": false
},
"defaultVariant": "on",
"metadata": {
"version": "%s"
}
}
}
}`,
FlagSetID,
Version,
StaticBoolFlag,
StaticBoolValue,
StaticStringFlag,
@ -377,13 +275,11 @@ var Flags = fmt.Sprintf(`{
DynamicObjectValue,
ColorProp,
ColorValue,
DisabledFlag,
MetadataFlag,
VersionOverride)
DisabledFlag)
func TestGetState_Valid_ContainsFlag(t *testing.T) {
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: ValidFlags, Source: "testSource"})
_, _, err := evaluator.SetState(sync.DataSync{FlagData: ValidFlags})
if err != nil {
t.Fatalf("Expected no error")
}
@ -405,9 +301,9 @@ func TestSetState_Invalid_Error(t *testing.T) {
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
// set state with an invalid flag definition
_, _, err := evaluator.SetState(sync.DataSync{FlagData: InvalidFlags, Source: "testSource"})
if err != nil {
t.Fatalf("unexpected error")
_, _, err := evaluator.SetState(sync.DataSync{FlagData: InvalidFlags})
if err == nil {
t.Fatalf("expected error")
}
}
@ -415,7 +311,7 @@ func TestSetState_Valid_NoError(t *testing.T) {
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
// set state with a valid flag definition
_, _, err := evaluator.SetState(sync.DataSync{FlagData: ValidFlags, Source: "testSource"})
_, _, err := evaluator.SetState(sync.DataSync{FlagData: ValidFlags})
if err != nil {
t.Fatalf("expected no error")
}
@ -423,7 +319,7 @@ func TestSetState_Valid_NoError(t *testing.T) {
func TestResolveAllValues(t *testing.T) {
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags})
if err != nil {
t.Fatalf("expected no error")
}
@ -439,11 +335,7 @@ func TestResolveAllValues(t *testing.T) {
}
const reqID = "default"
for _, test := range tests {
vals, _, err := evaluator.ResolveAllValues(context.TODO(), reqID, test.context)
if err != nil {
t.Error("error from resolver", err)
}
vals := evaluator.ResolveAllValues(context.TODO(), reqID, test.context)
for _, val := range vals {
// disabled flag must be ignored from bulk evaluation
if val.FlagKey == DisabledFlag {
@ -492,7 +384,7 @@ func TestResolveBooleanValue(t *testing.T) {
}
const reqID = "default"
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags})
if err != nil {
t.Fatalf("expected no error")
}
@ -527,7 +419,7 @@ func BenchmarkResolveBooleanValue(b *testing.B) {
}
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags})
if err != nil {
b.Fatalf("expected no error")
}
@ -567,7 +459,7 @@ func TestResolveStringValue(t *testing.T) {
}
const reqID = "default"
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags})
if err != nil {
t.Fatalf("expected no error")
}
@ -603,7 +495,7 @@ func BenchmarkResolveStringValue(b *testing.B) {
}
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags})
if err != nil {
b.Fatalf("expected no error")
}
@ -643,7 +535,7 @@ func TestResolveFloatValue(t *testing.T) {
}
const reqID = "default"
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags})
if err != nil {
t.Fatalf("expected no error")
}
@ -679,7 +571,7 @@ func BenchmarkResolveFloatValue(b *testing.B) {
}
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags})
if err != nil {
b.Fatalf("expected no error")
}
@ -719,7 +611,7 @@ func TestResolveIntValue(t *testing.T) {
}
const reqID = "default"
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags})
if err != nil {
t.Fatalf("expected no error")
}
@ -755,7 +647,7 @@ func BenchmarkResolveIntValue(b *testing.B) {
}
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags})
if err != nil {
b.Fatalf("expected no error")
}
@ -795,7 +687,7 @@ func TestResolveObjectValue(t *testing.T) {
}
const reqID = "default"
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags})
if err != nil {
t.Fatalf("expected no error")
}
@ -834,7 +726,7 @@ func BenchmarkResolveObjectValue(b *testing.B) {
}
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags})
if err != nil {
b.Fatalf("expected no error")
}
@ -861,74 +753,6 @@ func BenchmarkResolveObjectValue(b *testing.B) {
}
}
func TestResolveAsAnyValue(t *testing.T) {
tests := []struct {
flagKey string
context map[string]interface{}
val string
reason string
errorCode string
}{
// success
{StaticBoolFlag, nil, "{}", model.StaticReason, ""},
{StaticObjectFlag, nil, StaticObjectValue, model.StaticReason, ""},
{DynamicObjectFlag, map[string]interface{}{ColorProp: ColorValue}, DynamicObjectValue, model.TargetingMatchReason, ""},
// errors
{MissingFlag, nil, "{}", model.ErrorReason, model.FlagNotFoundErrorCode},
{DisabledFlag, nil, "{}", model.ErrorReason, model.FlagDisabledErrorCode},
}
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
if err != nil {
t.Fatalf("expected no error")
}
for _, test := range tests {
t.Run(fmt.Sprintf("evaluating flag: %s", test.flagKey), func(t *testing.T) {
anyResult := evaluator.ResolveAsAnyValue(context.TODO(), "", test.flagKey, test.context)
if test.errorCode == "" {
assert.NoError(t, anyResult.Error)
} else {
assert.Equal(t, model.ErrorReason, anyResult.Reason)
assert.EqualError(t, anyResult.Error, test.errorCode)
}
})
}
}
func TestResolve_DefaultVariant(t *testing.T) {
tests := []struct {
flags string
flagKey string
context map[string]interface{}
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},
}
for _, test := range tests {
t.Run("", func(t *testing.T) {
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{FlagData: test.flags, Source: "testSource"})
if err != nil {
t.Fatalf("expected no error")
}
anyResult := evaluator.ResolveAsAnyValue(context.TODO(), "", test.flagKey, test.context)
assert.Equal(t, model.ErrorReason, anyResult.Reason)
assert.EqualError(t, anyResult.Error, test.errorCode)
})
}
}
func TestSetState_DefaultVariantValidation(t *testing.T) {
tests := map[string]struct {
jsonFlags string
@ -982,7 +806,7 @@ func TestSetState_DefaultVariantValidation(t *testing.T) {
t.Run(name, func(t *testing.T) {
jsonEvaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := jsonEvaluator.SetState(sync.DataSync{FlagData: tt.jsonFlags, Source: "testSource"})
_, _, err := jsonEvaluator.SetState(sync.DataSync{FlagData: tt.jsonFlags})
if tt.valid && err != nil {
t.Error(err)
@ -994,6 +818,7 @@ func TestSetState_DefaultVariantValidation(t *testing.T) {
func TestState_Evaluator(t *testing.T) {
tests := map[string]struct {
inputState string
inputSyncType sync.Type
expectedOutputState string
expectedError bool
expectedResync bool
@ -1029,6 +854,7 @@ func TestState_Evaluator(t *testing.T) {
}
}
`,
inputSyncType: sync.ALL,
expectedOutputState: `
{
"flags": {
@ -1041,8 +867,7 @@ func TestState_Evaluator(t *testing.T) {
},
"defaultVariant": "recursive",
"state": "ENABLED",
"source":"testSource",
"selector":"",
"source":"",
"targeting": {
"if": [
{
@ -1089,6 +914,7 @@ func TestState_Evaluator(t *testing.T) {
}
}
`,
inputSyncType: sync.ALL,
expectedOutputState: `
{
"flags": {
@ -1101,8 +927,7 @@ func TestState_Evaluator(t *testing.T) {
},
"defaultVariant": "recursive",
"state": "ENABLED",
"source":"testSource",
"selector":"",
"source":"",
"targeting": {
"if": [
{
@ -1145,6 +970,7 @@ func TestState_Evaluator(t *testing.T) {
}
}
`,
inputSyncType: sync.ALL,
expectedError: true,
},
"invalid targeting": {
@ -1160,6 +986,7 @@ func TestState_Evaluator(t *testing.T) {
},
"defaultVariant": "recursive",
"state": "ENABLED",
"source":"",
"targeting": {
"if": [
{
@ -1177,7 +1004,7 @@ func TestState_Evaluator(t *testing.T) {
"off": false
},
"defaultVariant": "off",
"source":"testSource",
"source":"",
"targeting": {
"if": [
{
@ -1198,6 +1025,7 @@ func TestState_Evaluator(t *testing.T) {
"flagSources":null
}
`,
inputSyncType: sync.ALL,
expectedError: false,
expectedOutputState: `
{
@ -1211,8 +1039,7 @@ func TestState_Evaluator(t *testing.T) {
},
"defaultVariant": "recursive",
"state": "ENABLED",
"source":"testSource",
"selector":"",
"source":"",
"targeting": {
"if": [
{
@ -1230,8 +1057,7 @@ func TestState_Evaluator(t *testing.T) {
"off": false
},
"defaultVariant": "off",
"source":"testSource",
"selector":"",
"source":"",
"targeting": {
"if": [
{
@ -1280,15 +1106,47 @@ func TestState_Evaluator(t *testing.T) {
}
}
`,
inputSyncType: sync.ALL,
expectedError: true,
},
"unexpected sync type": {
inputState: `
{
"flags": {
"fibAlgo": {
"variants": {
"recursive": "recursive",
"memo": "memo",
"loop": "loop",
"binet": "binet"
},
"defaultVariant": "recursive",
"state": "ENABLED",
"targeting": {
"if": [
{
"$ref": "emailWithFaas"
}, "binet", null
]
}
}
},
"$evaluators": {
"emailWithFaas": ""
}
}
`,
inputSyncType: 999,
expectedError: true,
expectedResync: false,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
jsonEvaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, resync, err := jsonEvaluator.SetState(sync.DataSync{FlagData: tt.inputState, Source: "testSource"})
_, resync, err := jsonEvaluator.SetState(sync.DataSync{FlagData: tt.inputState})
if err != nil {
if !tt.expectedError {
t.Error(err)
@ -1321,8 +1179,8 @@ func TestState_Evaluator(t *testing.T) {
t.Fatal(err)
}
if !reflect.DeepEqual(expectedOutputJSON["flags"], gotOutputJSON) {
t.Errorf("expected state: %v got state: %v", expectedOutputJSON["flags"], gotOutputJSON)
if !reflect.DeepEqual(expectedOutputJSON["flags"], gotOutputJSON["flags"]) {
t.Errorf("expected state: %v got state: %v", expectedOutputJSON, gotOutputJSON)
}
})
}
@ -1330,60 +1188,60 @@ func TestState_Evaluator(t *testing.T) {
func TestFlagStateSafeForConcurrentReadWrites(t *testing.T) {
tests := map[string]struct {
dataSyncType sync.Type
flagResolution func(evaluator *evaluator.JSON) error
}{
"Add_ResolveAllValues": {
dataSyncType: sync.ADD,
flagResolution: func(evaluator *evaluator.JSON) error {
_, _, err := evaluator.ResolveAllValues(context.TODO(), "", nil)
if err != nil {
return err
}
evaluator.ResolveAllValues(context.TODO(), "", nil)
return nil
},
},
"Update_ResolveAllValues": {
dataSyncType: sync.UPDATE,
flagResolution: func(evaluator *evaluator.JSON) error {
_, _, err := evaluator.ResolveAllValues(context.TODO(), "", nil)
if err != nil {
return err
}
evaluator.ResolveAllValues(context.TODO(), "", nil)
return nil
},
},
"Delete_ResolveAllValues": {
dataSyncType: sync.DELETE,
flagResolution: func(evaluator *evaluator.JSON) error {
_, _, err := evaluator.ResolveAllValues(context.TODO(), "", nil)
if err != nil {
return err
}
evaluator.ResolveAllValues(context.TODO(), "", nil)
return nil
},
},
"Add_ResolveBooleanValue": {
dataSyncType: sync.ADD,
flagResolution: func(evaluator *evaluator.JSON) error {
_, _, _, _, err := evaluator.ResolveBooleanValue(context.TODO(), "", StaticBoolFlag, nil)
return err
},
},
"Update_ResolveStringValue": {
dataSyncType: sync.UPDATE,
flagResolution: func(evaluator *evaluator.JSON) error {
_, _, _, _, err := evaluator.ResolveBooleanValue(context.TODO(), "", StaticStringValue, nil)
return err
},
},
"Delete_ResolveIntValue": {
dataSyncType: sync.DELETE,
flagResolution: func(evaluator *evaluator.JSON) error {
_, _, _, _, err := evaluator.ResolveIntValue(context.TODO(), "", StaticIntFlag, nil)
return err
},
},
"Add_ResolveFloatValue": {
dataSyncType: sync.ADD,
flagResolution: func(evaluator *evaluator.JSON) error {
_, _, _, _, err := evaluator.ResolveFloatValue(context.TODO(), "", StaticFloatFlag, nil)
return err
},
},
"Update_ResolveObjectValue": {
dataSyncType: sync.UPDATE,
flagResolution: func(evaluator *evaluator.JSON) error {
_, _, _, _, err := evaluator.ResolveObjectValue(context.TODO(), "", StaticObjectFlag, nil)
return err
@ -1395,7 +1253,7 @@ func TestFlagStateSafeForConcurrentReadWrites(t *testing.T) {
t.Run(name, func(t *testing.T) {
jsonEvaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := jsonEvaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
_, _, err := jsonEvaluator.SetState(sync.DataSync{FlagData: Flags, Type: sync.ADD})
if err != nil {
t.Fatal(err)
}
@ -1418,7 +1276,7 @@ func TestFlagStateSafeForConcurrentReadWrites(t *testing.T) {
errChan <- nil
return
default:
_, _, err := jsonEvaluator.SetState(sync.DataSync{FlagData: Flags, Source: "testSource"})
_, _, err := jsonEvaluator.SetState(sync.DataSync{FlagData: Flags, Type: tt.dataSyncType})
if err != nil {
errChan <- err
return
@ -1460,7 +1318,7 @@ func TestFlagdAmbientProperties(t *testing.T) {
t.Run("flagKeyIsInTheContext", func(t *testing.T) {
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{Source: "testSource", FlagData: `{
_, _, err := evaluator.SetState(sync.DataSync{FlagData: `{
"flags": {
"welcome-banner": {
"state": "ENABLED",
@ -1500,7 +1358,7 @@ func TestFlagdAmbientProperties(t *testing.T) {
t.Run("timestampIsInTheContext", func(t *testing.T) {
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{Source: "testSource", FlagData: `{
_, _, err := evaluator.SetState(sync.DataSync{FlagData: `{
"flags": {
"welcome-banner": {
"state": "ENABLED",
@ -1534,7 +1392,7 @@ func TestTargetingVariantBehavior(t *testing.T) {
t.Run("missing variant error", func(t *testing.T) {
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{Source: "testSource", FlagData: `{
_, _, err := evaluator.SetState(sync.DataSync{FlagData: `{
"flags": {
"missing-variant": {
"state": "ENABLED",
@ -1562,7 +1420,7 @@ func TestTargetingVariantBehavior(t *testing.T) {
t.Run("null fallback", func(t *testing.T) {
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
_, _, err := evaluator.SetState(sync.DataSync{Source: "testSource", FlagData: `{
_, _, err := evaluator.SetState(sync.DataSync{FlagData: `{
"flags": {
"null-fallback": {
"state": "ENABLED",
@ -1595,7 +1453,7 @@ func TestTargetingVariantBehavior(t *testing.T) {
evaluator := evaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
//nolint:dupword
_, _, err := evaluator.SetState(sync.DataSync{Source: "testSource", FlagData: `{
_, _, err := evaluator.SetState(sync.DataSync{FlagData: `{
"flags": {
"match-boolean": {
"state": "ENABLED",

View File

@ -0,0 +1,145 @@
// This evaluation type is deprecated and will be removed before v1.
// Do not enhance it or use it for reference.
package evaluator
import (
"errors"
"fmt"
"math"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/zeebo/xxh3"
)
const (
LegacyFractionEvaluationName = "fractionalEvaluation"
LegacyFractionEvaluationLink = "https://flagd.dev/concepts/#migrating-from-legacy-fractionalevaluation"
)
// Deprecated: LegacyFractional is deprecated. This will be removed prior to v1 release.
type LegacyFractional struct {
Logger *logger.Logger
}
type legacyFractionalEvaluationDistribution struct {
variant string
percentage int
}
func NewLegacyFractional(logger *logger.Logger) *LegacyFractional {
return &LegacyFractional{Logger: logger}
}
func (fe *LegacyFractional) LegacyFractionalEvaluation(values, data interface{}) interface{} {
fe.Logger.Warn(
fmt.Sprintf("%s is deprecated, please use %s, see: %s",
LegacyFractionEvaluationName,
FractionEvaluationName,
LegacyFractionEvaluationLink))
valueToDistribute, feDistributions, err := parseLegacyFractionalEvaluationData(values, data)
if err != nil {
fe.Logger.Error(fmt.Sprintf("parse fractional evaluation data: %v", err))
return nil
}
return distributeLegacyValue(valueToDistribute, feDistributions)
}
func parseLegacyFractionalEvaluationData(values, data interface{}) (string,
[]legacyFractionalEvaluationDistribution, error,
) {
valuesArray, ok := values.([]interface{})
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")
}
bucketBy, ok := valuesArray[0].(string)
if !ok {
return "", nil, errors.New("first element of fractional evaluation data isn't of type string")
}
dataMap, ok := data.(map[string]interface{})
if !ok {
return "", nil, errors.New("data isn't of type map[string]interface{}")
}
v, ok := dataMap[bucketBy]
if !ok {
return "", nil, nil
}
valueToDistribute, ok := v.(string)
if !ok {
return "", nil, fmt.Errorf("var: %s isn't of type string", bucketBy)
}
feDistributions, err := parseLegacyFractionalEvaluationDistributions(valuesArray)
if err != nil {
return "", nil, err
}
return valueToDistribute, feDistributions, nil
}
func parseLegacyFractionalEvaluationDistributions(values []interface{}) (
[]legacyFractionalEvaluationDistribution, error,
) {
sumOfPercentages := 0
var feDistributions []legacyFractionalEvaluationDistribution
for i := 1; i < len(values); i++ {
distributionArray, ok := values[i].([]interface{})
if !ok {
return nil, errors.New("distribution elements aren't of type []interface{}")
}
if len(distributionArray) != 2 {
return nil, errors.New("distribution element isn't length 2")
}
variant, ok := distributionArray[0].(string)
if !ok {
return nil, errors.New("first element of distribution element isn't string")
}
percentage, ok := distributionArray[1].(float64)
if !ok {
return nil, errors.New("second element of distribution element isn't float")
}
sumOfPercentages += int(percentage)
feDistributions = append(feDistributions, legacyFractionalEvaluationDistribution{
variant: variant,
percentage: int(percentage),
})
}
if sumOfPercentages != 100 {
return nil, fmt.Errorf("percentages must sum to 100, got: %d", sumOfPercentages)
}
return feDistributions, nil
}
func distributeLegacyValue(value string, feDistribution []legacyFractionalEvaluationDistribution) string {
hashValue := xxh3.HashString(value)
hashRatio := float64(hashValue) / math.Pow(2, 64) // divide the hash value by the largest possible value, integer 2^64
bucket := int(hashRatio * 100) // integer in range [0, 99]
rangeEnd := 0
for _, dist := range feDistribution {
rangeEnd += dist.percentage
if bucket < rangeEnd {
return dist.variant
}
}
return ""
}

View File

@ -0,0 +1,304 @@
package evaluator
import (
"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"
)
func TestLegacyFractionalEvaluation(t *testing.T) {
flags := Flags{
Flags: map[string]model.Flag{
"headerColor": {
State: "ENABLED",
DefaultVariant: "red",
Variants: map[string]any{
"red": "#FF0000",
"blue": "#0000FF",
"green": "#00FF00",
"yellow": "#FFFF00",
},
Targeting: []byte(`{
"if": [
{
"in": ["@faas.com", {
"var": ["email"]
}]
},
{
"fractionalEvaluation": [
"email",
[
"red",
25
],
[
"blue",
25
],
[
"green",
25
],
[
"yellow",
25
]
]
}, null
]
}`),
},
},
}
tests := map[string]struct {
flags Flags
flagKey string
context map[string]any
expectedValue string
expectedVariant string
expectedReason string
expectedErrorCode string
}{
"test@faas.com": {
flags: flags,
flagKey: "headerColor",
context: map[string]any{
"email": "test@faas.com",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"test2@faas.com": {
flags: flags,
flagKey: "headerColor",
context: map[string]any{
"email": "test2@faas.com",
},
expectedVariant: "yellow",
expectedValue: "#FFFF00",
expectedReason: model.TargetingMatchReason,
},
"test3@faas.com": {
flags: flags,
flagKey: "headerColor",
context: map[string]any{
"email": "test3@faas.com",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"test4@faas.com": {
flags: flags,
flagKey: "headerColor",
context: map[string]any{
"email": "test4@faas.com",
},
expectedVariant: "blue",
expectedValue: "#0000FF",
expectedReason: model.TargetingMatchReason,
},
"non even split": {
flags: Flags{
Flags: map[string]model.Flag{
"headerColor": {
State: "ENABLED",
DefaultVariant: "red",
Variants: map[string]any{
"red": "#FF0000",
"blue": "#0000FF",
"green": "#00FF00",
"yellow": "#FFFF00",
},
Targeting: []byte(`{
"if": [
{
"in": ["@faas.com", {
"var": ["email"]
}]
},
{
"fractionalEvaluation": [
"email",
[
"red",
50
],
[
"blue",
25
],
[
"green",
25
]
]
}, null
]
}`),
},
},
},
flagKey: "headerColor",
context: map[string]any{
"email": "test4@faas.com",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"fallback to default variant if no email provided": {
flags: Flags{
Flags: map[string]model.Flag{
"headerColor": {
State: "ENABLED",
DefaultVariant: "red",
Variants: map[string]any{
"red": "#FF0000",
"blue": "#0000FF",
"green": "#00FF00",
"yellow": "#FFFF00",
},
Targeting: []byte(`{
"fractionalEvaluation": [
"email",
[
"red",
25
],
[
"blue",
25
],
[
"green",
25
],
[
"yellow",
25
]
]
}`),
},
},
},
flagKey: "headerColor",
context: map[string]any{},
expectedVariant: "",
expectedValue: "",
expectedReason: model.ErrorReason,
expectedErrorCode: model.ParseErrorCode,
},
"fallback to default variant if invalid variant as result of fractional evaluation": {
flags: Flags{
Flags: map[string]model.Flag{
"headerColor": {
State: "ENABLED",
DefaultVariant: "red",
Variants: map[string]any{
"red": "#FF0000",
"blue": "#0000FF",
"green": "#00FF00",
"yellow": "#FFFF00",
},
Targeting: []byte(`{
"fractionalEvaluation": [
"email",
[
"black",
100
]
]
}`),
},
},
},
flagKey: "headerColor",
context: map[string]any{
"email": "foo@foo.com",
},
expectedVariant: "",
expectedValue: "",
expectedReason: model.ErrorReason,
expectedErrorCode: model.ParseErrorCode,
},
"fallback to default variant if percentages don't sum to 100": {
flags: Flags{
Flags: map[string]model.Flag{
"headerColor": {
State: "ENABLED",
DefaultVariant: "red",
Variants: map[string]any{
"red": "#FF0000",
"blue": "#0000FF",
"green": "#00FF00",
"yellow": "#FFFF00",
},
Targeting: []byte(`{
"fractionalEvaluation": [
"email",
[
"red",
25
],
[
"blue",
25
]
]
}`),
},
},
},
flagKey: "headerColor",
context: map[string]any{
"email": "foo@foo.com",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.DefaultReason,
},
}
const reqID = "default"
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
log := logger.NewLogger(nil, false)
je := NewJSON(
log,
store.NewFlags(),
WithEvaluator(
"fractionalEvaluation",
NewLegacyFractional(log).LegacyFractionalEvaluation,
),
)
je.store.Flags = tt.flags.Flags
value, variant, reason, _, err := resolve[string](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 != nil {
errorCode := err.Error()
if errorCode != tt.expectedErrorCode {
t.Errorf("expected err '%v', got '%v'", tt.expectedErrorCode, err)
}
}
})
}
}

View File

@ -1,10 +1,5 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: pkg/evaluator/ievaluator.go
//
// Generated by this command:
//
// mockgen -source=pkg/evaluator/ievaluator.go -destination=pkg/evaluator/mock/ievaluator.go -package=evalmock
//
// Source: pkg/eval/ievaluator.go
// Package evalmock is a generated GoMock package.
package evalmock
@ -13,17 +8,15 @@ import (
context "context"
reflect "reflect"
evaluator "github.com/open-feature/flagd/core/pkg/evaluator"
model "github.com/open-feature/flagd/core/pkg/model"
gomock "github.com/golang/mock/gomock"
eval "github.com/open-feature/flagd/core/pkg/evaluator"
sync "github.com/open-feature/flagd/core/pkg/sync"
gomock "go.uber.org/mock/gomock"
)
// MockIEvaluator is a mock of IEvaluator interface.
type MockIEvaluator struct {
ctrl *gomock.Controller
recorder *MockIEvaluatorMockRecorder
isgomock struct{}
}
// MockIEvaluatorMockRecorder is the mock recorder for MockIEvaluator.
@ -59,281 +52,121 @@ func (mr *MockIEvaluatorMockRecorder) GetState() *gomock.Call {
}
// ResolveAllValues mocks base method.
func (m *MockIEvaluator) ResolveAllValues(ctx context.Context, reqID string, context map[string]any) ([]evaluator.AnyValue, model.Metadata, error) {
func (m *MockIEvaluator) ResolveAllValues(ctx context.Context, reqID string, context map[string]any) []eval.AnyValue {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveAllValues", ctx, reqID, context)
ret0, _ := ret[0].([]evaluator.AnyValue)
ret1, _ := ret[1].(model.Metadata)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
ret0, _ := ret[0].([]eval.AnyValue)
return ret0
}
// ResolveAllValues indicates an expected call of ResolveAllValues.
func (mr *MockIEvaluatorMockRecorder) ResolveAllValues(ctx, reqID, context any) *gomock.Call {
func (mr *MockIEvaluatorMockRecorder) ResolveAllValues(ctx, reqID, context interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveAllValues", reflect.TypeOf((*MockIEvaluator)(nil).ResolveAllValues), ctx, reqID, context)
}
// ResolveAsAnyValue mocks base method.
func (m *MockIEvaluator) ResolveAsAnyValue(ctx context.Context, reqID, flagKey string, context map[string]any) evaluator.AnyValue {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveAsAnyValue", ctx, reqID, flagKey, context)
ret0, _ := ret[0].(evaluator.AnyValue)
return ret0
}
// ResolveAsAnyValue indicates an expected call of ResolveAsAnyValue.
func (mr *MockIEvaluatorMockRecorder) ResolveAsAnyValue(ctx, reqID, flagKey, context any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveAsAnyValue", reflect.TypeOf((*MockIEvaluator)(nil).ResolveAsAnyValue), ctx, reqID, flagKey, context)
}
// ResolveBooleanValue mocks base method.
func (m *MockIEvaluator) ResolveBooleanValue(ctx context.Context, reqID, flagKey string, context map[string]any) (bool, string, string, model.Metadata, error) {
func (m *MockIEvaluator) ResolveBooleanValue(ctx context.Context, reqID, flagKey string, context map[string]any) (bool, string, string, map[string]interface{}, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveBooleanValue", ctx, reqID, flagKey, context)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(model.Metadata)
ret3, _ := ret[3].(map[string]interface{})
ret4, _ := ret[4].(error)
return ret0, ret1, ret2, ret3, ret4
}
// ResolveBooleanValue indicates an expected call of ResolveBooleanValue.
func (mr *MockIEvaluatorMockRecorder) ResolveBooleanValue(ctx, reqID, flagKey, context any) *gomock.Call {
func (mr *MockIEvaluatorMockRecorder) ResolveBooleanValue(ctx, reqID, flagKey, context interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveBooleanValue", reflect.TypeOf((*MockIEvaluator)(nil).ResolveBooleanValue), ctx, reqID, flagKey, context)
}
// ResolveFloatValue mocks base method.
func (m *MockIEvaluator) ResolveFloatValue(ctx context.Context, reqID, flagKey string, context map[string]any) (float64, string, string, model.Metadata, error) {
func (m *MockIEvaluator) ResolveFloatValue(ctx context.Context, reqID, flagKey string, context map[string]any) (float64, string, string, map[string]interface{}, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveFloatValue", ctx, reqID, flagKey, context)
ret0, _ := ret[0].(float64)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(model.Metadata)
ret3, _ := ret[3].(map[string]interface{})
ret4, _ := ret[4].(error)
return ret0, ret1, ret2, ret3, ret4
}
// ResolveFloatValue indicates an expected call of ResolveFloatValue.
func (mr *MockIEvaluatorMockRecorder) ResolveFloatValue(ctx, reqID, flagKey, context any) *gomock.Call {
func (mr *MockIEvaluatorMockRecorder) ResolveFloatValue(ctx, reqID, flagKey, context interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveFloatValue", reflect.TypeOf((*MockIEvaluator)(nil).ResolveFloatValue), ctx, reqID, flagKey, context)
}
// ResolveIntValue mocks base method.
func (m *MockIEvaluator) ResolveIntValue(ctx context.Context, reqID, flagKey string, context map[string]any) (int64, string, string, model.Metadata, error) {
func (m *MockIEvaluator) ResolveIntValue(ctx context.Context, reqID, flagKey string, context map[string]any) (int64, string, string, map[string]interface{}, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveIntValue", ctx, reqID, flagKey, context)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(model.Metadata)
ret3, _ := ret[3].(map[string]interface{})
ret4, _ := ret[4].(error)
return ret0, ret1, ret2, ret3, ret4
}
// ResolveIntValue indicates an expected call of ResolveIntValue.
func (mr *MockIEvaluatorMockRecorder) ResolveIntValue(ctx, reqID, flagKey, context any) *gomock.Call {
func (mr *MockIEvaluatorMockRecorder) ResolveIntValue(ctx, reqID, flagKey, context interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveIntValue", reflect.TypeOf((*MockIEvaluator)(nil).ResolveIntValue), ctx, reqID, flagKey, context)
}
// ResolveObjectValue mocks base method.
func (m *MockIEvaluator) ResolveObjectValue(ctx context.Context, reqID, flagKey string, context map[string]any) (map[string]any, string, string, model.Metadata, error) {
func (m *MockIEvaluator) ResolveObjectValue(ctx context.Context, reqID, flagKey string, context map[string]any) (map[string]any, string, string, map[string]interface{}, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveObjectValue", ctx, reqID, flagKey, context)
ret0, _ := ret[0].(map[string]any)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(model.Metadata)
ret3, _ := ret[3].(map[string]interface{})
ret4, _ := ret[4].(error)
return ret0, ret1, ret2, ret3, ret4
}
// ResolveObjectValue indicates an expected call of ResolveObjectValue.
func (mr *MockIEvaluatorMockRecorder) ResolveObjectValue(ctx, reqID, flagKey, context any) *gomock.Call {
func (mr *MockIEvaluatorMockRecorder) ResolveObjectValue(ctx, reqID, flagKey, context interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveObjectValue", reflect.TypeOf((*MockIEvaluator)(nil).ResolveObjectValue), ctx, reqID, flagKey, context)
}
// ResolveStringValue mocks base method.
func (m *MockIEvaluator) ResolveStringValue(ctx context.Context, reqID, flagKey string, context map[string]any) (string, string, string, model.Metadata, error) {
func (m *MockIEvaluator) ResolveStringValue(ctx context.Context, reqID, flagKey string, context map[string]any) (string, string, string, map[string]interface{}, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveStringValue", ctx, reqID, flagKey, context)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(model.Metadata)
ret3, _ := ret[3].(map[string]interface{})
ret4, _ := ret[4].(error)
return ret0, ret1, ret2, ret3, ret4
}
// ResolveStringValue indicates an expected call of ResolveStringValue.
func (mr *MockIEvaluatorMockRecorder) ResolveStringValue(ctx, reqID, flagKey, context any) *gomock.Call {
func (mr *MockIEvaluatorMockRecorder) ResolveStringValue(ctx, reqID, flagKey, context interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveStringValue", reflect.TypeOf((*MockIEvaluator)(nil).ResolveStringValue), ctx, reqID, flagKey, context)
}
// SetState mocks base method.
func (m *MockIEvaluator) SetState(payload sync.DataSync) (model.Metadata, bool, error) {
func (m *MockIEvaluator) SetState(payload sync.DataSync) (map[string]interface{}, bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetState", payload)
ret0, _ := ret[0].(model.Metadata)
ret0, _ := ret[0].(map[string]interface{})
ret1, _ := ret[1].(bool)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// SetState indicates an expected call of SetState.
func (mr *MockIEvaluatorMockRecorder) SetState(payload any) *gomock.Call {
func (mr *MockIEvaluatorMockRecorder) SetState(payload interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetState", reflect.TypeOf((*MockIEvaluator)(nil).SetState), payload)
}
// MockIResolver is a mock of IResolver interface.
type MockIResolver struct {
ctrl *gomock.Controller
recorder *MockIResolverMockRecorder
isgomock struct{}
}
// MockIResolverMockRecorder is the mock recorder for MockIResolver.
type MockIResolverMockRecorder struct {
mock *MockIResolver
}
// NewMockIResolver creates a new mock instance.
func NewMockIResolver(ctrl *gomock.Controller) *MockIResolver {
mock := &MockIResolver{ctrl: ctrl}
mock.recorder = &MockIResolverMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockIResolver) EXPECT() *MockIResolverMockRecorder {
return m.recorder
}
// ResolveAllValues mocks base method.
func (m *MockIResolver) ResolveAllValues(ctx context.Context, reqID string, context map[string]any) ([]evaluator.AnyValue, model.Metadata, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveAllValues", ctx, reqID, context)
ret0, _ := ret[0].([]evaluator.AnyValue)
ret1, _ := ret[1].(model.Metadata)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// ResolveAllValues indicates an expected call of ResolveAllValues.
func (mr *MockIResolverMockRecorder) ResolveAllValues(ctx, reqID, context any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveAllValues", reflect.TypeOf((*MockIResolver)(nil).ResolveAllValues), ctx, reqID, context)
}
// ResolveAsAnyValue mocks base method.
func (m *MockIResolver) ResolveAsAnyValue(ctx context.Context, reqID, flagKey string, context map[string]any) evaluator.AnyValue {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveAsAnyValue", ctx, reqID, flagKey, context)
ret0, _ := ret[0].(evaluator.AnyValue)
return ret0
}
// ResolveAsAnyValue indicates an expected call of ResolveAsAnyValue.
func (mr *MockIResolverMockRecorder) ResolveAsAnyValue(ctx, reqID, flagKey, context any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveAsAnyValue", reflect.TypeOf((*MockIResolver)(nil).ResolveAsAnyValue), ctx, reqID, flagKey, context)
}
// ResolveBooleanValue mocks base method.
func (m *MockIResolver) ResolveBooleanValue(ctx context.Context, reqID, flagKey string, context map[string]any) (bool, string, string, model.Metadata, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveBooleanValue", ctx, reqID, flagKey, context)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(model.Metadata)
ret4, _ := ret[4].(error)
return ret0, ret1, ret2, ret3, ret4
}
// ResolveBooleanValue indicates an expected call of ResolveBooleanValue.
func (mr *MockIResolverMockRecorder) ResolveBooleanValue(ctx, reqID, flagKey, context any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveBooleanValue", reflect.TypeOf((*MockIResolver)(nil).ResolveBooleanValue), ctx, reqID, flagKey, context)
}
// ResolveFloatValue mocks base method.
func (m *MockIResolver) ResolveFloatValue(ctx context.Context, reqID, flagKey string, context map[string]any) (float64, string, string, model.Metadata, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveFloatValue", ctx, reqID, flagKey, context)
ret0, _ := ret[0].(float64)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(model.Metadata)
ret4, _ := ret[4].(error)
return ret0, ret1, ret2, ret3, ret4
}
// ResolveFloatValue indicates an expected call of ResolveFloatValue.
func (mr *MockIResolverMockRecorder) ResolveFloatValue(ctx, reqID, flagKey, context any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveFloatValue", reflect.TypeOf((*MockIResolver)(nil).ResolveFloatValue), ctx, reqID, flagKey, context)
}
// ResolveIntValue mocks base method.
func (m *MockIResolver) ResolveIntValue(ctx context.Context, reqID, flagKey string, context map[string]any) (int64, string, string, model.Metadata, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveIntValue", ctx, reqID, flagKey, context)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(model.Metadata)
ret4, _ := ret[4].(error)
return ret0, ret1, ret2, ret3, ret4
}
// ResolveIntValue indicates an expected call of ResolveIntValue.
func (mr *MockIResolverMockRecorder) ResolveIntValue(ctx, reqID, flagKey, context any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveIntValue", reflect.TypeOf((*MockIResolver)(nil).ResolveIntValue), ctx, reqID, flagKey, context)
}
// ResolveObjectValue mocks base method.
func (m *MockIResolver) ResolveObjectValue(ctx context.Context, reqID, flagKey string, context map[string]any) (map[string]any, string, string, model.Metadata, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveObjectValue", ctx, reqID, flagKey, context)
ret0, _ := ret[0].(map[string]any)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(model.Metadata)
ret4, _ := ret[4].(error)
return ret0, ret1, ret2, ret3, ret4
}
// ResolveObjectValue indicates an expected call of ResolveObjectValue.
func (mr *MockIResolverMockRecorder) ResolveObjectValue(ctx, reqID, flagKey, context any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveObjectValue", reflect.TypeOf((*MockIResolver)(nil).ResolveObjectValue), ctx, reqID, flagKey, context)
}
// ResolveStringValue mocks base method.
func (m *MockIResolver) ResolveStringValue(ctx context.Context, reqID, flagKey string, context map[string]any) (string, string, string, model.Metadata, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveStringValue", ctx, reqID, flagKey, context)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(string)
ret3, _ := ret[3].(model.Metadata)
ret4, _ := ret[4].(error)
return ret0, ret1, ret2, ret3, ret4
}
// ResolveStringValue indicates an expected call of ResolveStringValue.
func (mr *MockIResolverMockRecorder) ResolveStringValue(ctx, reqID, flagKey, context any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveStringValue", reflect.TypeOf((*MockIResolver)(nil).ResolveStringValue), ctx, reqID, flagKey, context)
}

View File

@ -102,7 +102,7 @@ func parseSemverEvaluationData(values interface{}) (string, string, SemVerOperat
}
if len(parsed) != 3 {
return "", "", "", errors.New("sem_ver evaluation must contain a value, an operator, and a comparison target")
return "", "", "", errors.New("sem_ver evaluation must contain a value, an operator and a comparison target")
}
actualVersion, err := parseSemanticVersion(parsed[0])
@ -122,17 +122,11 @@ func parseSemverEvaluationData(values interface{}) (string, string, SemVerOperat
return actualVersion, targetVersion, operator, nil
}
func ensureString(v interface{}) string {
if str, ok := v.(string); ok {
// It's already a string
return str
}
// Convert to string if not already
return fmt.Sprintf("%v", v)
}
func parseSemanticVersion(v interface{}) (string, error) {
version := ensureString(v)
version, ok := v.(string)
if !ok {
return "", errors.New("sem_ver evaluation: property did not resolve to a string value")
}
// 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
if !strings.HasPrefix(version, "v") {
@ -140,7 +134,7 @@ func parseSemanticVersion(v interface{}) (string, error) {
}
if !semver.IsValid(version) {
return "", fmt.Errorf("'%v' is not a valid semantic version string", version)
return "", errors.New("not a valid semantic version string")
}
return version, nil
@ -149,7 +143,7 @@ func parseSemanticVersion(v interface{}) (string, error) {
func parseOperator(o interface{}) (SemVerOperator, error) {
operatorString, ok := o.(string)
if !ok {
return "", fmt.Errorf("could not parse operator '%v'", o)
return "", errors.New("could not parse operator")
}
return SemVerOperator(operatorString), nil

View File

@ -1,8 +1,6 @@
package evaluator
import (
"context"
"errors"
"testing"
"github.com/open-feature/flagd/core/pkg/logger"
@ -23,76 +21,6 @@ func TestSemVerOperator_Compare(t *testing.T) {
want bool
wantErr bool
}{
{
name: "invalid version",
svo: Greater,
args: args{
v1: "invalid",
v2: "v1.0.0",
},
want: false,
wantErr: true,
},
{
name: "preview version vs non preview version",
svo: Greater,
args: args{
v1: "v1.0.0-preview.1.2",
v2: "v1.0.0",
},
want: false,
wantErr: false,
},
{
name: "preview version vs preview version",
svo: Greater,
args: args{
v1: "v1.0.0-preview.1.3",
v2: "v1.0.0-preview.1.2",
},
want: true,
wantErr: false,
},
{
name: "no prefixed v left greater",
svo: Greater,
args: args{
v1: "0.0.1",
v2: "v0.0.2",
},
want: false,
wantErr: false,
},
{
name: "no prefixed v right greater",
svo: Greater,
args: args{
v1: "v0.0.1",
v2: "0.0.2",
},
want: false,
wantErr: false,
},
{
name: "no prefixed v right equals",
svo: Equals,
args: args{
v1: "v0.0.1",
v2: "0.0.1",
},
want: true,
wantErr: false,
},
{
name: "no prefixed v both",
svo: Greater,
args: args{
v1: "0.0.1",
v2: "0.0.2",
},
want: false,
wantErr: false,
},
{
name: "invalid operator",
svo: "",
@ -103,16 +31,6 @@ func TestSemVerOperator_Compare(t *testing.T) {
want: false,
wantErr: true,
},
{
name: "less with large number",
svo: Less,
args: args{
v1: "v1234.0.1",
v2: "v1235.0.2",
},
want: true,
wantErr: false,
},
{
name: "less",
svo: Less,
@ -123,16 +41,6 @@ func TestSemVerOperator_Compare(t *testing.T) {
want: true,
wantErr: false,
},
{
name: "no minor version",
svo: Less,
args: args{
v1: "v1.0",
v2: "v1.2",
},
want: true,
wantErr: false,
},
{
name: "not less",
svo: Less,
@ -296,30 +204,19 @@ func TestSemVerOperator_Compare(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var operatorInterface interface{} = string(tt.svo)
actualVersion, targetVersion, operator, err := parseSemverEvaluationData([]interface{}{tt.args.v1, operatorInterface, tt.args.v2})
if err != nil {
require.Truef(t, tt.wantErr, "Error parsing semver evaluation data. actualVersion: %s, targetVersion: %s, operator: %s, err: %s", actualVersion, targetVersion, operator, err)
return
}
got, err := operator.compare(actualVersion, targetVersion)
got, err := tt.svo.compare(tt.args.v1, tt.args.v2)
if tt.wantErr {
require.NotNil(t, err)
} else {
require.Nil(t, err)
require.Equalf(t, tt.want, got, "compare(%v, %v) operator: %s", tt.args.v1, tt.args.v2, operator)
require.Equalf(t, tt.want, got, "compare(%v, %v)", tt.args.v1, tt.args.v2)
}
})
}
}
func TestJSONEvaluator_semVerEvaluation(t *testing.T) {
const source = "testSource"
var sources = []string{source}
ctx := context.Background()
tests := map[string]struct {
flags Flags
flagKey string
@ -484,130 +381,6 @@ func TestJSONEvaluator_semVerEvaluation(t *testing.T) {
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"versions given as double - match": {
flags: Flags{
Flags: map[string]model.Flag{
"headerColor": {
State: "ENABLED",
DefaultVariant: "red",
Variants: map[string]any{
"red": "#FF0000",
"blue": "#0000FF",
"green": "#00FF00",
"yellow": "#FFFF00",
},
Targeting: []byte(`{
"if": [
{
"sem_ver": [1.2, "=", "1.2"]
},
"red", "green"
]
}`),
},
},
},
flagKey: "headerColor",
context: map[string]any{
"version": "1.0.0",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"versions given as int - match": {
flags: Flags{
Flags: map[string]model.Flag{
"headerColor": {
State: "ENABLED",
DefaultVariant: "red",
Variants: map[string]any{
"red": "#FF0000",
"blue": "#0000FF",
"green": "#00FF00",
"yellow": "#FFFF00",
},
Targeting: []byte(`{
"if": [
{
"sem_ver": [1, "=", "v1.0.0"]
},
"red", "green"
]
}`),
},
},
},
flagKey: "headerColor",
context: map[string]any{
"version": "1.0.0",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"versions and minor-version without patch version operator provided - match": {
flags: Flags{
Flags: map[string]model.Flag{
"headerColor": {
State: "ENABLED",
DefaultVariant: "red",
Variants: map[string]any{
"red": "#FF0000",
"blue": "#0000FF",
"green": "#00FF00",
"yellow": "#FFFF00",
},
Targeting: []byte(`{
"if": [
{
"sem_ver": [1.2, "=", "1.2"]
},
"red", "green"
]
}`),
},
},
},
flagKey: "headerColor",
context: map[string]any{
"version": "1.0.0",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"versions with prefixed v operator provided - match": {
flags: Flags{
Flags: map[string]model.Flag{
"headerColor": {
State: "ENABLED",
DefaultVariant: "red",
Variants: map[string]any{
"red": "#FF0000",
"blue": "#0000FF",
"green": "#00FF00",
"yellow": "#FFFF00",
},
Targeting: []byte(`{
"if": [
{
"sem_ver": [{"var": "version"}, "<", "v1.2"]
},
"red", "green"
]
}`),
},
},
},
flagKey: "headerColor",
context: map[string]any{
"version": "v1.0.0",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"versions and major-version operator provided - no match": {
flags: Flags{
Flags: map[string]model.Flag{
@ -924,14 +697,17 @@ func TestJSONEvaluator_semVerEvaluation(t *testing.T) {
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.Flags, model.Metadata{})
je := NewJSON(
log,
store.NewFlags(),
WithEvaluator(
SemVerEvaluationName,
NewSemVerComparison(log).SemVerEvaluation,
),
)
je.store.Flags = tt.flags.Flags
value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant)
value, variant, reason, _, err := resolve[string](reqID, tt.flagKey, tt.context, je.evaluateVariant)
if value != tt.expectedValue {
t.Errorf("expected value '%s', got '%s'", tt.expectedValue, value)
@ -945,7 +721,7 @@ func TestJSONEvaluator_semVerEvaluation(t *testing.T) {
t.Errorf("expected reason '%s', got '%s'", tt.expectedReason, reason)
}
if !errors.Is(err, tt.expectedError) {
if err != tt.expectedError {
t.Errorf("expected err '%v', got '%v'", tt.expectedError, err)
}
})

View File

@ -68,7 +68,7 @@ func (sce *StringComparisonEvaluator) StartsWithEvaluation(values, _ interface{}
//
// Note that the 'ends_with' evaluation rule must contain exactly two items, which both resolve to a
// string value
func (sce *StringComparisonEvaluator) EndsWithEvaluation(values, _ interface{}) interface{} {
func (sce StringComparisonEvaluator) EndsWithEvaluation(values, _ interface{}) interface{} {
propertyValue, target, err := parseStringComparisonEvaluationData(values)
if err != nil {
sce.Logger.Error(fmt.Sprintf("parse ends_with evaluation data: %v", err))

View File

@ -1,8 +1,6 @@
package evaluator
import (
"context"
"errors"
"fmt"
"testing"
@ -13,10 +11,6 @@ import (
)
func TestJSONEvaluator_startsWithEvaluation(t *testing.T) {
const source = "testSource"
var sources = []string{source}
ctx := context.Background()
tests := map[string]struct {
flags Flags
flagKey string
@ -187,14 +181,17 @@ func TestJSONEvaluator_startsWithEvaluation(t *testing.T) {
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.Flags, model.Metadata{})
je := NewJSON(
log,
store.NewFlags(),
WithEvaluator(
StartsWithEvaluationName,
NewStringComparisonEvaluator(log).StartsWithEvaluation,
),
)
je.store.Flags = tt.flags.Flags
value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant)
value, variant, reason, _, err := resolve[string](reqID, tt.flagKey, tt.context, je.evaluateVariant)
if value != tt.expectedValue {
t.Errorf("expected value '%s', got '%s'", tt.expectedValue, value)
@ -208,7 +205,7 @@ func TestJSONEvaluator_startsWithEvaluation(t *testing.T) {
t.Errorf("expected reason '%s', got '%s'", tt.expectedReason, reason)
}
if !errors.Is(err, tt.expectedError) {
if err != tt.expectedError {
t.Errorf("expected err '%v', got '%v'", tt.expectedError, err)
}
})
@ -216,10 +213,6 @@ func TestJSONEvaluator_startsWithEvaluation(t *testing.T) {
}
func TestJSONEvaluator_endsWithEvaluation(t *testing.T) {
const source = "testSource"
var sources = []string{source}
ctx := context.Background()
tests := map[string]struct {
flags Flags
flagKey string
@ -390,14 +383,18 @@ func TestJSONEvaluator_endsWithEvaluation(t *testing.T) {
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.Flags, model.Metadata{})
je := NewJSON(
log,
store.NewFlags(),
WithEvaluator(
EndsWithEvaluationName,
NewStringComparisonEvaluator(log).EndsWithEvaluation,
),
)
value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant)
je.store.Flags = tt.flags.Flags
value, variant, reason, _, err := resolve[string](reqID, tt.flagKey, tt.context, je.evaluateVariant)
if value != tt.expectedValue {
t.Errorf("expected value '%s', got '%s'", tt.expectedValue, value)

View File

@ -1,28 +1,9 @@
package model
import "fmt"
const (
FlagNotFoundErrorCode = "FLAG_NOT_FOUND"
ParseErrorCode = "PARSE_ERROR"
TypeMismatchErrorCode = "TYPE_MISMATCH"
GeneralErrorCode = "GENERAL"
FlagDisabledErrorCode = "FLAG_DISABLED"
InvalidContextCode = "INVALID_CONTEXT"
)
var ReadableErrorMessage = map[string]string{
FlagNotFoundErrorCode: "Flag not found",
ParseErrorCode: "Error parsing input or configuration",
TypeMismatchErrorCode: "Type mismatch error",
GeneralErrorCode: "General error",
FlagDisabledErrorCode: "Flag is disabled",
InvalidContextCode: "Invalid context provided",
}
func GetErrorMessage(code string) string {
if msg, exists := ReadableErrorMessage[code]; exists {
return msg
}
return fmt.Sprintf("Unknown error code: %s", code)
}

View File

@ -2,26 +2,14 @@ package model
import "encoding/json"
const Key = "Key"
const FlagSetId = "FlagSetId"
const Source = "Source"
const Priority = "Priority"
type Flag struct {
Key string `json:"-"` // not serialized, used only for indexing
FlagSetId string `json:"-"` // not serialized, used only for indexing
Priority int `json:"-"` // not serialized, used only for indexing
State string `json:"state"`
DefaultVariant string `json:"defaultVariant"`
Variants map[string]any `json:"variants"`
Targeting json.RawMessage `json:"targeting,omitempty"`
Source string `json:"source"`
Selector string `json:"selector"`
Metadata Metadata `json:"metadata,omitempty"`
}
type Evaluators struct {
Evaluators map[string]json.RawMessage `json:"$evaluators"`
}
type Metadata = map[string]interface{}

View File

@ -1,52 +0,0 @@
package notifications
import (
"reflect"
"github.com/open-feature/flagd/core/pkg/model"
)
const typeField = "type"
// Use to represent change notifications for mode PROVIDER_CONFIGURATION_CHANGE events.
type Notifications map[string]any
// Generate notifications (deltas) from old and new flag sets for use in RPC mode PROVIDER_CONFIGURATION_CHANGE events.
func NewFromFlags(oldFlags, newFlags map[string]model.Flag) Notifications {
notifications := map[string]interface{}{}
// flags removed
for key := range oldFlags {
if _, ok := newFlags[key]; !ok {
notifications[key] = map[string]interface{}{
typeField: string(model.NotificationDelete),
}
}
}
// flags added or modified
for key, newFlag := range newFlags {
oldFlag, exists := oldFlags[key]
if !exists {
notifications[key] = map[string]interface{}{
typeField: string(model.NotificationCreate),
}
} else if !flagsEqual(oldFlag, newFlag) {
notifications[key] = map[string]interface{}{
typeField: string(model.NotificationUpdate),
}
}
}
return notifications
}
func flagsEqual(a, b model.Flag) bool {
return a.State == b.State &&
a.DefaultVariant == b.DefaultVariant &&
reflect.DeepEqual(a.Variants, b.Variants) &&
reflect.DeepEqual(a.Targeting, b.Targeting) &&
a.Source == b.Source &&
a.Selector == b.Selector &&
reflect.DeepEqual(a.Metadata, b.Metadata)
}

View File

@ -1,102 +0,0 @@
package notifications
import (
"testing"
"github.com/open-feature/flagd/core/pkg/model"
"github.com/stretchr/testify/assert"
)
func TestNewFromFlags(t *testing.T) {
flagA := model.Flag{
Key: "flagA",
State: "ENABLED",
DefaultVariant: "on",
Source: "source1",
}
flagAUpdated := model.Flag{
Key: "flagA",
State: "DISABLED",
DefaultVariant: "on",
Source: "source1",
}
flagB := model.Flag{
Key: "flagB",
State: "ENABLED",
DefaultVariant: "off",
Source: "source1",
}
tests := []struct {
name string
oldFlags map[string]model.Flag
newFlags map[string]model.Flag
want Notifications
}{
{
name: "flag added",
oldFlags: map[string]model.Flag{},
newFlags: map[string]model.Flag{"flagA": flagA},
want: Notifications{
"flagA": map[string]interface{}{
"type": string(model.NotificationCreate),
},
},
},
{
name: "flag deleted",
oldFlags: map[string]model.Flag{"flagA": flagA},
newFlags: map[string]model.Flag{},
want: Notifications{
"flagA": map[string]interface{}{
"type": string(model.NotificationDelete),
},
},
},
{
name: "flag changed",
oldFlags: map[string]model.Flag{"flagA": flagA},
newFlags: map[string]model.Flag{"flagA": flagAUpdated},
want: Notifications{
"flagA": map[string]interface{}{
"type": string(model.NotificationUpdate),
},
},
},
{
name: "flag unchanged",
oldFlags: map[string]model.Flag{"flagA": flagA},
newFlags: map[string]model.Flag{"flagA": flagA},
want: Notifications{},
},
{
name: "mixed changes",
oldFlags: map[string]model.Flag{
"flagA": flagA,
"flagB": flagB,
},
newFlags: map[string]model.Flag{
"flagA": flagAUpdated, // updated
"flagC": flagA, // added
},
want: Notifications{
"flagA": map[string]interface{}{
"type": string(model.NotificationUpdate),
},
"flagB": map[string]interface{}{
"type": string(model.NotificationDelete),
},
"flagC": map[string]interface{}{
"type": string(model.NotificationCreate),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NewFromFlags(tt.oldFlags, tt.newFlags)
assert.Equal(t, tt.want, got)
})
}
}

View File

@ -0,0 +1,146 @@
package runtime
import (
"context"
"fmt"
"github.com/open-feature/flagd/core/pkg/evaluator"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/service"
flageval "github.com/open-feature/flagd/core/pkg/service/flag-evaluation"
"github.com/open-feature/flagd/core/pkg/store"
"github.com/open-feature/flagd/core/pkg/sync"
syncbuilder "github.com/open-feature/flagd/core/pkg/sync/builder"
"github.com/open-feature/flagd/core/pkg/telemetry"
"go.uber.org/zap"
)
// from_config is a collection of structures and parsers responsible for deriving flagd runtime
const svcName = "flagd"
// Config is the configuration structure derived from startup arguments.
type Config struct {
MetricExporter string
ManagementPort uint16
OtelCollectorURI string
ServiceCertPath string
ServiceKeyPath string
ServicePort uint16
ServiceSocketPath string
SyncProviders []sync.SourceConfig
CORS []string
}
// FromConfig builds a runtime from startup configurations
// nolint: funlen
func FromConfig(logger *logger.Logger, version string, config Config) (*Runtime, error) {
telCfg := telemetry.Config{
MetricsExporter: config.MetricExporter,
CollectorTarget: config.OtelCollectorURI,
}
// register error handling for OpenTelemetry
telemetry.RegisterErrorHandling(logger)
// register trace provider for the runtime
err := telemetry.BuildTraceProvider(context.Background(), logger, svcName, version, telCfg)
if err != nil {
return nil, fmt.Errorf("error building trace provider: %w", err)
}
// build metrics recorder with startup configurations
recorder, err := telemetry.BuildMetricsRecorder(context.Background(), svcName, version, telCfg)
if err != nil {
return nil, fmt.Errorf("error building metrics recorder: %w", err)
}
// build flag store & fill sources details
s := store.NewFlags()
for _, provider := range config.SyncProviders {
s.FlagSources = append(s.FlagSources, provider.URI)
s.SourceMetadata[provider.URI] = store.SourceDetails{
Source: provider.URI,
Selector: provider.Selector,
}
}
// derive evaluator
evaluator := setupJSONEvaluator(logger, s)
// derive service
connectService := flageval.NewConnectService(
logger.WithFields(zap.String("component", "service")),
evaluator,
recorder)
// build sync providers
syncLogger := logger.WithFields(zap.String("component", "sync"))
iSyncs, err := syncProvidersFromConfig(syncLogger, config.SyncProviders)
if err != nil {
return nil, err
}
options, err := telemetry.BuildConnectOptions(telCfg)
if err != nil {
return nil, fmt.Errorf("failed to build connect options, %w", err)
}
return &Runtime{
Logger: logger.WithFields(zap.String("component", "runtime")),
Evaluator: evaluator,
Service: connectService,
ServiceConfig: service.Configuration{
Port: config.ServicePort,
ManagementPort: config.ManagementPort,
ServiceName: svcName,
KeyPath: config.ServiceKeyPath,
CertPath: config.ServiceCertPath,
SocketPath: config.ServiceSocketPath,
CORS: config.CORS,
Options: options,
},
SyncImpl: iSyncs,
}, nil
}
func setupJSONEvaluator(logger *logger.Logger, s *store.Flags) *evaluator.JSON {
evaluator := evaluator.NewJSON(
logger,
s,
evaluator.WithEvaluator(
evaluator.FractionEvaluationName,
evaluator.NewFractional(logger).Evaluate,
),
evaluator.WithEvaluator(
evaluator.StartsWithEvaluationName,
evaluator.NewStringComparisonEvaluator(logger).StartsWithEvaluation,
),
evaluator.WithEvaluator(
evaluator.EndsWithEvaluationName,
evaluator.NewStringComparisonEvaluator(logger).EndsWithEvaluation,
),
evaluator.WithEvaluator(
evaluator.SemVerEvaluationName,
evaluator.NewSemVerComparison(logger).SemVerEvaluation,
),
// deprecated: will be removed before v1!
evaluator.WithEvaluator(
evaluator.LegacyFractionEvaluationName,
evaluator.NewLegacyFractional(logger).LegacyFractionalEvaluation,
),
)
return evaluator
}
// syncProvidersFromConfig is a helper to build ISync implementations from SourceConfig
func syncProvidersFromConfig(logger *logger.Logger, sources []sync.SourceConfig) ([]sync.ISync, error) {
builder := syncbuilder.NewSyncBuilder()
syncs, err := builder.SyncsFromConfig(sources, logger)
if err != nil {
return nil, fmt.Errorf("could not create sync sources from config: %w", err)
}
return syncs, nil
}

View File

@ -0,0 +1,16 @@
package runtime
import (
"testing"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/store"
"github.com/stretchr/testify/require"
)
func Test_setupJSONEvaluator(t *testing.T) {
lg := logger.NewLogger(nil, false)
je := setupJSONEvaluator(lg, store.NewFlags())
require.NotNil(t, je)
}

View File

@ -13,29 +13,25 @@ import (
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/service"
"github.com/open-feature/flagd/core/pkg/sync"
"github.com/open-feature/flagd/flagd/pkg/service/flag-evaluation/ofrep"
flagsync "github.com/open-feature/flagd/flagd/pkg/service/flag-sync"
"golang.org/x/sync/errgroup"
)
type Runtime struct {
Evaluator evaluator.IEvaluator
Logger *logger.Logger
SyncService flagsync.ISyncService
OfrepService ofrep.IOfrepService
EvaluationService service.IFlagEvaluationService
ServiceConfig service.Configuration
Syncs []sync.ISync
Evaluator evaluator.IEvaluator
Logger *logger.Logger
Service service.IFlagEvaluationService
ServiceConfig service.Configuration
SyncImpl []sync.ISync
mu msync.Mutex
}
//nolint:funlen
func (r *Runtime) Start() error {
if r.EvaluationService == nil {
if r.Service == nil {
return errors.New("no service set")
}
if len(r.Syncs) == 0 {
if len(r.SyncImpl) == 0 {
return errors.New("no sync implementation set")
}
if r.Evaluator == nil {
@ -44,26 +40,42 @@ func (r *Runtime) Start() error {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
g, gCtx := errgroup.WithContext(ctx)
dataSync := make(chan sync.DataSync, len(r.Syncs))
dataSync := make(chan sync.DataSync, len(r.SyncImpl))
// Initialize DataSync channel watcher
g.Go(func() error {
for {
select {
case data := <-dataSync:
r.updateAndEmit(data)
// resync events are triggered when a delete occurs during flag merges in the store
// resync events may trigger further resync events, however for a flag to be deleted from the store
// its source must match, preventing the opportunity for resync events to snowball
if resyncRequired := r.updateWithNotify(data); resyncRequired {
for _, s := range r.SyncImpl {
p := s
go func() {
g.Go(func() error {
err := p.ReSync(gCtx, dataSync)
if err != nil {
return fmt.Errorf("error resyncing sources: %w", err)
}
return nil
})
}()
}
}
case <-gCtx.Done():
return nil
}
}
})
// Init sync providers
for _, s := range r.Syncs {
for _, s := range r.SyncImpl {
if err := s.Init(gCtx); err != nil {
return fmt.Errorf("sync provider Init returned error: %w", err)
}
}
// Start sync provider
for _, s := range r.Syncs {
for _, s := range r.SyncImpl {
p := s
g.Go(func() error {
if err := p.Sync(gCtx, dataSync); err != nil {
@ -75,37 +87,19 @@ func (r *Runtime) Start() error {
defer func() {
r.Logger.Info("Shutting down server...")
r.EvaluationService.Shutdown()
r.Service.Shutdown()
r.Logger.Info("Server successfully shutdown.")
}()
g.Go(func() error {
// Readiness probe rely on the runtime
r.ServiceConfig.ReadinessProbe = r.isReady
if err := r.EvaluationService.Serve(gCtx, r.ServiceConfig); err != nil {
if err := r.Service.Serve(gCtx, r.ServiceConfig); err != nil {
return fmt.Errorf("error returned from serving flag evaluation service: %w", err)
}
return nil
})
g.Go(func() error {
err := r.OfrepService.Start(gCtx)
if err != nil {
return fmt.Errorf("error from ofrep server: %w", err)
}
return nil
})
g.Go(func() error {
err := r.SyncService.Start(gCtx)
if err != nil {
return fmt.Errorf("error from sync server: %w", err)
}
return nil
})
<-gCtx.Done()
if err := g.Wait(); err != nil {
return fmt.Errorf("errgroup closed with error: %w", err)
}
@ -114,7 +108,7 @@ func (r *Runtime) Start() error {
func (r *Runtime) isReady() bool {
// if all providers can watch for flag changes, we are ready.
for _, p := range r.Syncs {
for _, p := range r.SyncImpl {
if !p.IsReady() {
return false
}
@ -122,15 +116,23 @@ func (r *Runtime) isReady() bool {
return true
}
// updateAndEmit helps to update state, notify changes and trigger sync updates
func (r *Runtime) updateAndEmit(payload sync.DataSync) {
// updateWithNotify helps to update state and notify listeners
func (r *Runtime) updateWithNotify(payload sync.DataSync) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, _, err := r.Evaluator.SetState(payload)
notifications, resyncRequired, err := r.Evaluator.SetState(payload)
if err != nil {
r.Logger.Error(fmt.Sprintf("error setting state: %v", err))
return
r.Logger.Error(err.Error())
return false
}
r.SyncService.Emit(payload.Source)
r.Service.Notify(service.Notification{
Type: service.ConfigurationChange,
Data: map[string]interface{}{
"flags": notifications,
},
})
return resyncRequired
}

View File

@ -16,12 +16,11 @@ import (
"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/service/middleware"
corsmw "github.com/open-feature/flagd/core/pkg/service/middleware/cors"
h2cmw "github.com/open-feature/flagd/core/pkg/service/middleware/h2c"
metricsmw "github.com/open-feature/flagd/core/pkg/service/middleware/metrics"
"github.com/open-feature/flagd/core/pkg/telemetry"
"github.com/open-feature/flagd/flagd/pkg/service/middleware"
corsmw "github.com/open-feature/flagd/flagd/pkg/service/middleware/cors"
h2cmw "github.com/open-feature/flagd/flagd/pkg/service/middleware/h2c"
metricsmw "github.com/open-feature/flagd/flagd/pkg/service/middleware/metrics"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/zap"
"golang.org/x/net/http2"
@ -58,8 +57,8 @@ func (b bufSwitchHandler) ServeHTTP(writer http.ResponseWriter, request *http.Re
type ConnectService struct {
logger *logger.Logger
eval evaluator.IEvaluator
metrics telemetry.IMetricsRecorder
eventingConfiguration IEvents
metrics *telemetry.MetricsRecorder
eventingConfiguration *eventingConfiguration
server *http.Server
metricsServer *http.Server
@ -72,23 +71,17 @@ type ConnectService struct {
// NewConnectService creates a ConnectService with provided parameters
func NewConnectService(
logger *logger.Logger, evaluator evaluator.IEvaluator, store store.IStore, mRecorder telemetry.IMetricsRecorder,
logger *logger.Logger, evaluator evaluator.IEvaluator, mRecorder *telemetry.MetricsRecorder,
) *ConnectService {
cs := &ConnectService{
return &ConnectService{
logger: logger,
eval: evaluator,
metrics: &telemetry.NoopMetricsRecorder{},
metrics: mRecorder,
eventingConfiguration: &eventingConfiguration{
subs: make(map[interface{}]chan service.Notification),
mu: &sync.RWMutex{},
store: store,
logger: logger,
subs: make(map[interface{}]chan service.Notification),
mu: &sync.RWMutex{},
},
}
if mRecorder != nil {
cs.metrics = mRecorder
}
return cs
}
// Serve serves services with provided configuration options
@ -132,7 +125,7 @@ func (s *ConnectService) Serve(ctx context.Context, svcConf service.Configuratio
// Notify emits change event notifications for subscriptions
func (s *ConnectService) Notify(n service.Notification) {
s.eventingConfiguration.EmitToAll(n)
s.eventingConfiguration.emitToAll(n)
}
// nolint: funlen
@ -157,7 +150,6 @@ func (s *ConnectService) setupServer(svcConf service.Configuration) (net.Listene
s.eval,
s.eventingConfiguration,
s.metrics,
svcConf.ContextValues,
)
marshalOpts := WithJSON(
@ -174,9 +166,6 @@ func (s *ConnectService) setupServer(svcConf service.Configuration) (net.Listene
s.eval,
s.eventingConfiguration,
s.metrics,
svcConf.ContextValues,
svcConf.HeaderToContextKeyMappings,
svcConf.StreamDeadline,
)
_, newHandler := evaluationV1.NewServiceHandler(newFes, append(svcConf.Options, marshalOpts)...)
@ -220,7 +209,7 @@ func (s *ConnectService) AddMiddleware(mw middleware.IMiddleware) {
func (s *ConnectService) Shutdown() {
s.readinessEnabled = false
s.eventingConfiguration.EmitToAll(service.Notification{
s.eventingConfiguration.emitToAll(service.Notification{
Type: service.Shutdown,
Data: map[string]interface{}{},
})
@ -231,7 +220,7 @@ func (s *ConnectService) startServer(svcConf service.Configuration) error {
if err != nil {
return err
}
s.logger.Info(fmt.Sprintf("Flag IResolver listening at %s", lis.Addr()))
s.logger.Info(fmt.Sprintf("Flag Evaluation listening at %s", lis.Addr()))
if svcConf.CertPath != "" && svcConf.KeyPath != "" {
if err := s.server.ServeTLS(
lis,
@ -253,8 +242,8 @@ func (s *ConnectService) startServer(svcConf service.Configuration) error {
func (s *ConnectService) startMetricsServer(svcConf service.Configuration) error {
s.logger.Info(fmt.Sprintf("metrics and probes listening at %d", svcConf.ManagementPort))
srv := grpc.NewServer()
grpc_health_v1.RegisterHealthServer(srv, health.NewServer())
grpc := grpc.NewServer()
grpc_health_v1.RegisterHealthServer(grpc, health.NewServer())
mux := http.NewServeMux()
mux.Handle("/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -272,7 +261,7 @@ func (s *ConnectService) startMetricsServer(svcConf service.Configuration) error
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
// if this is 'application/grpc' and HTTP2, handle with gRPC, otherwise HTTP.
if request.ProtoMajor == 2 && strings.HasPrefix(request.Header.Get("Content-Type"), "application/grpc") {
srv.ServeHTTP(writer, request)
grpc.ServeHTTP(writer, request)
} else {
mux.ServeHTTP(writer, request)
return

View File

@ -6,24 +6,21 @@ import (
"fmt"
"net/http"
"os"
"sync"
"testing"
"time"
schemaGrpcV1 "buf.build/gen/go/open-feature/flagd/grpc/go/schema/v1/schemav1grpc"
schemaV1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/schema/v1"
"github.com/golang/mock/gomock"
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"
middlewaremock "github.com/open-feature/flagd/core/pkg/service/middleware/mock"
"github.com/open-feature/flagd/core/pkg/telemetry"
middlewaremock "github.com/open-feature/flagd/flagd/pkg/service/middleware/mock"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
"go.uber.org/mock/gomock"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/structpb"
@ -84,7 +81,7 @@ func TestConnectService_UnixConnection(t *testing.T) {
exp := metric.NewManualReader()
rs := resource.NewWithAttributes("testSchema")
metricRecorder := telemetry.NewOTelRecorder(exp, rs, tt.name)
svc := NewConnectService(logger.NewLogger(nil, false), eval, &store.Store{}, metricRecorder)
svc := NewConnectService(logger.NewLogger(nil, false), eval, metricRecorder)
serveConf := iservice.Configuration{
ReadinessProbe: func() bool {
return true
@ -139,7 +136,7 @@ func TestAddMiddleware(t *testing.T) {
rs := resource.NewWithAttributes("testSchema")
metricRecorder := telemetry.NewOTelRecorder(exp, rs, "my-exporter")
svc := NewConnectService(logger.NewLogger(nil, false), nil, &store.Store{}, metricRecorder)
svc := NewConnectService(logger.NewLogger(nil, false), nil, metricRecorder)
serveConf := iservice.Configuration{
ReadinessProbe: func() bool {
@ -176,22 +173,16 @@ func TestConnectServiceNotify(t *testing.T) {
// given
ctrl := gomock.NewController(t)
eval := mock.NewMockIEvaluator(ctrl)
sources := []string{"source1", "source2"}
log := logger.NewLogger(nil, false)
s, err := store.NewStore(log, sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
exp := metric.NewManualReader()
rs := resource.NewWithAttributes("testSchema")
metricRecorder := telemetry.NewOTelRecorder(exp, rs, "my-exporter")
service := NewConnectService(logger.NewLogger(nil, false), eval, s, metricRecorder)
service := NewConnectService(logger.NewLogger(nil, false), eval, metricRecorder)
sChan := make(chan iservice.Notification, 1)
eventing := service.eventingConfiguration
eventing.Subscribe(context.Background(), "key", nil, sChan)
eventing.subs["key"] = sChan
// notification type
ofType := iservice.ConfigurationChange
@ -216,73 +207,20 @@ func TestConnectServiceNotify(t *testing.T) {
}
}
func TestConnectServiceWatcher(t *testing.T) {
sources := []string{"source1", "source2"}
log := logger.NewLogger(nil, false)
s, err := store.NewStore(log, sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
sChan := make(chan iservice.Notification, 1)
eventing := eventingConfiguration{
store: s,
logger: log,
mu: &sync.RWMutex{},
subs: make(map[any]chan iservice.Notification),
}
// subscribe and wait for for the sub to be active
eventing.Subscribe(context.Background(), "anything", nil, sChan)
time.Sleep(100 * time.Millisecond)
// make a change
s.Update(sources[0], map[string]model.Flag{
"flag1": {
Key: "flag1",
DefaultVariant: "off",
},
}, model.Metadata{})
// notification type
ofType := iservice.ConfigurationChange
timeout, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
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{})
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():
t.Error("timeout while waiting for notifications")
}
}
func TestConnectServiceShutdown(t *testing.T) {
// given
ctrl := gomock.NewController(t)
eval := mock.NewMockIEvaluator(ctrl)
sources := []string{"source1", "source2"}
log := logger.NewLogger(nil, false)
s, err := store.NewStore(log, sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
exp := metric.NewManualReader()
rs := resource.NewWithAttributes("testSchema")
metricRecorder := telemetry.NewOTelRecorder(exp, rs, "my-exporter")
service := NewConnectService(logger.NewLogger(nil, false), eval, s, metricRecorder)
service := NewConnectService(logger.NewLogger(nil, false), eval, metricRecorder)
sChan := make(chan iservice.Notification, 1)
eventing := service.eventingConfiguration
eventing.Subscribe(context.Background(), "key", nil, sChan)
eventing.subs["key"] = sChan
// notification type
ofType := iservice.Shutdown

View File

@ -0,0 +1,36 @@
package service
import (
"sync"
iservice "github.com/open-feature/flagd/core/pkg/service"
)
// eventingConfiguration is a wrapper for notification subscriptions
type eventingConfiguration struct {
mu *sync.RWMutex
subs map[interface{}]chan iservice.Notification
}
func (eventing *eventingConfiguration) subscribe(id interface{}, notifyChan chan iservice.Notification) {
eventing.mu.Lock()
defer eventing.mu.Unlock()
eventing.subs[id] = notifyChan
}
func (eventing *eventingConfiguration) emitToAll(n iservice.Notification) {
eventing.mu.RLock()
defer eventing.mu.RUnlock()
for _, send := range eventing.subs {
send <- n
}
}
func (eventing *eventingConfiguration) unSubscribe(id interface{}) {
eventing.mu.Lock()
defer eventing.mu.Unlock()
delete(eventing.subs, id)
}

View File

@ -1,29 +1,18 @@
package service
import (
"context"
"sync"
"testing"
"github.com/open-feature/flagd/core/pkg/logger"
iservice "github.com/open-feature/flagd/core/pkg/service"
"github.com/open-feature/flagd/core/pkg/store"
"github.com/stretchr/testify/require"
)
func TestSubscribe(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,
subs: make(map[interface{}]chan iservice.Notification),
mu: &sync.RWMutex{},
}
idA := "a"
@ -33,8 +22,8 @@ func TestSubscribe(t *testing.T) {
chanB := make(chan iservice.Notification, 1)
// when
eventing.Subscribe(context.Background(), idA, nil, chanA)
eventing.Subscribe(context.Background(), idB, nil, chanB)
eventing.subscribe(idA, chanA)
eventing.subscribe(idB, chanB)
// then
require.Equal(t, chanA, eventing.subs[idA], "incorrect subscription association")
@ -43,16 +32,9 @@ 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,
subs: make(map[interface{}]chan iservice.Notification),
mu: &sync.RWMutex{},
}
idA := "a"
@ -61,10 +43,10 @@ func TestUnsubscribe(t *testing.T) {
chanB := make(chan iservice.Notification, 1)
// when
eventing.Subscribe(context.Background(), idA, nil, chanA)
eventing.Subscribe(context.Background(), idB, nil, chanB)
eventing.subscribe(idA, chanA)
eventing.subscribe(idB, chanB)
eventing.Unsubscribe(idA)
eventing.unSubscribe(idA)
// then
require.Empty(t, eventing.subs[idA],

View File

@ -3,7 +3,6 @@ package service
import (
"context"
"fmt"
"net/http"
"time"
schemaV1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/schema/v1"
@ -12,9 +11,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/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"
@ -32,64 +29,45 @@ type resolverSignature[T constraints] func(context context.Context, reqID, flagK
type OldFlagEvaluationService struct {
logger *logger.Logger
eval evaluator.IEvaluator
metrics telemetry.IMetricsRecorder
eventingConfiguration IEvents
metrics *telemetry.MetricsRecorder
eventingConfiguration *eventingConfiguration
flagEvalTracer trace.Tracer
contextValues map[string]any
}
// NewOldFlagEvaluationService creates a OldFlagEvaluationService with provided parameters
func NewOldFlagEvaluationService(
log *logger.Logger,
eval evaluator.IEvaluator,
eventingCfg IEvents,
metricsRecorder telemetry.IMetricsRecorder,
contextValues map[string]any,
func NewOldFlagEvaluationService(log *logger.Logger,
eval evaluator.IEvaluator, eventingCfg *eventingConfiguration, metricsRecorder *telemetry.MetricsRecorder,
) *OldFlagEvaluationService {
svc := &OldFlagEvaluationService{
return &OldFlagEvaluationService{
logger: log,
eval: eval,
metrics: &telemetry.NoopMetricsRecorder{},
metrics: metricsRecorder,
eventingConfiguration: eventingCfg,
flagEvalTracer: otel.Tracer("flagEvaluationService"),
contextValues: contextValues,
}
if metricsRecorder != nil {
svc.metrics = metricsRecorder
}
return svc
}
// nolint:dupl,funlen,staticcheck
// nolint:dupl
func (s *OldFlagEvaluationService) ResolveAll(
ctx context.Context,
req *connect.Request[schemaV1.ResolveAllRequest],
) (*connect.Response[schemaV1.ResolveAllResponse], error) {
reqID := xid.New().String()
defer s.logger.ClearFields(reqID)
ctx, span := s.flagEvalTracer.Start(ctx, "resolveAll", trace.WithSpanKind(trace.SpanKindServer))
sCtx, span := s.flagEvalTracer.Start(ctx, "resolveAll", trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
res := &schemaV1.ResolveAllResponse{
Flags: make(map[string]*schemaV1.AnyFlag),
}
selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER)
selector := store.NewSelector(selectorExpression)
ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector)
values, _, err := s.eval.ResolveAllValues(ctx, reqID, mergeContexts(req.Msg.GetContext().AsMap(), s.contextValues, req.Header(), make(map[string]string)))
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)
evalCtx := map[string]any{}
if e := req.Msg.GetContext(); e != nil {
evalCtx = e.AsMap()
}
values := s.eval.ResolveAllValues(sCtx, reqID, evalCtx)
span.SetAttributes(attribute.Int("feature_flag.count", len(values)))
for _, value := range values {
// register the impression and reason for each flag evaluated
s.metrics.RecordEvaluation(ctx, value.Error, value.Reason, value.Variant, value.FlagKey)
s.metrics.RecordEvaluation(sCtx, value.Error, value.Reason, value.Variant, value.FlagKey)
switch v := value.Value.(type) {
case bool:
res.Flags[value.FlagKey] = &schemaV1.AnyFlag{
@ -133,19 +111,14 @@ func (s *OldFlagEvaluationService) ResolveAll(
return connect.NewResponse(res), nil
}
// nolint:dupl,staticcheck
func (s *OldFlagEvaluationService) EventStream(
ctx context.Context,
req *connect.Request[schemaV1.EventStreamRequest],
stream *connect.ServerStream[schemaV1.EventStreamResponse],
) error {
s.logger.Debug(fmt.Sprintf("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)
s.eventingConfiguration.subscribe(req, requestNotificationChan)
defer s.eventingConfiguration.unSubscribe(req)
requestNotificationChan <- service.Notification{
Type: service.ProviderReady,
@ -177,29 +150,21 @@ func (s *OldFlagEvaluationService) EventStream(
}
}
//nolint:staticcheck
func (s *OldFlagEvaluationService) ResolveBoolean(
ctx context.Context,
req *connect.Request[schemaV1.ResolveBooleanRequest],
) (*connect.Response[schemaV1.ResolveBooleanResponse], error) {
ctx, span := s.flagEvalTracer.Start(ctx, "resolveBoolean", trace.WithSpanKind(trace.SpanKindServer))
sCtx, span := s.flagEvalTracer.Start(ctx, "resolveBoolean", trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
res := connect.NewResponse(&schemaV1.ResolveBooleanResponse{})
selectorExpression := req.Header().Get(flagdService.FLAGD_SELECTOR_HEADER)
selector := store.NewSelector(selectorExpression)
ctx = context.WithValue(ctx, store.SelectorContextKey{}, selector)
err := resolve[bool](
ctx,
sCtx,
s.logger,
s.eval.ResolveBooleanValue,
req.Header(),
req.Msg.GetFlagKey(),
req.Msg.GetContext(),
&booleanResponse{schemaV1Resp: res},
s.metrics,
s.contextValues,
make(map[string]string),
)
if err != nil {
span.RecordError(err)
@ -209,30 +174,22 @@ func (s *OldFlagEvaluationService) ResolveBoolean(
return res, err
}
//nolint:staticcheck
func (s *OldFlagEvaluationService) ResolveString(
ctx context.Context,
req *connect.Request[schemaV1.ResolveStringRequest],
) (*connect.Response[schemaV1.ResolveStringResponse], error) {
ctx, span := s.flagEvalTracer.Start(ctx, "resolveString", trace.WithSpanKind(trace.SpanKindServer))
sCtx, 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)
res := connect.NewResponse(&schemaV1.ResolveStringResponse{})
err := resolve[string](
ctx,
sCtx,
s.logger,
s.eval.ResolveStringValue,
req.Header(),
req.Msg.GetFlagKey(),
req.Msg.GetContext(),
&stringResponse{schemaV1Resp: res},
s.metrics,
s.contextValues,
make(map[string]string),
)
if err != nil {
span.RecordError(err)
@ -242,30 +199,22 @@ func (s *OldFlagEvaluationService) ResolveString(
return res, err
}
//nolint:staticcheck
func (s *OldFlagEvaluationService) ResolveInt(
ctx context.Context,
req *connect.Request[schemaV1.ResolveIntRequest],
) (*connect.Response[schemaV1.ResolveIntResponse], error) {
ctx, span := s.flagEvalTracer.Start(ctx, "resolveInt", trace.WithSpanKind(trace.SpanKindServer))
sCtx, 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)
res := connect.NewResponse(&schemaV1.ResolveIntResponse{})
err := resolve[int64](
ctx,
sCtx,
s.logger,
s.eval.ResolveIntValue,
req.Header(),
req.Msg.GetFlagKey(),
req.Msg.GetContext(),
&intResponse{schemaV1Resp: res},
s.metrics,
s.contextValues,
make(map[string]string),
)
if err != nil {
span.RecordError(err)
@ -275,30 +224,22 @@ func (s *OldFlagEvaluationService) ResolveInt(
return res, err
}
//nolint:staticcheck
func (s *OldFlagEvaluationService) ResolveFloat(
ctx context.Context,
req *connect.Request[schemaV1.ResolveFloatRequest],
) (*connect.Response[schemaV1.ResolveFloatResponse], error) {
ctx, span := s.flagEvalTracer.Start(ctx, "resolveFloat", trace.WithSpanKind(trace.SpanKindServer))
sCtx, 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)
res := connect.NewResponse(&schemaV1.ResolveFloatResponse{})
err := resolve[float64](
ctx,
sCtx,
s.logger,
s.eval.ResolveFloatValue,
req.Header(),
req.Msg.GetFlagKey(),
req.Msg.GetContext(),
&floatResponse{schemaV1Resp: res},
s.metrics,
s.contextValues,
make(map[string]string),
)
if err != nil {
span.RecordError(err)
@ -308,30 +249,22 @@ func (s *OldFlagEvaluationService) ResolveFloat(
return res, err
}
//nolint:staticcheck
func (s *OldFlagEvaluationService) ResolveObject(
ctx context.Context,
req *connect.Request[schemaV1.ResolveObjectRequest],
) (*connect.Response[schemaV1.ResolveObjectResponse], error) {
ctx, span := s.flagEvalTracer.Start(ctx, "resolveObject", trace.WithSpanKind(trace.SpanKindServer))
sCtx, 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)
res := connect.NewResponse(&schemaV1.ResolveObjectResponse{})
err := resolve[map[string]any](
ctx,
sCtx,
s.logger,
s.eval.ResolveObjectValue,
req.Header(),
req.Msg.GetFlagKey(),
req.Msg.GetContext(),
&objectResponse{schemaV1Resp: res},
s.metrics,
s.contextValues,
make(map[string]string),
)
if err != nil {
span.RecordError(err)
@ -341,51 +274,28 @@ 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
}
// resolve is a generic flag resolver
func resolve[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,
func resolve[T constraints](ctx context.Context, logger *logger.Logger, resolver resolverSignature[T], flagKey string,
evaluationContext *structpb.Struct, resp response[T], metrics *telemetry.MetricsRecorder,
) 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)),
zap.Strings("context-keys", formatContextKeys(evaluationContext)),
)
var evalErrFormatted error
result, variant, reason, metadata, evalErr := resolver(ctx, reqID, flagKey, mergedContext)
result, variant, reason, metadata, evalErr := resolver(ctx, reqID, flagKey, evaluationContext.AsMap())
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)
}
metrics.RecordEvaluation(ctx, evalErr, reason, variant, flagKey)
spanFromContext := trace.SpanFromContext(ctx)
spanFromContext.SetAttributes(telemetry.SemConvFeatureFlagAttributes(flagKey, variant)...)
@ -398,25 +308,24 @@ func resolve[T constraints](ctx context.Context, logger *logger.Logger, resolver
return evalErrFormatted
}
func formatContextKeys(context map[string]any) []string {
func formatContextKeys(context *structpb.Struct) []string {
res := []string{}
for k := range context {
for k := range context.AsMap() {
res = append(res, k)
}
return res
}
func errFormat(err error) error {
ReadableErrorMsg := model.GetErrorMessage(err.Error())
switch err.Error() {
case model.FlagNotFoundErrorCode, model.FlagDisabledErrorCode:
return connect.NewError(connect.CodeNotFound, fmt.Errorf("%s", ReadableErrorMsg))
return connect.NewError(connect.CodeNotFound, fmt.Errorf("%s, %s", ErrorPrefix, err.Error()))
case model.TypeMismatchErrorCode:
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("%s", ReadableErrorMsg))
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("%s, %s", ErrorPrefix, err.Error()))
case model.ParseErrorCode:
return connect.NewError(connect.CodeDataLoss, fmt.Errorf("%s", ReadableErrorMsg))
return connect.NewError(connect.CodeDataLoss, fmt.Errorf("%s, %s", ErrorPrefix, err.Error()))
case model.GeneralErrorCode:
return connect.NewError(connect.CodeUnknown, fmt.Errorf("%s", ReadableErrorMsg))
return connect.NewError(connect.CodeUnknown, fmt.Errorf("%s, %s", ErrorPrefix, err.Error()))
}
return err

View File

@ -1,5 +1,3 @@
//lint:file-ignore SA4003 old proto is deprecated but we want to serve it for a while
package service
import (
@ -9,6 +7,7 @@ import (
schemaV1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/schema/v1"
"connectrpc.com/connect"
"github.com/golang/mock/gomock"
"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"
@ -18,7 +17,6 @@ import (
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"go.opentelemetry.io/otel/sdk/resource"
"go.uber.org/mock/gomock"
"google.golang.org/protobuf/types/known/structpb"
)
@ -57,11 +55,10 @@ var sadCommon = evalCommons{
func TestConnectService_ResolveAll(t *testing.T) {
tests := map[string]struct {
req *schemaV1.ResolveAllRequest
evalRes []evaluator.AnyValue
metadata model.Metadata
wantErr error
wantRes *schemaV1.ResolveAllResponse
req *schemaV1.ResolveAllRequest
evalRes []evaluator.AnyValue
wantErr error
wantRes *schemaV1.ResolveAllResponse
}{
"happy-path": {
req: &schemaV1.ResolveAllRequest{},
@ -121,7 +118,7 @@ func TestConnectService_ResolveAll(t *testing.T) {
t.Run(name, func(t *testing.T) {
eval := mock.NewMockIEvaluator(ctrl)
eval.EXPECT().ResolveAllValues(gomock.Any(), gomock.Any(), gomock.Any()).Return(
tt.evalRes, tt.metadata, nil,
tt.evalRes,
).AnyTimes()
metrics, exp := getMetricReader()
s := NewOldFlagEvaluationService(
@ -129,7 +126,6 @@ func TestConnectService_ResolveAll(t *testing.T) {
eval,
&eventingConfiguration{},
metrics,
nil,
)
got, err := s.ResolveAll(context.Background(), connect.NewRequest(tt.req))
if err != nil && !errors.Is(err, tt.wantErr) {
@ -237,7 +233,6 @@ func TestFlag_Evaluation_ResolveBoolean(t *testing.T) {
eval,
&eventingConfiguration{},
metrics,
nil,
)
got, err := s.ResolveBoolean(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req))
if (err != nil) && !errors.Is(err, tt.wantErr) {
@ -293,7 +288,6 @@ func BenchmarkFlag_Evaluation_ResolveBoolean(b *testing.B) {
eval,
&eventingConfiguration{},
metrics,
nil,
)
b.Run(name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
@ -392,7 +386,6 @@ func TestFlag_Evaluation_ResolveString(t *testing.T) {
eval,
&eventingConfiguration{},
metrics,
nil,
)
got, err := s.ResolveString(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req))
if (err != nil) && !errors.Is(err, tt.wantErr) {
@ -448,7 +441,6 @@ func BenchmarkFlag_Evaluation_ResolveString(b *testing.B) {
eval,
&eventingConfiguration{},
metrics,
nil,
)
b.Run(name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
@ -546,7 +538,6 @@ func TestFlag_Evaluation_ResolveFloat(t *testing.T) {
eval,
&eventingConfiguration{},
metrics,
nil,
)
got, err := s.ResolveFloat(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req))
if (err != nil) && !errors.Is(err, tt.wantErr) {
@ -602,7 +593,6 @@ func BenchmarkFlag_Evaluation_ResolveFloat(b *testing.B) {
eval,
&eventingConfiguration{},
metrics,
nil,
)
b.Run(name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
@ -700,7 +690,6 @@ func TestFlag_Evaluation_ResolveInt(t *testing.T) {
eval,
&eventingConfiguration{},
metrics,
nil,
)
got, err := s.ResolveInt(tt.functionArgs.ctx, connect.NewRequest(tt.functionArgs.req))
if (err != nil) && !errors.Is(err, tt.wantErr) {
@ -756,7 +745,6 @@ func BenchmarkFlag_Evaluation_ResolveInt(b *testing.B) {
eval,
&eventingConfiguration{},
metrics,
nil,
)
b.Run(name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
@ -857,7 +845,6 @@ func TestFlag_Evaluation_ResolveObject(t *testing.T) {
eval,
&eventingConfiguration{},
metrics,
nil,
)
outParsed, err := structpb.NewStruct(tt.evalFields.result)
@ -921,7 +908,6 @@ func BenchmarkFlag_Evaluation_ResolveObject(b *testing.B) {
eval,
&eventingConfiguration{},
metrics,
nil,
)
if name != "eval returns error" {
outParsed, err := structpb.NewStruct(tt.evalFields.result)
@ -1000,49 +986,3 @@ func TestFlag_Evaluation_ErrorCodes(t *testing.T) {
}
}
}
func Test_Readable_ErrorMessage(t *testing.T) {
tests := []struct {
name string
code string
want string
}{
{
name: "Testing flag not found error",
code: model.FlagNotFoundErrorCode,
want: model.ReadableErrorMessage[model.FlagNotFoundErrorCode],
},
{
name: "Testing parse error",
code: model.ParseErrorCode,
want: model.ReadableErrorMessage[model.ParseErrorCode],
},
{
name: "Testing type mismatch error",
code: model.TypeMismatchErrorCode,
want: model.ReadableErrorMessage[model.TypeMismatchErrorCode],
},
{
name: "Testing general error",
code: model.GeneralErrorCode,
want: model.ReadableErrorMessage[model.GeneralErrorCode],
},
{
name: "Testing flag disabled error",
code: model.FlagDisabledErrorCode,
want: model.ReadableErrorMessage[model.FlagDisabledErrorCode],
},
{
name: "Testing invalid context error",
code: model.InvalidContextCode,
want: model.ReadableErrorMessage[model.InvalidContextCode],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := model.GetErrorMessage(tt.code); got != tt.want {
t.Errorf("GetErrorMessage() Wanted: %v , but got: %v as a ReadableErrorMessage", tt.want, got)
}
})
}
}

View File

@ -18,12 +18,10 @@ type constraints interface {
}
type booleanResponse struct {
//nolint:staticcheck
schemaV1Resp *connect.Response[schemaV1.ResolveBooleanResponse]
evalV1Resp *connect.Response[evalV1.ResolveBooleanResponse]
}
//nolint:staticcheck
func (r *booleanResponse) SetResult(value bool, variant, reason string, metadata map[string]interface{}) error {
newStruct, err := structpb.NewStruct(metadata)
if err != nil {
@ -47,12 +45,10 @@ func (r *booleanResponse) SetResult(value bool, variant, reason string, metadata
}
type stringResponse struct {
//nolint:staticcheck
schemaV1Resp *connect.Response[schemaV1.ResolveStringResponse]
evalV1Resp *connect.Response[evalV1.ResolveStringResponse]
}
//nolint:staticcheck
func (r *stringResponse) SetResult(value string, variant, reason string, metadata map[string]interface{}) error {
newStruct, err := structpb.NewStruct(metadata)
if err != nil {
@ -76,12 +72,10 @@ func (r *stringResponse) SetResult(value string, variant, reason string, metadat
}
type floatResponse struct {
//nolint:staticcheck
schemaV1Resp *connect.Response[schemaV1.ResolveFloatResponse]
evalV1Resp *connect.Response[evalV1.ResolveFloatResponse]
}
//nolint:staticcheck
func (r *floatResponse) SetResult(value float64, variant, reason string, metadata map[string]interface{}) error {
newStruct, err := structpb.NewStruct(metadata)
if err != nil {
@ -89,7 +83,6 @@ func (r *floatResponse) SetResult(value float64, variant, reason string, metadat
}
if r.schemaV1Resp != nil {
// nolint:staticcheck
r.schemaV1Resp.Msg.Value = value
r.schemaV1Resp.Msg.Variant = variant
r.schemaV1Resp.Msg.Reason = reason
@ -106,12 +99,10 @@ func (r *floatResponse) SetResult(value float64, variant, reason string, metadat
}
type intResponse struct {
//nolint:staticcheck
schemaV1Resp *connect.Response[schemaV1.ResolveIntResponse]
evalV1Resp *connect.Response[evalV1.ResolveIntResponse]
}
//nolint:staticcheck
func (r *intResponse) SetResult(value int64, variant, reason string, metadata map[string]interface{}) error {
newStruct, err := structpb.NewStruct(metadata)
if err != nil {
@ -134,12 +125,10 @@ func (r *intResponse) SetResult(value int64, variant, reason string, metadata ma
}
type objectResponse struct {
// nolint:staticcheck
schemaV1Resp *connect.Response[schemaV1.ResolveObjectResponse]
evalV1Resp *connect.Response[evalV1.ResolveObjectResponse]
}
//nolint:staticcheck
func (r *objectResponse) SetResult(value map[string]any, variant, reason string,
metadata map[string]interface{},
) error {

View File

@ -0,0 +1,274 @@
package service
import (
"context"
"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/telemetry"
"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.MetricsRecorder
eventingConfiguration *eventingConfiguration
flagEvalTracer trace.Tracer
}
// NewFlagEvaluationService creates a FlagEvaluationService with provided parameters
func NewFlagEvaluationService(log *logger.Logger,
eval evaluator.IEvaluator,
eventingCfg *eventingConfiguration,
metricsRecorder *telemetry.MetricsRecorder,
) *FlagEvaluationService {
return &FlagEvaluationService{
logger: log,
eval: eval,
metrics: metricsRecorder,
eventingConfiguration: eventingCfg,
flagEvalTracer: otel.Tracer("flagd.evaluation.v1"),
}
}
// 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)
sCtx, span := s.flagEvalTracer.Start(ctx, "resolveAll", trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
res := &evalV1.ResolveAllResponse{
Flags: make(map[string]*evalV1.AnyFlag),
}
evalCtx := map[string]any{}
if e := req.Msg.GetContext(); e != nil {
evalCtx = e.AsMap()
}
values := s.eval.ResolveAllValues(sCtx, reqID, evalCtx)
span.SetAttributes(attribute.Int("feature_flag.count", len(values)))
for _, value := range values {
// register the impression and reason for each flag evaluated
s.metrics.RecordEvaluation(sCtx, value.Error, value.Reason, value.Variant, value.FlagKey)
switch v := value.Value.(type) {
case bool:
res.Flags[value.FlagKey] = &evalV1.AnyFlag{
Reason: value.Reason,
Variant: value.Variant,
Value: &evalV1.AnyFlag_BoolValue{
BoolValue: v,
},
}
case string:
res.Flags[value.FlagKey] = &evalV1.AnyFlag{
Reason: value.Reason,
Variant: value.Variant,
Value: &evalV1.AnyFlag_StringValue{
StringValue: v,
},
}
case float64:
res.Flags[value.FlagKey] = &evalV1.AnyFlag{
Reason: value.Reason,
Variant: value.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[value.FlagKey] = &evalV1.AnyFlag{
Reason: value.Reason,
Variant: value.Variant,
Value: &evalV1.AnyFlag_ObjectValue{
ObjectValue: val,
},
}
}
}
return connect.NewResponse(res), nil
}
func (s *FlagEvaluationService) EventStream(
ctx context.Context,
req *connect.Request[evalV1.EventStreamRequest],
stream *connect.ServerStream[evalV1.EventStreamResponse],
) error {
requestNotificationChan := make(chan service.Notification, 1)
s.eventingConfiguration.subscribe(req, 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():
return nil
}
}
}
func (s *FlagEvaluationService) ResolveBoolean(
ctx context.Context,
req *connect.Request[evalV1.ResolveBooleanRequest],
) (*connect.Response[evalV1.ResolveBooleanResponse], error) {
sCtx, span := s.flagEvalTracer.Start(ctx, "resolveBoolean", trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
res := connect.NewResponse(&evalV1.ResolveBooleanResponse{})
err := resolve[bool](
sCtx,
s.logger,
s.eval.ResolveBooleanValue,
req.Msg.GetFlagKey(),
req.Msg.GetContext(),
&booleanResponse{evalV1Resp: res},
s.metrics,
)
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) {
sCtx, span := s.flagEvalTracer.Start(ctx, "resolveString", trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
res := connect.NewResponse(&evalV1.ResolveStringResponse{})
err := resolve[string](
sCtx,
s.logger,
s.eval.ResolveStringValue,
req.Msg.GetFlagKey(),
req.Msg.GetContext(),
&stringResponse{evalV1Resp: res},
s.metrics,
)
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) {
sCtx, span := s.flagEvalTracer.Start(ctx, "resolveInt", trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
res := connect.NewResponse(&evalV1.ResolveIntResponse{})
err := resolve[int64](
sCtx,
s.logger,
s.eval.ResolveIntValue,
req.Msg.GetFlagKey(),
req.Msg.GetContext(),
&intResponse{evalV1Resp: res},
s.metrics,
)
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) {
sCtx, span := s.flagEvalTracer.Start(ctx, "resolveFloat", trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
res := connect.NewResponse(&evalV1.ResolveFloatResponse{})
err := resolve[float64](
sCtx,
s.logger,
s.eval.ResolveFloatValue,
req.Msg.GetFlagKey(),
req.Msg.GetContext(),
&floatResponse{evalV1Resp: res},
s.metrics,
)
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) {
sCtx, span := s.flagEvalTracer.Start(ctx, "resolveObject", trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
res := connect.NewResponse(&evalV1.ResolveObjectResponse{})
err := resolve[map[string]any](
sCtx,
s.logger,
s.eval.ResolveObjectValue,
req.Msg.GetFlagKey(),
req.Msg.GetContext(),
&objectResponse{evalV1Resp: res},
s.metrics,
)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, fmt.Sprintf("error evaluating flag with key %s", req.Msg.GetFlagKey()))
}
return res, err
}

View File

@ -3,30 +3,26 @@ 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/golang/mock/gomock"
"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
req *evalV1.ResolveAllRequest
evalRes []evaluator.AnyValue
wantErr error
wantRes *evalV1.ResolveAllResponse
}{
"happy-path": {
req: &evalV1.ResolveAllRequest{},
@ -56,15 +52,8 @@ func TestConnectServiceV2_ResolveAll(t *testing.T) {
FlagKey: "object",
},
},
metadataRes: model.Metadata{
"key": "value",
},
wantErr: nil,
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{
@ -87,43 +76,31 @@ func TestConnectServiceV2_ResolveAll(t *testing.T) {
},
},
},
"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,
tt.evalRes,
).AnyTimes()
metrics, exp := getMetricReader()
s := NewFlagEvaluationService(logger.NewLogger(nil, false), eval, &eventingConfiguration{}, metrics, nil, nil, 0)
// when
s := NewFlagEvaluationService(
logger.NewLogger(nil, false),
eval,
&eventingConfiguration{},
metrics,
)
got, err := s.ResolveAll(context.Background(), connect.NewRequest(tt.req))
// then
if tt.wantErr {
if err == nil {
t.Error("expected error but git none")
}
if err != nil && !errors.Is(err, tt.wantErr) {
t.Errorf("ConnectService.ResolveAll() 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), 1)
require.EqualValues(t, tt.wantRes.Metadata, got.Msg.Metadata)
for _, flag := range tt.evalRes {
switch v := flag.Value.(type) {
case bool:
@ -220,9 +197,6 @@ func TestFlag_EvaluationV2_ResolveBoolean(t *testing.T) {
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) {
@ -278,9 +252,6 @@ func BenchmarkFlag_EvaluationV2_ResolveBoolean(b *testing.B) {
eval,
&eventingConfiguration{},
metrics,
nil,
nil,
0,
)
b.Run(name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
@ -379,9 +350,6 @@ func TestFlag_EvaluationV2_ResolveString(t *testing.T) {
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) {
@ -437,9 +405,6 @@ func BenchmarkFlag_EvaluationV2_ResolveString(b *testing.B) {
eval,
&eventingConfiguration{},
metrics,
nil,
nil,
0,
)
b.Run(name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
@ -537,9 +502,6 @@ func TestFlag_EvaluationV2_ResolveFloat(t *testing.T) {
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) {
@ -595,9 +557,6 @@ func BenchmarkFlag_EvaluationV2_ResolveFloat(b *testing.B) {
eval,
&eventingConfiguration{},
metrics,
nil,
nil,
0,
)
b.Run(name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
@ -695,9 +654,6 @@ func TestFlag_EvaluationV2_ResolveInt(t *testing.T) {
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) {
@ -753,9 +709,6 @@ func BenchmarkFlag_EvaluationV2_ResolveInt(b *testing.B) {
eval,
&eventingConfiguration{},
metrics,
nil,
nil,
0,
)
b.Run(name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
@ -856,9 +809,6 @@ func TestFlag_EvaluationV2_ResolveObject(t *testing.T) {
eval,
&eventingConfiguration{},
metrics,
nil,
nil,
0,
)
outParsed, err := structpb.NewStruct(tt.evalFields.result)
@ -922,9 +872,6 @@ func BenchmarkFlag_EvaluationV2_ResolveObject(b *testing.B) {
eval,
&eventingConfiguration{},
metrics,
nil,
nil,
0,
)
if name != "eval returns error" {
outParsed, err := structpb.NewStruct(tt.evalFields.result)
@ -997,73 +944,3 @@ func TestFlag_EvaluationV2_ErrorCodes(t *testing.T) {
}
}
}
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)
}
})
}
}

View File

@ -2,7 +2,6 @@ package service
import (
"context"
"time"
"connectrpc.com/connect"
)
@ -24,18 +23,15 @@ type Notification struct {
type ReadinessProbe func() bool
type Configuration struct {
ReadinessProbe ReadinessProbe
Port uint16
ManagementPort uint16
ServiceName string
CertPath string
KeyPath string
SocketPath string
CORS []string
Options []connect.HandlerOption
ContextValues map[string]any
HeaderToContextKeyMappings map[string]string
StreamDeadline time.Duration
ReadinessProbe ReadinessProbe
Port uint16
ManagementPort uint16
ServiceName string
CertPath string
KeyPath string
SocketPath string
CORS []string
Options []connect.HandlerOption
}
/*

View File

@ -5,9 +5,9 @@ import (
"net/http/httptest"
"testing"
"github.com/open-feature/flagd/flagd/pkg/service/middleware/mock"
"github.com/golang/mock/gomock"
middlewaremock "github.com/open-feature/flagd/core/pkg/service/middleware/mock"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
func TestMiddleware(t *testing.T) {

View File

@ -5,9 +5,9 @@ import (
"net/http/httptest"
"testing"
"github.com/open-feature/flagd/flagd/pkg/service/middleware/mock"
"github.com/golang/mock/gomock"
middlewaremock "github.com/open-feature/flagd/core/pkg/service/middleware/mock"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
func TestMiddleware(t *testing.T) {

View File

@ -16,7 +16,7 @@ import (
)
type Config struct {
MetricRecorder telemetry.IMetricsRecorder
MetricRecorder *telemetry.MetricsRecorder
Logger *logger.Logger
Service string
GroupedStatus bool
@ -41,7 +41,7 @@ func (cfg *Config) defaults() {
log.Fatal("missing logger")
}
if cfg.MetricRecorder == nil {
cfg.MetricRecorder = &telemetry.NoopMetricsRecorder{}
cfg.Logger.Fatal("missing OpenTelemetry metric recorder")
}
}
@ -68,7 +68,6 @@ func (m Middleware) Measure(ctx context.Context, handlerID string, reporter Repo
hid,
reporter.Method(),
code,
reporter.Scheme(),
)
m.cfg.MetricRecorder.InFlightRequestStart(ctx, httpAttrs)
@ -113,7 +112,6 @@ type Reporter interface {
URLPath() string
StatusCode() int
BytesWritten() int64
Scheme() string
}
type stdReporter struct {
@ -129,13 +127,6 @@ func (s *stdReporter) StatusCode() int { return s.w.statusCode }
func (s *stdReporter) BytesWritten() int64 { return int64(s.w.bytesWritten) }
func (s *stdReporter) Scheme() string {
if s.r.TLS != nil {
return "https"
}
return "http"
}
// responseWriterInterceptor is a simple wrapper to intercept set data on a
// ResponseWriter.
type responseWriterInterceptor struct {

View File

@ -190,10 +190,6 @@ func (m *MockReporter) BytesWritten() int64 {
return m.Bytes
}
func (m *MockReporter) Scheme() string {
return "http"
}
func (m *MockReporter) URLCalled() bool { return m.urlCalled }
func (m *MockReporter) MethodCalled() bool { return m.methodCalled }
func (m *MockReporter) StatusCalled() bool { return m.statusCalled }

View File

@ -1,10 +1,5 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: pkg/service/middleware/interface.go
//
// Generated by this command:
//
// mockgen -source=pkg/service/middleware/interface.go -destination=pkg/service/middleware/mock/interface.go -package=middlewaremock
//
// Package middlewaremock is a generated GoMock package.
package middlewaremock
@ -13,7 +8,7 @@ import (
http "net/http"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
gomock "github.com/golang/mock/gomock"
)
// MockIMiddleware is a mock of IMiddleware interface.
@ -48,7 +43,7 @@ func (m *MockIMiddleware) Handler(handler http.Handler) http.Handler {
}
// Handler indicates an expected call of Handler.
func (mr *MockIMiddlewareMockRecorder) Handler(handler any) *gomock.Call {
func (mr *MockIMiddlewareMockRecorder) Handler(handler interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handler", reflect.TypeOf((*MockIMiddleware)(nil).Handler), handler)
}

View File

@ -1,122 +0,0 @@
package ofrep
import (
"fmt"
"github.com/open-feature/flagd/core/pkg/evaluator"
"github.com/open-feature/flagd/core/pkg/model"
)
type Request struct {
Context interface{} `json:"context"`
}
type EvaluationSuccess struct {
Value interface{} `json:"value"`
Key string `json:"key"`
Reason string `json:"reason"`
Variant string `json:"variant"`
Metadata model.Metadata `json:"metadata"`
}
type BulkEvaluationResponse struct {
Flags []interface{} `json:"flags"`
Metadata model.Metadata `json:"metadata"`
}
type EvaluationError struct {
Key string `json:"key"`
ErrorCode string `json:"errorCode"`
ErrorDetails string `json:"errorDetails"`
Metadata model.Metadata `json:"metadata"`
}
type BulkEvaluationError struct {
ErrorCode string `json:"errorCode"`
ErrorDetails string `json:"errorDetails"`
Metadata model.Metadata `json:"metadata"`
}
type InternalError struct {
ErrorDetails string `json:"errorDetails"`
}
func BulkEvaluationResponseFrom(resolutions []evaluator.AnyValue, metadata model.Metadata) BulkEvaluationResponse {
evaluations := make([]interface{}, 0)
for _, value := range resolutions {
if value.Error != nil {
_, evaluationError := EvaluationErrorResponseFrom(value)
evaluations = append(evaluations, evaluationError)
} else {
evaluations = append(evaluations, SuccessResponseFrom(value))
}
}
return BulkEvaluationResponse{
evaluations,
metadata,
}
}
func SuccessResponseFrom(result evaluator.AnyValue) EvaluationSuccess {
return EvaluationSuccess{
Value: result.Value,
Key: result.FlagKey,
Reason: result.Reason,
Variant: result.Variant,
Metadata: result.Metadata,
}
}
func ContextErrorResponseFrom(key string) EvaluationError {
return EvaluationError{
Key: key,
ErrorCode: model.InvalidContextCode,
ErrorDetails: "Provider context is not valid",
}
}
func BulkEvaluationContextError() BulkEvaluationError {
return BulkEvaluationError{
ErrorCode: model.InvalidContextCode,
ErrorDetails: "Provider context is not valid",
}
}
func BulkEvaluationContextErrorFrom(code string, details string) BulkEvaluationError {
return BulkEvaluationError{
ErrorCode: code,
ErrorDetails: details,
}
}
func EvaluationErrorResponseFrom(result evaluator.AnyValue) (int, EvaluationError) {
payload := EvaluationError{
Key: result.FlagKey,
Metadata: result.Metadata,
}
status := 400
switch result.Error.Error() {
case model.FlagNotFoundErrorCode:
status = 404
payload.ErrorCode = model.FlagNotFoundErrorCode
payload.ErrorDetails = fmt.Sprintf("flag `%s` does not exist", result.FlagKey)
case model.FlagDisabledErrorCode:
status = 404
payload.ErrorCode = model.FlagNotFoundErrorCode
payload.ErrorDetails = fmt.Sprintf("flag `%s` is disabled", result.FlagKey)
case model.ParseErrorCode:
payload.ErrorCode = model.ParseErrorCode
payload.ErrorDetails = fmt.Sprintf("error parsing the flag `%s`", result.FlagKey)
case model.GeneralErrorCode:
fallthrough
default:
payload.ErrorCode = model.GeneralErrorCode
payload.ErrorDetails = "error processing the flag for evaluation"
}
return status, payload
}

View File

@ -1,153 +0,0 @@
package ofrep
import (
"encoding/json"
"errors"
"reflect"
"testing"
"github.com/open-feature/flagd/core/pkg/evaluator"
"github.com/open-feature/flagd/core/pkg/model"
)
func TestSuccessResult(t *testing.T) {
// given
value := evaluator.AnyValue{
Value: false,
Variant: "false",
Reason: model.StaticReason,
FlagKey: "key",
Metadata: map[string]interface{}{
"key": "value",
},
}
// when
evaluationSuccess := SuccessResponseFrom(value)
if evaluationSuccess.Key != value.FlagKey {
t.Errorf("expected %v, got %v", value.FlagKey, evaluationSuccess.Key)
}
if evaluationSuccess.Value != value.Value {
t.Errorf("expected %v, got %v", value.Value, evaluationSuccess.Value)
}
if evaluationSuccess.Variant != value.Variant {
t.Errorf("expected %v, got %v", value.Variant, evaluationSuccess.Variant)
}
if evaluationSuccess.Reason != value.Reason {
t.Errorf("expected %v, got %v", value.Reason, evaluationSuccess.Reason)
}
if !reflect.DeepEqual(evaluationSuccess.Metadata, value.Metadata) {
t.Errorf("metadata mismatch")
}
}
func TestBulkEvaluationResponse(t *testing.T) {
tests := []struct {
name string
input []evaluator.AnyValue
marshalledOutput string
}{
{
name: "empty input",
input: nil,
marshalledOutput: "{\"flags\":[],\"metadata\":{}}",
},
{
name: "valid values",
input: []evaluator.AnyValue{
{
Value: false,
Variant: "false",
Reason: model.StaticReason,
FlagKey: "key",
Metadata: map[string]interface{}{
"key": "value",
},
},
{
Value: false,
Variant: "false",
Reason: model.ErrorReason,
FlagKey: "errorFlag",
Error: errors.New(model.FlagNotFoundErrorCode),
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\":{}}",
},
}
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)
}
if test.marshalledOutput != string(marshal) {
t.Errorf("expected %s, got %s", test.marshalledOutput, string(marshal))
}
})
}
}
func TestErrorStatus(t *testing.T) {
tests := []struct {
name string
modelError string
expectedStatus int
expectedCode string
}{
{
name: "parsing error",
modelError: model.ParseErrorCode,
expectedStatus: 400,
expectedCode: model.ParseErrorCode,
},
{
name: "flag disabled",
modelError: model.FlagDisabledErrorCode,
expectedStatus: 404,
expectedCode: model.FlagNotFoundErrorCode,
},
{
name: "general error",
modelError: model.GeneralErrorCode,
expectedStatus: 400,
expectedCode: model.GeneralErrorCode,
},
{
name: "flag not found",
modelError: model.FlagNotFoundErrorCode,
expectedStatus: 404,
expectedCode: model.FlagNotFoundErrorCode,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
status, evaluationError := EvaluationErrorResponseFrom(evaluator.AnyValue{
Value: "value",
Variant: "variant",
Reason: model.ErrorReason,
FlagKey: "key",
Error: errors.New(test.modelError),
})
if status != test.expectedStatus {
t.Errorf("expected status %d, but got %d", test.expectedStatus, status)
}
if evaluationError.ErrorCode != test.expectedCode {
t.Errorf("expected error code %s, but got %s", test.expectedCode, evaluationError.ErrorCode)
}
})
}
}

View File

@ -1,4 +1,4 @@
package service
package sync
import (
"context"
@ -9,8 +9,8 @@ import (
syncv12 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/sync/v1"
syncv1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/sync/v1"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/subscriptions"
"github.com/open-feature/flagd/core/pkg/sync"
"github.com/open-feature/flagd/flagd-proxy/pkg/service/subscriptions"
)
type handler struct {
@ -72,7 +72,6 @@ type oldHandler struct {
ctx context.Context
}
//nolint:staticcheck
func (l *oldHandler) FetchAllFlags(ctx context.Context, req *syncv1.FetchAllFlagsRequest) (
*syncv1.FetchAllFlagsResponse,
error,
@ -87,7 +86,6 @@ func (l *oldHandler) FetchAllFlags(ctx context.Context, req *syncv1.FetchAllFlag
}, nil
}
//nolint:staticcheck
func (l *oldHandler) SyncFlags(
req *syncv1.SyncFlagsRequest,
stream rpc.FlagSyncService_SyncFlagsServer,
@ -104,6 +102,7 @@ func (l *oldHandler) SyncFlags(
case d := <-dataSync:
if err := stream.Send(&syncv1.SyncFlagsResponse{
FlagConfiguration: d.FlagData,
State: dataSyncToGrpcState(d),
}); err != nil {
return fmt.Errorf("error sending configuration change event: %w", err)
}
@ -114,3 +113,7 @@ func (l *oldHandler) SyncFlags(
}
}
}
func dataSyncToGrpcState(s sync.DataSync) syncv1.SyncState {
return syncv1.SyncState(s.Type + 1)
}

View File

@ -1,4 +1,4 @@
package service
package sync
import (
"context"
@ -12,8 +12,8 @@ import (
syncv1 "buf.build/gen/go/open-feature/flagd/grpc/go/flagd/sync/v1/syncv1grpc"
rpc "buf.build/gen/go/open-feature/flagd/grpc/go/sync/v1/syncv1grpc"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/service"
"github.com/open-feature/flagd/flagd-proxy/pkg/service/subscriptions"
iservice "github.com/open-feature/flagd/core/pkg/service"
"github.com/open-feature/flagd/core/pkg/subscriptions"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
@ -30,7 +30,7 @@ type Server struct {
// oldHandler will not be required anymore when https://github.com/open-feature/flagd/issues/1088 is being worked on
oldHandler *oldHandler
handler *handler
config service.Configuration
config iservice.Configuration
grpcServer *grpc.Server
metricServerReady bool
}
@ -53,7 +53,7 @@ func NewServer(ctx context.Context, logger *logger.Logger, store subscriptions.M
}
}
func (s *Server) Serve(ctx context.Context, svcConf service.Configuration) error {
func (s *Server) Serve(ctx context.Context, svcConf iservice.Configuration) error {
s.config = svcConf
s.metricServerReady = true

View File

@ -1,4 +1,4 @@
package service
package sync
import (
"context"

283
core/pkg/store/flags.go Normal file
View File

@ -0,0 +1,283 @@
package store
import (
"encoding/json"
"fmt"
"reflect"
"sync"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/model"
)
type Flags struct {
mx sync.RWMutex
Flags map[string]model.Flag `json:"flags"`
FlagSources []string
SourceMetadata map[string]SourceDetails
}
type SourceDetails struct {
Source string
Selector string
}
func (f *Flags) hasPriority(stored string, new string) bool {
if stored == new {
return true
}
for i := len(f.FlagSources) - 1; i >= 0; i-- {
switch f.FlagSources[i] {
case stored:
return false
case new:
return true
}
}
return true
}
func NewFlags() *Flags {
return &Flags{
Flags: map[string]model.Flag{},
SourceMetadata: map[string]SourceDetails{},
}
}
func (f *Flags) Set(key string, flag model.Flag) {
f.mx.Lock()
defer f.mx.Unlock()
f.Flags[key] = flag
}
func (f *Flags) Get(key string) (model.Flag, bool) {
f.mx.RLock()
defer f.mx.RUnlock()
flag, ok := f.Flags[key]
return flag, ok
}
func (f *Flags) SelectorForFlag(flag model.Flag) string {
f.mx.RLock()
defer f.mx.RUnlock()
return f.SourceMetadata[flag.Source].Selector
}
func (f *Flags) Delete(key string) {
f.mx.Lock()
defer f.mx.Unlock()
delete(f.Flags, key)
}
func (f *Flags) String() (string, error) {
f.mx.RLock()
defer f.mx.RUnlock()
bytes, err := json.Marshal(f)
if err != nil {
return "", fmt.Errorf("unable to marshal flags: %w", err)
}
return string(bytes), nil
}
// GetAll returns a copy of the store's state (copy in order to be concurrency safe)
func (f *Flags) GetAll() map[string]model.Flag {
f.mx.RLock()
defer f.mx.RUnlock()
state := make(map[string]model.Flag, len(f.Flags))
for key, flag := range f.Flags {
state[key] = flag
}
return state
}
// Add new flags from source.
func (f *Flags) Add(logger *logger.Logger, source string, flags map[string]model.Flag) map[string]interface{} {
notifications := map[string]interface{}{}
for k, newFlag := range flags {
storedFlag, ok := f.Get(k)
if ok && !f.hasPriority(storedFlag.Source, source) {
logger.Debug(
fmt.Sprintf(
"not overwriting: flag %s from source %s does not have priority over %s",
k,
source,
storedFlag.Source,
),
)
continue
}
notifications[k] = map[string]interface{}{
"type": string(model.NotificationCreate),
"source": source,
}
// Store the new version of the flag
newFlag.Source = source
f.Set(k, newFlag)
}
return notifications
}
// Update existing flags from source.
func (f *Flags) Update(logger *logger.Logger, source string, flags map[string]model.Flag) map[string]interface{} {
notifications := map[string]interface{}{}
for k, flag := range flags {
storedFlag, ok := f.Get(k)
if !ok {
logger.Warn(
fmt.Sprintf("failed to update the flag, flag with key %s from source %s does not exist.",
k,
source))
continue
}
if !f.hasPriority(storedFlag.Source, source) {
logger.Debug(
fmt.Sprintf(
"not updating: flag %s from source %s does not have priority over %s",
k,
source,
storedFlag.Source,
),
)
continue
}
notifications[k] = map[string]interface{}{
"type": string(model.NotificationUpdate),
"source": source,
}
flag.Source = source
f.Set(k, flag)
}
return notifications
}
// DeleteFlags matching flags from source.
func (f *Flags) DeleteFlags(logger *logger.Logger, source string, flags map[string]model.Flag) map[string]interface{} {
logger.Debug(
fmt.Sprintf(
"store resync triggered: delete event from source %s",
source,
),
)
notifications := map[string]interface{}{}
if len(flags) == 0 {
allFlags := f.GetAll()
for key, flag := range allFlags {
if flag.Source != source {
continue
}
notifications[key] = map[string]interface{}{
"type": string(model.NotificationDelete),
"source": source,
}
f.Delete(key)
}
}
for k := range flags {
flag, ok := f.Get(k)
if ok {
if !f.hasPriority(flag.Source, source) {
logger.Debug(
fmt.Sprintf(
"not deleting: flag %s from source %s cannot be deleted by %s",
k,
flag.Source,
source,
),
)
continue
}
notifications[k] = map[string]interface{}{
"type": string(model.NotificationDelete),
"source": source,
}
f.Delete(k)
} else {
logger.Warn(
fmt.Sprintf("failed to remove flag, flag with key %s from source %s does not exist.",
k,
source))
}
}
return notifications
}
// Merge provided flags from source with currently stored flags.
func (f *Flags) Merge(
logger *logger.Logger,
source string,
flags map[string]model.Flag,
) (map[string]interface{}, bool) {
notifications := map[string]interface{}{}
resyncRequired := false
f.mx.Lock()
for k, v := range f.Flags {
if v.Source == source {
if _, ok := flags[k]; !ok {
// flag has been deleted
delete(f.Flags, k)
notifications[k] = map[string]interface{}{
"type": string(model.NotificationDelete),
"source": source,
}
resyncRequired = true
logger.Debug(
fmt.Sprintf(
"store resync triggered: flag %s has been deleted from source %s",
k, source,
),
)
continue
}
}
}
f.mx.Unlock()
for k, newFlag := range flags {
newFlag.Source = source
storedFlag, ok := f.Get(k)
if ok {
if !f.hasPriority(storedFlag.Source, source) {
logger.Debug(
fmt.Sprintf(
"not merging: flag %s from source %s does not have priority over %s",
k, source, storedFlag.Source,
),
)
continue
}
if reflect.DeepEqual(storedFlag, newFlag) {
continue
}
}
if !ok {
notifications[k] = map[string]interface{}{
"type": string(model.NotificationCreate),
"source": source,
}
} else {
notifications[k] = map[string]interface{}{
"type": string(model.NotificationUpdate),
"source": source,
}
}
// Store the new version of the flag
f.Set(k, newFlag)
}
return notifications, resyncRequired
}

View File

@ -0,0 +1,551 @@
package store
import (
"testing"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/model"
"github.com/stretchr/testify/require"
)
func TestHasPriority(t *testing.T) {
tests := []struct {
name string
currentState *Flags
storedSource string
newSource string
hasPriority bool
}{
{
name: "same source",
currentState: &Flags{},
storedSource: "A",
newSource: "A",
hasPriority: true,
},
{
name: "no priority",
currentState: &Flags{
FlagSources: []string{
"B",
"A",
},
},
storedSource: "A",
newSource: "B",
hasPriority: false,
},
{
name: "priority",
currentState: &Flags{
FlagSources: []string{
"A",
"B",
},
},
storedSource: "A",
newSource: "B",
hasPriority: true,
},
{
name: "not in sources",
currentState: &Flags{
FlagSources: []string{
"A",
"B",
},
},
storedSource: "C",
newSource: "D",
hasPriority: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p := tt.currentState.hasPriority(tt.storedSource, tt.newSource)
require.Equal(t, p, tt.hasPriority)
})
}
}
func TestMergeFlags(t *testing.T) {
t.Parallel()
tests := []struct {
name string
current *Flags
new map[string]model.Flag
newSource string
want *Flags
wantNotifs map[string]interface{}
wantResync bool
}{
{
name: "both nil",
current: &Flags{
Flags: nil,
},
new: nil,
want: &Flags{Flags: map[string]model.Flag{}},
wantNotifs: map[string]interface{}{},
},
{
name: "both empty flags",
current: &Flags{
Flags: map[string]model.Flag{},
},
new: map[string]model.Flag{},
want: &Flags{Flags: map[string]model.Flag{}},
wantNotifs: map[string]interface{}{},
},
{
name: "empty current",
current: &Flags{
Flags: nil,
},
new: map[string]model.Flag{},
want: &Flags{Flags: map[string]model.Flag{}},
wantNotifs: map[string]interface{}{},
},
{
name: "empty new",
current: &Flags{
Flags: map[string]model.Flag{},
},
new: nil,
want: &Flags{Flags: map[string]model.Flag{}},
wantNotifs: map[string]interface{}{},
},
{
name: "extra fields on each",
current: &Flags{
Flags: map[string]model.Flag{
"waka": {
DefaultVariant: "off",
Source: "1",
},
},
},
new: map[string]model.Flag{
"paka": {
DefaultVariant: "on",
},
},
newSource: "2",
want: &Flags{Flags: map[string]model.Flag{
"waka": {
DefaultVariant: "off",
Source: "1",
},
"paka": {
DefaultVariant: "on",
Source: "2",
},
}},
wantNotifs: map[string]interface{}{
"paka": map[string]interface{}{"type": "write", "source": "2"},
},
},
{
name: "override",
current: &Flags{
Flags: map[string]model.Flag{"waka": {DefaultVariant: "off"}},
},
new: map[string]model.Flag{
"waka": {DefaultVariant: "on"},
"paka": {DefaultVariant: "on"},
},
want: &Flags{Flags: map[string]model.Flag{
"waka": {DefaultVariant: "on"},
"paka": {DefaultVariant: "on"},
}},
wantNotifs: map[string]interface{}{
"waka": map[string]interface{}{"type": "update", "source": ""},
"paka": map[string]interface{}{"type": "write", "source": ""},
},
},
{
name: "identical",
current: &Flags{
Flags: map[string]model.Flag{"hello": {DefaultVariant: "off"}},
},
new: map[string]model.Flag{
"hello": {DefaultVariant: "off"},
},
want: &Flags{Flags: map[string]model.Flag{
"hello": {DefaultVariant: "off"},
}},
wantNotifs: map[string]interface{}{},
},
{
name: "deleted flag",
current: &Flags{
Flags: map[string]model.Flag{"hello": {DefaultVariant: "off", Source: "A"}},
},
new: map[string]model.Flag{},
newSource: "A",
want: &Flags{Flags: map[string]model.Flag{}},
wantNotifs: map[string]interface{}{
"hello": map[string]interface{}{"type": "delete", "source": "A"},
},
wantResync: true,
},
{
name: "no merge priority",
current: &Flags{
FlagSources: []string{
"B",
"A",
},
Flags: map[string]model.Flag{
"hello": {
DefaultVariant: "off",
Source: "A",
},
},
},
new: map[string]model.Flag{
"hello": {DefaultVariant: "off"},
},
newSource: "B",
want: &Flags{
FlagSources: []string{
"B",
"A",
},
Flags: map[string]model.Flag{
"hello": {
DefaultVariant: "off",
Source: "A",
},
},
},
wantNotifs: map[string]interface{}{},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotNotifs, resyncRequired := tt.current.Merge(logger.NewLogger(nil, false), tt.newSource, tt.new)
require.Equal(t, tt.want, tt.want)
require.Equal(t, tt.wantNotifs, gotNotifs)
require.Equal(t, tt.wantResync, resyncRequired)
})
}
}
func TestFlags_Add(t *testing.T) {
mockLogger := logger.NewLogger(nil, false)
mockSource := "source"
mockOverrideSource := "source-2"
type request struct {
source string
flags map[string]model.Flag
}
tests := []struct {
name string
storedState *Flags
addRequest request
expectedState *Flags
expectedNotificationKeys []string
}{
{
name: "Add success",
storedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource},
},
},
addRequest: request{
source: mockSource,
flags: map[string]model.Flag{
"B": {Source: mockSource},
},
},
expectedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource},
"B": {Source: mockSource},
},
},
expectedNotificationKeys: []string{"B"},
},
{
name: "Add multiple success",
storedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource},
},
},
addRequest: request{
source: mockSource,
flags: map[string]model.Flag{
"B": {Source: mockSource},
"C": {Source: mockSource},
},
},
expectedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource},
"B": {Source: mockSource},
"C": {Source: mockSource},
},
},
expectedNotificationKeys: []string{"B", "C"},
},
{
name: "Add success - conflict and override",
storedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource},
},
},
addRequest: request{
source: mockOverrideSource,
flags: map[string]model.Flag{
"A": {Source: mockOverrideSource},
},
},
expectedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockOverrideSource},
},
},
expectedNotificationKeys: []string{"A"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
messages := tt.storedState.Add(mockLogger, tt.addRequest.source, tt.addRequest.flags)
require.Equal(t, tt.storedState, tt.expectedState)
for k := range messages {
require.Containsf(t, tt.expectedNotificationKeys, k,
"Message key %s not present in the expected key list", k)
}
})
}
}
func TestFlags_Update(t *testing.T) {
mockLogger := logger.NewLogger(nil, false)
mockSource := "source"
mockOverrideSource := "source-2"
type request struct {
source string
flags map[string]model.Flag
}
tests := []struct {
name string
storedState *Flags
UpdateRequest request
expectedState *Flags
expectedNotificationKeys []string
}{
{
name: "Update success",
storedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource, DefaultVariant: "True"},
},
},
UpdateRequest: request{
source: mockSource,
flags: map[string]model.Flag{
"A": {Source: mockSource, DefaultVariant: "False"},
},
},
expectedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource, DefaultVariant: "False"},
},
},
expectedNotificationKeys: []string{"A"},
},
{
name: "Update multiple success",
storedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource, DefaultVariant: "True"},
"B": {Source: mockSource, DefaultVariant: "True"},
},
},
UpdateRequest: request{
source: mockSource,
flags: map[string]model.Flag{
"A": {Source: mockSource, DefaultVariant: "False"},
"B": {Source: mockSource, DefaultVariant: "False"},
},
},
expectedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource, DefaultVariant: "False"},
"B": {Source: mockSource, DefaultVariant: "False"},
},
},
expectedNotificationKeys: []string{"A", "B"},
},
{
name: "Update success - conflict and override",
storedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource, DefaultVariant: "True"},
},
},
UpdateRequest: request{
source: mockOverrideSource,
flags: map[string]model.Flag{
"A": {Source: mockOverrideSource, DefaultVariant: "True"},
},
},
expectedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockOverrideSource, DefaultVariant: "True"},
},
},
expectedNotificationKeys: []string{"A"},
},
{
name: "Update fail",
storedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource},
},
},
UpdateRequest: request{
source: mockSource,
flags: map[string]model.Flag{
"B": {Source: mockSource},
},
},
expectedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource},
},
},
expectedNotificationKeys: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
messages := tt.storedState.Update(mockLogger, tt.UpdateRequest.source, tt.UpdateRequest.flags)
require.Equal(t, tt.storedState, tt.expectedState)
for k := range messages {
require.Containsf(t, tt.expectedNotificationKeys, k,
"Message key %s not present in the expected key list", k)
}
})
}
}
func TestFlags_Delete(t *testing.T) {
mockLogger := logger.NewLogger(nil, false)
mockSource := "source"
mockSource2 := "source2"
tests := []struct {
name string
storedState *Flags
deleteRequest map[string]model.Flag
expectedState *Flags
expectedNotificationKeys []string
}{
{
name: "Remove success",
storedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource},
"B": {Source: mockSource},
"C": {Source: mockSource2},
},
FlagSources: []string{
mockSource,
mockSource2,
},
},
deleteRequest: map[string]model.Flag{
"A": {Source: mockSource},
},
expectedState: &Flags{
Flags: map[string]model.Flag{
"B": {Source: mockSource},
"C": {Source: mockSource2},
},
FlagSources: []string{
mockSource,
mockSource2,
},
},
expectedNotificationKeys: []string{"A"},
},
{
name: "Nothing to remove",
storedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource},
"B": {Source: mockSource},
"C": {Source: mockSource2},
},
FlagSources: []string{
mockSource,
mockSource2,
},
},
deleteRequest: map[string]model.Flag{
"C": {Source: mockSource},
},
expectedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource},
"B": {Source: mockSource},
"C": {Source: mockSource2},
},
FlagSources: []string{
mockSource,
mockSource2,
},
},
expectedNotificationKeys: []string{},
},
{
name: "Remove all",
storedState: &Flags{
Flags: map[string]model.Flag{
"A": {Source: mockSource},
"B": {Source: mockSource},
"C": {Source: mockSource2},
},
},
deleteRequest: map[string]model.Flag{},
expectedState: &Flags{
Flags: map[string]model.Flag{
"C": {Source: mockSource2},
},
},
expectedNotificationKeys: []string{"A", "B"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
messages := tt.storedState.DeleteFlags(mockLogger, mockSource, tt.deleteRequest)
require.Equal(t, tt.storedState, tt.expectedState)
for k := range messages {
require.Containsf(t, tt.expectedNotificationKeys, k,
"Message key %s not present in the expected key list", k)
}
})
}
}

View File

@ -1,133 +0,0 @@
package store
import (
"maps"
"sort"
"strings"
uuid "github.com/google/uuid"
"github.com/open-feature/flagd/core/pkg/model"
)
// flags table and index constants
const flagsTable = "flags"
const idIndex = "id"
const keyIndex = "key"
const sourceIndex = "source"
const priorityIndex = "priority"
const flagSetIdIndex = "flagSetId"
// compound indices; maintain sub-indexes alphabetically; order matters; these must match what's generated in the SelectorMapToQuery func.
const flagSetIdSourceCompoundIndex = flagSetIdIndex + "+" + sourceIndex
const keySourceCompoundIndex = keyIndex + "+" + sourceIndex
const flagSetIdKeySourceCompoundIndex = flagSetIdIndex + "+" + keyIndex + "+" + sourceIndex
// flagSetId defaults to a UUID generated at startup to make our queries consistent
// any flag without a "flagSetId" is assigned this one; it's never exposed externally
var nilFlagSetId = uuid.New().String()
// A selector represents a set of constraints used to query the store.
type Selector struct {
indexMap map[string]string
}
// NewSelector creates a new Selector from a selector expression string.
// For example, to select flags from source "./mySource" and flagSetId "1234", use the expression:
// "source=./mySource,flagSetId=1234"
func NewSelector(selectorExpression string) Selector {
return Selector{
indexMap: expressionToMap(selectorExpression),
}
}
func expressionToMap(sExp string) map[string]string {
selectorMap := make(map[string]string)
if sExp == "" {
return selectorMap
}
if strings.Index(sExp, "=") == -1 {
// if no '=' is found, treat the whole string as as source (backwards compatibility)
// we may may support interpreting this as a flagSetId in the future as an option
selectorMap[sourceIndex] = sExp
return selectorMap
}
// Split the selector by commas
pairs := strings.Split(sExp, ",")
for _, pair := range pairs {
// Split each pair by the first equal sign
parts := strings.Split(pair, "=")
if len(parts) == 2 {
key := parts[0]
value := parts[1]
selectorMap[key] = value
}
}
return selectorMap
}
func (s Selector) WithIndex(key string, value string) Selector {
m := maps.Clone(s.indexMap)
m[key] = value
return Selector{
indexMap: m,
}
}
func (s *Selector) IsEmpty() bool {
return s == nil || len(s.indexMap) == 0
}
// SelectorMapToQuery converts the selector map to an indexId and constraints for querying the store.
// For a given index, a specific order and number of constraints are required.
// Both the indexId and constraints are generated based on the keys present in the selector's internal map.
func (s Selector) ToQuery() (indexId string, constraints []interface{}) {
if len(s.indexMap) == 2 && s.indexMap[flagSetIdIndex] != "" && s.indexMap[keyIndex] != "" {
// special case for flagSetId and key (this is the "id" index)
return idIndex, []interface{}{s.indexMap[flagSetIdIndex], s.indexMap[keyIndex]}
}
qs := []string{}
keys := make([]string, 0, len(s.indexMap))
for key := range s.indexMap {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
indexId += key + "+"
qs = append(qs, s.indexMap[key])
}
indexId = strings.TrimSuffix(indexId, "+")
// Convert []string to []interface{}
c := make([]interface{}, 0, len(qs))
for _, v := range qs {
c = append(c, v)
}
constraints = c
return indexId, constraints
}
// SelectorToMetadata converts the selector's internal map to metadata for logging or tracing purposes.
// Only includes known indices to avoid leaking sensitive information, and is usually returned as the "top level" metadata
func (s *Selector) ToMetadata() model.Metadata {
meta := model.Metadata{}
if s == nil || s.indexMap == nil {
return meta
}
if s.indexMap[flagSetIdIndex] != "" {
meta[flagSetIdIndex] = s.indexMap[flagSetIdIndex]
}
if s.indexMap[sourceIndex] != "" {
meta[sourceIndex] = s.indexMap[sourceIndex]
}
return meta
}

View File

@ -1,193 +0,0 @@
package store
import (
"reflect"
"testing"
"github.com/open-feature/flagd/core/pkg/model"
)
func TestSelector_IsEmpty(t *testing.T) {
tests := []struct {
name string
selector *Selector
wantEmpty bool
}{
{
name: "nil selector",
selector: nil,
wantEmpty: true,
},
{
name: "nil indexMap",
selector: &Selector{indexMap: nil},
wantEmpty: true,
},
{
name: "empty indexMap",
selector: &Selector{indexMap: map[string]string{}},
wantEmpty: true,
},
{
name: "non-empty indexMap",
selector: &Selector{indexMap: map[string]string{"source": "abc"}},
wantEmpty: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.selector.IsEmpty()
if got != tt.wantEmpty {
t.Errorf("IsEmpty() = %v, want %v", got, tt.wantEmpty)
}
})
}
}
func TestSelector_WithIndex(t *testing.T) {
oldS := Selector{indexMap: map[string]string{"source": "abc"}}
newS := oldS.WithIndex("flagSetId", "1234")
if newS.indexMap["source"] != "abc" {
t.Errorf("WithIndex did not preserve existing keys")
}
if newS.indexMap["flagSetId"] != "1234" {
t.Errorf("WithIndex did not add new key")
}
// Ensure original is unchanged
if _, ok := oldS.indexMap["flagSetId"]; ok {
t.Errorf("WithIndex mutated original selector")
}
}
func TestSelector_ToQuery(t *testing.T) {
tests := []struct {
name string
selector Selector
wantIndex string
wantConstr []interface{}
}{
{
name: "flagSetId and key primary index special case",
selector: Selector{indexMap: map[string]string{"flagSetId": "fsid", "key": "myKey"}},
wantIndex: "id",
wantConstr: []interface{}{"fsid", "myKey"},
},
{
name: "multiple keys sorted",
selector: Selector{indexMap: map[string]string{"source": "src", "flagSetId": "fsid"}},
wantIndex: "flagSetId+source",
wantConstr: []interface{}{"fsid", "src"},
},
{
name: "single key",
selector: Selector{indexMap: map[string]string{"source": "src"}},
wantIndex: "source",
wantConstr: []interface{}{"src"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotIndex, gotConstr := tt.selector.ToQuery()
if gotIndex != tt.wantIndex {
t.Errorf("ToQuery() index = %v, want %v", gotIndex, tt.wantIndex)
}
if !reflect.DeepEqual(gotConstr, tt.wantConstr) {
t.Errorf("ToQuery() constraints = %v, want %v", gotConstr, tt.wantConstr)
}
})
}
}
func TestSelector_ToMetadata(t *testing.T) {
tests := []struct {
name string
selector *Selector
want model.Metadata
}{
{
name: "nil selector",
selector: nil,
want: model.Metadata{},
},
{
name: "nil indexMap",
selector: &Selector{indexMap: nil},
want: model.Metadata{},
},
{
name: "empty indexMap",
selector: &Selector{indexMap: map[string]string{}},
want: model.Metadata{},
},
{
name: "flagSetId only",
selector: &Selector{indexMap: map[string]string{"flagSetId": "fsid"}},
want: model.Metadata{"flagSetId": "fsid"},
},
{
name: "source only",
selector: &Selector{indexMap: map[string]string{"source": "src"}},
want: model.Metadata{"source": "src"},
},
{
name: "flagSetId and source",
selector: &Selector{indexMap: map[string]string{"flagSetId": "fsid", "source": "src"}},
want: model.Metadata{"flagSetId": "fsid", "source": "src"},
},
{
name: "flagSetId, source, and key (key should be ignored)",
selector: &Selector{indexMap: map[string]string{"flagSetId": "fsid", "source": "src", "key": "myKey"}},
want: model.Metadata{"flagSetId": "fsid", "source": "src"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.selector.ToMetadata()
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ToMetadata() = %v, want %v", got, tt.want)
}
})
}
}
func TestNewSelector(t *testing.T) {
tests := []struct {
name string
input string
wantMap map[string]string
}{
{
name: "source and flagSetId",
input: "source=abc,flagSetId=1234",
wantMap: map[string]string{"source": "abc", "flagSetId": "1234"},
},
{
name: "source",
input: "source=abc",
wantMap: map[string]string{"source": "abc"},
},
{
name: "no equals, treat as source",
input: "mysource",
wantMap: map[string]string{"source": "mysource"},
},
{
name: "empty string",
input: "",
wantMap: map[string]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := NewSelector(tt.input)
if !reflect.DeepEqual(s.indexMap, tt.wantMap) {
t.Errorf("NewSelector(%q) indexMap = %v, want %v", tt.input, s.indexMap, tt.wantMap)
}
})
}
}

View File

@ -1,396 +0,0 @@
package store
import (
"context"
"encoding/json"
"fmt"
"slices"
"sync"
"github.com/hashicorp/go-memdb"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/model"
"github.com/open-feature/flagd/core/pkg/notifications"
)
var noValidatedSources = []string{}
type SelectorContextKey struct{}
type FlagQueryResult struct {
Flags map[string]model.Flag
}
type IStore interface {
Get(ctx context.Context, key string, selector *Selector) (model.Flag, model.Metadata, error)
GetAll(ctx context.Context, selector *Selector) (map[string]model.Flag, model.Metadata, error)
Watch(ctx context.Context, selector *Selector, watcher chan<- FlagQueryResult)
}
var _ IStore = (*Store)(nil)
type Store struct {
mx sync.RWMutex
db *memdb.MemDB
logger *logger.Logger
sources []string
// deprecated: has no effect and will be removed soon.
FlagSources []string
}
type SourceDetails struct {
Source string
Selector string
}
// NewStore creates a new in-memory store with the given sources.
// The order of sources in the slice determines their priority, when queries result in duplicate flags (queries without source or flagSetId), the higher priority source "wins".
func NewStore(logger *logger.Logger, sources []string) (*Store, error) {
// a unique index must exist for each set of constraints - for example, to look up by key and source, we need a compound index on key+source, etc
// we maybe want to generate these dynamically in the future to support more robust querying, but for now we will hardcode the ones we need
schema := &memdb.DBSchema{
Tables: map[string]*memdb.TableSchema{
flagsTable: {
Name: flagsTable,
Indexes: map[string]*memdb.IndexSchema{
// primary index; must be unique and named "id"
idIndex: {
Name: idIndex,
Unique: true,
Indexer: &memdb.CompoundIndex{
Indexes: []memdb.Indexer{
&memdb.StringFieldIndex{Field: model.FlagSetId, Lowercase: false},
&memdb.StringFieldIndex{Field: model.Key, Lowercase: false},
},
},
},
// for looking up by source
sourceIndex: {
Name: sourceIndex,
Unique: false,
Indexer: &memdb.StringFieldIndex{Field: model.Source, Lowercase: false},
},
// for looking up by priority, used to maintain highest priority flag when there are duplicates and no selector is provided
priorityIndex: {
Name: priorityIndex,
Unique: false,
Indexer: &memdb.IntFieldIndex{Field: model.Priority},
},
// for looking up by flagSetId
flagSetIdIndex: {
Name: flagSetIdIndex,
Unique: false,
Indexer: &memdb.StringFieldIndex{Field: model.FlagSetId, Lowercase: false},
},
keyIndex: {
Name: keyIndex,
Unique: false,
Indexer: &memdb.StringFieldIndex{Field: model.Key, Lowercase: false},
},
flagSetIdSourceCompoundIndex: {
Name: flagSetIdSourceCompoundIndex,
Unique: false,
Indexer: &memdb.CompoundIndex{
Indexes: []memdb.Indexer{
&memdb.StringFieldIndex{Field: model.FlagSetId, Lowercase: false},
&memdb.StringFieldIndex{Field: model.Source, Lowercase: false},
},
},
},
keySourceCompoundIndex: {
Name: keySourceCompoundIndex,
Unique: false, // duplicate from a single source ARE allowed (they just must have different flag sets)
Indexer: &memdb.CompoundIndex{
Indexes: []memdb.Indexer{
&memdb.StringFieldIndex{Field: model.Key, Lowercase: false},
&memdb.StringFieldIndex{Field: model.Source, Lowercase: false},
},
},
},
// used to query all flags from a specific source so we know which flags to delete if a flag is missing from a source
flagSetIdKeySourceCompoundIndex: {
Name: flagSetIdKeySourceCompoundIndex,
Unique: true,
Indexer: &memdb.CompoundIndex{
Indexes: []memdb.Indexer{
&memdb.StringFieldIndex{Field: model.FlagSetId, Lowercase: false},
&memdb.StringFieldIndex{Field: model.Key, Lowercase: false},
&memdb.StringFieldIndex{Field: model.Source, Lowercase: false},
},
},
},
},
},
},
}
// Create a new data base
db, err := memdb.NewMemDB(schema)
if err != nil {
return nil, fmt.Errorf("unable to initialize flag database: %w", err)
}
// clone the sources to avoid modifying the original slice
s := slices.Clone(sources)
return &Store{
sources: s,
db: db,
logger: logger,
}, nil
}
// Deprecated: use NewStore instead - will be removed very soon.
func NewFlags() *Store {
state, err := NewStore(logger.NewLogger(nil, false), noValidatedSources)
if err != nil {
panic(fmt.Sprintf("unable to create flag store: %v", err))
}
return state
}
func (s *Store) Get(_ context.Context, key string, selector *Selector) (model.Flag, model.Metadata, error) {
s.logger.Debug(fmt.Sprintf("getting flag %s", key))
txn := s.db.Txn(false)
queryMeta := selector.ToMetadata()
// if present, use the selector to query the flags
if !selector.IsEmpty() {
selector := selector.WithIndex("key", key)
indexId, constraints := selector.ToQuery()
s.logger.Debug(fmt.Sprintf("getting flag with query: %s, %v", indexId, constraints))
raw, err := txn.First(flagsTable, indexId, constraints...)
flag, ok := raw.(model.Flag)
if err != nil {
return model.Flag{}, queryMeta, fmt.Errorf("flag %s not found: %w", key, err)
}
if !ok {
return model.Flag{}, queryMeta, fmt.Errorf("flag %s is not a valid flag", key)
}
return flag, queryMeta, nil
}
// otherwise, get all flags with the given key, and keep the last one with the highest priority
s.logger.Debug(fmt.Sprintf("getting highest priority flag with key: %s", key))
it, err := txn.Get(flagsTable, keyIndex, key)
if err != nil {
return model.Flag{}, queryMeta, fmt.Errorf("flag %s not found: %w", key, err)
}
flag := model.Flag{}
found := false
for raw := it.Next(); raw != nil; raw = it.Next() {
nextFlag, ok := raw.(model.Flag)
if !ok {
continue
}
found = true
if nextFlag.Priority >= flag.Priority {
flag = nextFlag
} else {
s.logger.Debug(fmt.Sprintf("discarding flag %s from lower priority source %s in favor of flag from source %s", nextFlag.Key, s.sources[nextFlag.Priority], s.sources[flag.Priority]))
}
}
if !found {
return flag, queryMeta, fmt.Errorf("flag %s not found", key)
}
return flag, queryMeta, nil
}
func (f *Store) String() (string, error) {
f.logger.Debug("dumping flags to string")
f.mx.RLock()
defer f.mx.RUnlock()
state, _, err := f.GetAll(context.Background(), nil)
if err != nil {
return "", fmt.Errorf("unable to get all flags: %w", err)
}
bytes, err := json.Marshal(state)
if err != nil {
return "", fmt.Errorf("unable to marshal flags: %w", err)
}
return string(bytes), nil
}
// GetAll returns a copy of the store's state (copy in order to be concurrency safe)
func (s *Store) GetAll(ctx context.Context, selector *Selector) (map[string]model.Flag, model.Metadata, error) {
flags := make(map[string]model.Flag)
queryMeta := selector.ToMetadata()
it, err := s.selectOrAll(selector)
if err != nil {
s.logger.Error(fmt.Sprintf("flag query error: %v", err))
return flags, queryMeta, err
}
flags = s.collect(it)
return flags, queryMeta, nil
}
// Update the flag state with the provided flags.
func (s *Store) Update(
source string,
flags map[string]model.Flag,
metadata model.Metadata,
) (map[string]interface{}, bool) {
resyncRequired := false
if source == "" {
panic("source cannot be empty")
}
priority := slices.Index(s.sources, source)
if priority == -1 {
// this is a hack to allow old constructors that didn't pass sources, remove when we remove "NewFlags" constructor
if !slices.Equal(s.sources, noValidatedSources) {
panic(fmt.Sprintf("source %s is not registered in the store", source))
}
// same as above - remove when we remove "NewFlags" constructor
priority = 0
}
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)
s.mx.Lock()
for key := range oldFlags {
if _, ok := flags[key]; !ok {
// flag has been deleted
s.logger.Debug(fmt.Sprintf("flag %s has been deleted from source %s", key, source))
count, err := txn.DeleteAll(flagsTable, keySourceCompoundIndex, key, source)
s.logger.Debug(fmt.Sprintf("deleted %d flags with key %s from source %s", count, key, source))
if err != nil {
s.logger.Error(fmt.Sprintf("error deleting flag: %s, %v", key, err))
}
continue
}
}
s.mx.Unlock()
for key, newFlag := range flags {
s.logger.Debug(fmt.Sprintf("got metadata %v", metadata))
newFlag.Key = key
newFlag.Source = source
newFlag.Priority = priority
newFlag.Metadata = patchMetadata(metadata, newFlag.Metadata)
// flagSetId defaults to a UUID generated at startup to make our queries isomorphic
flagSetId := nilFlagSetId
// flagSetId is inherited from the set, but can be overridden by the flag
setFlagSetId, ok := newFlag.Metadata["flagSetId"].(string)
if ok {
flagSetId = setFlagSetId
}
newFlag.FlagSetId = flagSetId
raw, err := txn.First(flagsTable, keySourceCompoundIndex, key, source)
if err != nil {
s.logger.Error(fmt.Sprintf("unable to get flag %s from source %s: %v", key, source, err))
continue
}
oldFlag, ok := raw.(model.Flag)
// If we already have a flag with the same key and source, we need to check if it has the same flagSetId
if ok {
if oldFlag.FlagSetId != newFlag.FlagSetId {
// If the flagSetId is different, we need to delete the entry, since flagSetId+key represents the primary index, and it's now been changed.
// This is important especially for clients listening to flagSetId changes, as they expect the flag to be removed from the set in this case.
_, err = txn.DeleteAll(flagsTable, idIndex, oldFlag.FlagSetId, key)
if err != nil {
s.logger.Error(fmt.Sprintf("unable to delete flags with key %s and flagSetId %s: %v", key, oldFlag.FlagSetId, err))
continue
}
}
}
// Store the new version of the flag
s.logger.Debug(fmt.Sprintf("storing flag: %v", newFlag))
err = txn.Insert(flagsTable, newFlag)
if err != nil {
s.logger.Error(fmt.Sprintf("unable to insert flag %s: %v", key, err))
continue
}
}
txn.Commit()
return notifications.NewFromFlags(oldFlags, flags), resyncRequired
}
// Watch the result-set of a selector for changes, sending updates to the watcher channel.
func (s *Store) Watch(ctx context.Context, selector *Selector, watcher chan<- FlagQueryResult) {
go func() {
for {
ws := memdb.NewWatchSet()
it, err := s.selectOrAll(selector)
if err != nil {
s.logger.Error(fmt.Sprintf("error watching flags: %v", err))
close(watcher)
return
}
ws.Add(it.WatchCh())
flags := s.collect(it)
watcher <- FlagQueryResult{
Flags: flags,
}
if err = ws.WatchCtx(ctx); err != nil {
s.logger.Error(fmt.Sprintf("error watching flags: %v", err))
close(watcher)
return
}
}
}()
}
// returns an iterator for the given selector, or all flags if the selector is nil or empty
func (s *Store) selectOrAll(selector *Selector) (it memdb.ResultIterator, err error) {
txn := s.db.Txn(false)
if !selector.IsEmpty() {
indexId, constraints := selector.ToQuery()
s.logger.Debug(fmt.Sprintf("getting all flags with query: %s, %v", indexId, constraints))
return txn.Get(flagsTable, indexId, constraints...)
} else {
// no selector, get all flags
return txn.Get(flagsTable, idIndex)
}
}
// collects flags from an iterator, ensuring that only the highest priority flag is kept when there are duplicates
func (s *Store) collect(it memdb.ResultIterator) map[string]model.Flag {
flags := make(map[string]model.Flag)
for raw := it.Next(); raw != nil; raw = it.Next() {
flag := raw.(model.Flag)
if existing, ok := flags[flag.Key]; ok {
if flag.Priority < existing.Priority {
s.logger.Debug(fmt.Sprintf("discarding duplicate flag %s from lower priority source %s in favor of flag from source %s", flag.Key, s.sources[flag.Priority], s.sources[existing.Priority]))
continue // we already have a higher priority flag
}
s.logger.Debug(fmt.Sprintf("overwriting duplicate flag %s from lower priority source %s in favor of flag from source %s", flag.Key, s.sources[existing.Priority], s.sources[flag.Priority]))
}
flags[flag.Key] = flag
}
return flags
}
func patchMetadata(original, patch model.Metadata) model.Metadata {
patched := make(model.Metadata)
if original == nil && patch == nil {
return nil
}
for key, value := range original {
patched[key] = value
}
for key, value := range patch { // patch values overwrite m1 values on key conflict
patched[key] = value
}
return patched
}

View File

@ -1,487 +0,0 @@
package store
import (
"context"
"testing"
"time"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateFlags(t *testing.T) {
const source1 = "source1"
const source2 = "source2"
var sources = []string{source1, source2}
t.Parallel()
tests := []struct {
name string
setup func(t *testing.T) *Store
newFlags map[string]model.Flag
source string
wantFlags map[string]model.Flag
setMetadata model.Metadata
wantNotifs map[string]interface{}
wantResync bool
}{
{
name: "both nil",
setup: func(t *testing.T) *Store {
s, err := NewStore(logger.NewLogger(nil, false), sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
return s
},
source: source1,
newFlags: nil,
wantFlags: map[string]model.Flag{},
wantNotifs: map[string]interface{}{},
},
{
name: "both empty flags",
setup: func(t *testing.T) *Store {
s, err := NewStore(logger.NewLogger(nil, false), sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
return s
},
source: source1,
newFlags: map[string]model.Flag{},
wantFlags: map[string]model.Flag{},
wantNotifs: map[string]interface{}{},
},
{
name: "empty new",
setup: func(t *testing.T) *Store {
s, err := NewStore(logger.NewLogger(nil, false), sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
return s
},
source: source1,
newFlags: nil,
wantFlags: map[string]model.Flag{},
wantNotifs: map[string]interface{}{},
},
{
name: "update from source 1 (old flag removed)",
setup: func(t *testing.T) *Store {
s, err := NewStore(logger.NewLogger(nil, false), sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
s.Update(source1, map[string]model.Flag{
"waka": {DefaultVariant: "off"},
}, nil)
return s
},
newFlags: map[string]model.Flag{
"paka": {DefaultVariant: "on"},
},
source: source1,
wantFlags: map[string]model.Flag{
"paka": {Key: "paka", DefaultVariant: "on", Source: source1, FlagSetId: nilFlagSetId, Priority: 0},
},
wantNotifs: map[string]interface{}{
"paka": map[string]interface{}{"type": "write"},
"waka": map[string]interface{}{"type": "delete"},
},
},
{
name: "update from source 1 (new flag added)",
setup: func(t *testing.T) *Store {
s, err := NewStore(logger.NewLogger(nil, false), sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
s.Update(source1, map[string]model.Flag{
"waka": {DefaultVariant: "off"},
}, nil)
return s
},
newFlags: map[string]model.Flag{
"paka": {DefaultVariant: "on"},
},
source: source2,
wantFlags: map[string]model.Flag{
"waka": {Key: "waka", DefaultVariant: "off", Source: source1, FlagSetId: nilFlagSetId, Priority: 0},
"paka": {Key: "paka", DefaultVariant: "on", Source: source2, FlagSetId: nilFlagSetId, Priority: 1},
},
wantNotifs: map[string]interface{}{"paka": map[string]interface{}{"type": "write"}},
},
{
name: "flag set inheritance",
setup: func(t *testing.T) *Store {
s, err := NewStore(logger.NewLogger(nil, false), sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
s.Update(source1, map[string]model.Flag{}, model.Metadata{})
return s
},
setMetadata: model.Metadata{
"flagSetId": "topLevelSet", // top level set metadata, including flagSetId
},
newFlags: map[string]model.Flag{
"waka": {DefaultVariant: "on"},
"paka": {DefaultVariant: "on", Metadata: model.Metadata{"flagSetId": "flagLevelSet"}}, // overrides set level flagSetId
},
source: source1,
wantFlags: map[string]model.Flag{
"waka": {Key: "waka", DefaultVariant: "on", Source: source1, FlagSetId: "topLevelSet", Priority: 0, Metadata: model.Metadata{"flagSetId": "topLevelSet"}},
"paka": {Key: "paka", DefaultVariant: "on", Source: source1, FlagSetId: "flagLevelSet", Priority: 0, Metadata: model.Metadata{"flagSetId": "flagLevelSet"}},
},
wantNotifs: map[string]interface{}{
"paka": map[string]interface{}{"type": "write"},
"waka": map[string]interface{}{"type": "write"},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
store := tt.setup(t)
gotNotifs, resyncRequired := store.Update(tt.source, tt.newFlags, tt.setMetadata)
gotFlags, _, _ := store.GetAll(context.Background(), nil)
require.Equal(t, tt.wantFlags, gotFlags)
require.Equal(t, tt.wantNotifs, gotNotifs)
require.Equal(t, tt.wantResync, resyncRequired)
})
}
}
func TestGet(t *testing.T) {
sourceA := "sourceA"
sourceB := "sourceB"
sourceC := "sourceC"
flagSetIdB := "flagSetIdA"
flagSetIdC := "flagSetIdC"
var sources = []string{sourceA, sourceB, sourceC}
sourceASelector := NewSelector("source=" + sourceA)
flagSetIdCSelector := NewSelector("flagSetId=" + flagSetIdC)
t.Parallel()
tests := []struct {
name string
key string
selector *Selector
wantFlag model.Flag
wantErr bool
}{
{
name: "nil selector",
key: "flagA",
selector: nil,
wantFlag: model.Flag{Key: "flagA", DefaultVariant: "off", Source: sourceA, FlagSetId: nilFlagSetId, Priority: 0},
wantErr: false,
},
{
name: "flagSetId selector",
key: "dupe",
selector: &flagSetIdCSelector,
wantFlag: model.Flag{Key: "dupe", DefaultVariant: "off", Source: sourceC, FlagSetId: flagSetIdC, Priority: 2, Metadata: model.Metadata{"flagSetId": flagSetIdC}},
wantErr: false,
},
{
name: "source selector",
key: "dupe",
selector: &sourceASelector,
wantFlag: model.Flag{Key: "dupe", DefaultVariant: "on", Source: sourceA, FlagSetId: nilFlagSetId, Priority: 0},
wantErr: false,
},
{
name: "flag not found with source selector",
key: "flagB",
selector: &sourceASelector,
wantFlag: model.Flag{Key: "flagB", DefaultVariant: "off", Source: sourceB, FlagSetId: flagSetIdB, Priority: 1, Metadata: model.Metadata{"flagSetId": flagSetIdB}},
wantErr: true,
},
{
name: "flag not found with flagSetId selector",
key: "flagB",
selector: &flagSetIdCSelector,
wantFlag: model.Flag{Key: "flagB", DefaultVariant: "off", Source: sourceB, FlagSetId: flagSetIdB, Priority: 1, Metadata: model.Metadata{"flagSetId": flagSetIdB}},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
sourceAFlags := map[string]model.Flag{
"flagA": {Key: "flagA", DefaultVariant: "off"},
"dupe": {Key: "dupe", DefaultVariant: "on"},
}
sourceBFlags := map[string]model.Flag{
"flagB": {Key: "flagB", DefaultVariant: "off", Metadata: model.Metadata{"flagSetId": flagSetIdB}},
}
sourceCFlags := map[string]model.Flag{
"flagC": {Key: "flagC", DefaultVariant: "off", Metadata: model.Metadata{"flagSetId": flagSetIdC}},
"dupe": {Key: "dupe", DefaultVariant: "off", Metadata: model.Metadata{"flagSetId": flagSetIdC}},
}
store, err := NewStore(logger.NewLogger(nil, false), sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
store.Update(sourceA, sourceAFlags, nil)
store.Update(sourceB, sourceBFlags, nil)
store.Update(sourceC, sourceCFlags, nil)
gotFlag, _, err := store.Get(context.Background(), tt.key, tt.selector)
if !tt.wantErr {
require.Equal(t, tt.wantFlag, gotFlag)
} else {
require.Error(t, err, "expected an error for key %s with selector %v", tt.key, tt.selector)
}
})
}
}
func TestGetAllNoWatcher(t *testing.T) {
sourceA := "sourceA"
sourceB := "sourceB"
sourceC := "sourceC"
flagSetIdB := "flagSetIdA"
flagSetIdC := "flagSetIdC"
sources := []string{sourceA, sourceB, sourceC}
sourceASelector := NewSelector("source=" + sourceA)
flagSetIdCSelector := NewSelector("flagSetId=" + flagSetIdC)
t.Parallel()
tests := []struct {
name string
selector *Selector
wantFlags map[string]model.Flag
}{
{
name: "nil selector",
selector: nil,
wantFlags: map[string]model.Flag{
// "dupe" should be overwritten by higher priority flag
"flagA": {Key: "flagA", DefaultVariant: "off", Source: sourceA, FlagSetId: nilFlagSetId, Priority: 0},
"flagB": {Key: "flagB", DefaultVariant: "off", Source: sourceB, FlagSetId: flagSetIdB, Priority: 1, Metadata: model.Metadata{"flagSetId": flagSetIdB}},
"flagC": {Key: "flagC", DefaultVariant: "off", Source: sourceC, FlagSetId: flagSetIdC, Priority: 2, Metadata: model.Metadata{"flagSetId": flagSetIdC}},
"dupe": {Key: "dupe", DefaultVariant: "off", Source: sourceC, FlagSetId: flagSetIdC, Priority: 2, Metadata: model.Metadata{"flagSetId": flagSetIdC}},
},
},
{
name: "source selector",
selector: &sourceASelector,
wantFlags: map[string]model.Flag{
// we should get the "dupe" from sourceA
"flagA": {Key: "flagA", DefaultVariant: "off", Source: sourceA, FlagSetId: nilFlagSetId, Priority: 0},
"dupe": {Key: "dupe", DefaultVariant: "on", Source: sourceA, FlagSetId: nilFlagSetId, Priority: 0},
},
},
{
name: "flagSetId selector",
selector: &flagSetIdCSelector,
wantFlags: map[string]model.Flag{
// we should get the "dupe" from flagSetIdC
"flagC": {Key: "flagC", DefaultVariant: "off", Source: sourceC, FlagSetId: flagSetIdC, Priority: 2, Metadata: model.Metadata{"flagSetId": flagSetIdC}},
"dupe": {Key: "dupe", DefaultVariant: "off", Source: sourceC, FlagSetId: flagSetIdC, Priority: 2, Metadata: model.Metadata{"flagSetId": flagSetIdC}},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
sourceAFlags := map[string]model.Flag{
"flagA": {Key: "flagA", DefaultVariant: "off"},
"dupe": {Key: "dupe", DefaultVariant: "on"},
}
sourceBFlags := map[string]model.Flag{
"flagB": {Key: "flagB", DefaultVariant: "off", Metadata: model.Metadata{"flagSetId": flagSetIdB}},
}
sourceCFlags := map[string]model.Flag{
"flagC": {Key: "flagC", DefaultVariant: "off", Metadata: model.Metadata{"flagSetId": flagSetIdC}},
"dupe": {Key: "dupe", DefaultVariant: "off", Metadata: model.Metadata{"flagSetId": flagSetIdC}},
}
store, err := NewStore(logger.NewLogger(nil, false), sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
store.Update(sourceA, sourceAFlags, nil)
store.Update(sourceB, sourceBFlags, nil)
store.Update(sourceC, sourceCFlags, nil)
gotFlags, _, _ := store.GetAll(context.Background(), tt.selector)
require.Equal(t, len(tt.wantFlags), len(gotFlags))
require.Equal(t, tt.wantFlags, gotFlags)
})
}
}
func TestWatch(t *testing.T) {
sourceA := "sourceA"
sourceB := "sourceB"
sourceC := "sourceC"
myFlagSetId := "myFlagSet"
var sources = []string{sourceA, sourceB, sourceC}
pauseTime := 100 * time.Millisecond // time for updates to settle
timeout := 1000 * time.Millisecond // time to make sure we get enough updates, and no extras
sourceASelector := NewSelector("source=" + sourceA)
flagSetIdCSelector := NewSelector("flagSetId=" + myFlagSetId)
emptySelector := NewSelector("")
sourceCSelector := NewSelector("source=" + sourceC)
tests := []struct {
name string
selector *Selector
wantUpdates int
}{
{
name: "flag source selector (initial, plus 1 update)",
selector: &sourceASelector,
wantUpdates: 2,
},
{
name: "flag set selector (initial, plus 3 updates)",
selector: &flagSetIdCSelector,
wantUpdates: 4,
},
{
name: "no selector (all updates)",
selector: &emptySelector,
wantUpdates: 5,
},
{
name: "flag source selector for unchanged source (initial, plus no updates)",
selector: &sourceCSelector,
wantUpdates: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
sourceAFlags := map[string]model.Flag{
"flagA": {Key: "flagA", DefaultVariant: "off"},
}
sourceBFlags := map[string]model.Flag{
"flagB": {Key: "flagB", DefaultVariant: "off", Metadata: model.Metadata{"flagSetId": myFlagSetId}},
}
sourceCFlags := map[string]model.Flag{
"flagC": {Key: "flagC", DefaultVariant: "off"},
}
store, err := NewStore(logger.NewLogger(nil, false), sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
// setup initial flags
store.Update(sourceA, sourceAFlags, model.Metadata{})
store.Update(sourceB, sourceBFlags, model.Metadata{})
store.Update(sourceC, sourceCFlags, model.Metadata{})
watcher := make(chan FlagQueryResult, 1)
time.Sleep(pauseTime)
ctx, cancel := context.WithCancel(context.Background())
store.Watch(ctx, tt.selector, watcher)
// perform updates
go func() {
time.Sleep(pauseTime)
// changing a flag default variant should trigger an update
store.Update(sourceA, map[string]model.Flag{
"flagA": {Key: "flagA", DefaultVariant: "on"},
}, model.Metadata{})
time.Sleep(pauseTime)
// changing a flag default variant should trigger an update
store.Update(sourceB, map[string]model.Flag{
"flagB": {Key: "flagB", DefaultVariant: "on", Metadata: model.Metadata{"flagSetId": myFlagSetId}},
}, model.Metadata{})
time.Sleep(pauseTime)
// removing a flag set id should trigger an update (even for flag set id selectors; it should remove the flag from the set)
store.Update(sourceB, map[string]model.Flag{
"flagB": {Key: "flagB", DefaultVariant: "on"},
}, model.Metadata{})
time.Sleep(pauseTime)
// adding a flag set id should trigger an update
store.Update(sourceB, map[string]model.Flag{
"flagB": {Key: "flagB", DefaultVariant: "on", Metadata: model.Metadata{"flagSetId": myFlagSetId}},
}, model.Metadata{})
}()
updates := 0
for {
select {
case <-time.After(timeout):
assert.Equal(t, tt.wantUpdates, updates, "expected %d updates, got %d", tt.wantUpdates, updates)
cancel()
_, open := <-watcher
assert.False(t, open, "watcher channel should be closed after cancel")
return
case q := <-watcher:
if q.Flags != nil {
updates++
}
}
}
})
}
}
func TestQueryMetadata(t *testing.T) {
sourceA := "sourceA"
otherSource := "otherSource"
nonExistingFlagSetId := "nonExistingFlagSetId"
var sources = []string{sourceA}
sourceAFlags := map[string]model.Flag{
"flagA": {Key: "flagA", DefaultVariant: "off"},
"flagB": {Key: "flagB", DefaultVariant: "on"},
}
store, err := NewStore(logger.NewLogger(nil, false), sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
// setup initial flags
store.Update(sourceA, sourceAFlags, model.Metadata{})
selector := NewSelector("source=" + otherSource + ",flagSetId=" + nonExistingFlagSetId)
_, metadata, _ := store.GetAll(context.Background(), &selector)
assert.Equal(t, metadata, model.Metadata{"source": otherSource, "flagSetId": nonExistingFlagSetId}, "metadata did not match expected")
selector = NewSelector("source=" + otherSource + ",flagSetId=" + nonExistingFlagSetId)
_, metadata, _ = store.Get(context.Background(), "key", &selector)
assert.Equal(t, metadata, model.Metadata{"source": otherSource, "flagSetId": nonExistingFlagSetId}, "metadata did not match expected")
}

View File

@ -21,8 +21,6 @@ type syncMock struct {
initError error
ctxCloseError error
mu sync.Mutex
}
func newMockSync() *syncMock {
@ -40,8 +38,6 @@ func (s *syncMock) Sync(ctx context.Context, dataSync chan<- isync.DataSync) err
for {
select {
case <-ctx.Done():
s.mu.Lock()
defer s.mu.Unlock()
return s.ctxCloseError
case d := <-s.dataSyncChanIn:
dataSync <- d
@ -52,8 +48,6 @@ func (s *syncMock) Sync(ctx context.Context, dataSync chan<- isync.DataSync) err
}
func (s *syncMock) ReSync(_ context.Context, dataSync chan<- isync.DataSync) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.resyncData != nil {
dataSync <- *s.resyncData
}
@ -110,6 +104,7 @@ func Test_watchResource(t *testing.T) {
in := isync.DataSync{
FlagData: "im a flag",
Source: "im a flag source",
Type: isync.ALL,
}
syncMock.dataSyncChanIn <- in
@ -334,6 +329,7 @@ func Test_FetchAllFlags(t *testing.T) {
mockData: &isync.DataSync{
FlagData: "im a flag",
Source: "im a flag source",
Type: isync.ALL,
},
setHandler: true,
},
@ -400,6 +396,7 @@ func Test_registerSubscriptionResyncPath(t *testing.T) {
data: &isync.DataSync{
FlagData: "im a flag",
Source: "im a flag source",
Type: isync.ALL,
},
expectErr: false,
},

View File

@ -1,147 +0,0 @@
package blob
import (
"context"
"errors"
"fmt"
"io"
"path/filepath"
"time"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/sync"
"github.com/open-feature/flagd/core/pkg/utils"
"gocloud.dev/blob"
_ "gocloud.dev/blob/azureblob" // needed to initialize Azure Blob Storage driver
_ "gocloud.dev/blob/gcsblob" // needed to initialize GCS driver
_ "gocloud.dev/blob/s3blob" // needed to initialize s3 driver
)
type Sync struct {
Bucket string
Object string
BlobURLMux *blob.URLMux
Cron Cron
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")
}
if hs.Object == "" {
return errors.New("no object string set")
}
return nil
}
func (hs *Sync) IsReady() bool {
return hs.ready
}
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))
}
})
// Initial fetch
hs.Logger.Debug(fmt.Sprintf("initial sync of the %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()
return nil
}
func (hs *Sync) ReSync(ctx context.Context, dataSync chan<- sync.DataSync) error {
return hs.sync(ctx, dataSync, true)
}
func (hs *Sync) sync(ctx context.Context, dataSync chan<- sync.DataSync, skipCheckingModTime bool) error {
bucket, err := hs.getBucket(ctx)
if err != nil {
return fmt.Errorf("couldn't get bucket: %v", err)
}
defer bucket.Close()
var updated time.Time
if !skipCheckingModTime {
updated, err = hs.fetchObjectModificationTime(ctx, bucket)
if err != nil {
return fmt.Errorf("couldn't get object attributes: %v", err)
}
if hs.lastUpdated.Equal(updated) {
hs.Logger.Debug("configuration hasn't changed, skipping fetching full object")
return nil
}
if hs.lastUpdated.After(updated) {
hs.Logger.Warn("configuration changed but the modification time decreased instead of increasing")
}
}
msg, err := hs.fetchObject(ctx, bucket)
if err != nil {
return fmt.Errorf("couldn't get object: %v", err)
}
hs.Logger.Debug(fmt.Sprintf("configuration updated: %s", msg))
if !skipCheckingModTime {
hs.lastUpdated = updated
}
dataSync <- sync.DataSync{FlagData: msg, Source: hs.Bucket + hs.Object}
return nil
}
func (hs *Sync) getBucket(ctx context.Context) (*blob.Bucket, error) {
b, err := hs.BlobURLMux.OpenBucket(ctx, hs.Bucket)
if err != nil {
return nil, fmt.Errorf("error opening bucket %s: %v", hs.Bucket, err)
}
return b, nil
}
func (hs *Sync) fetchObjectModificationTime(ctx context.Context, bucket *blob.Bucket) (time.Time, error) {
if hs.Object == "" {
return time.Time{}, errors.New("no object string set")
}
attrs, err := bucket.Attributes(ctx, hs.Object)
if err != nil {
return time.Time{}, fmt.Errorf("error fetching attributes for object %s/%s: %w", hs.Bucket, hs.Object, err)
}
return attrs.ModTime, nil
}
func (hs *Sync) fetchObject(ctx context.Context, bucket *blob.Bucket) (string, error) {
r, err := bucket.NewReader(ctx, hs.Object, nil)
if err != nil {
return "", fmt.Errorf("error opening reader for object %s/%s: %w", hs.Bucket, hs.Object, err)
}
defer r.Close()
data, err := io.ReadAll(r)
if err != nil {
return "", fmt.Errorf("error downloading object %s/%s: %w", hs.Bucket, hs.Object, err)
}
json, err := utils.ConvertToJSON(data, filepath.Ext(hs.Object), r.ContentType())
if err != nil {
return "", fmt.Errorf("error converting blob data to json: %w", err)
}
return json, nil
}

View File

@ -1,152 +0,0 @@
package blob
import (
"context"
"log"
"testing"
"time"
"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) {
tests := map[string]struct {
scheme string
bucket string
object string
content string
convertedContent string
}{
"json file type": {
scheme: "xyz",
bucket: "b",
object: "flags.json",
content: "{\"flags\":{}}",
convertedContent: "{\"flags\":{}}",
},
"yaml file type": {
scheme: "xyz",
bucket: "b",
object: "flags.yaml",
content: "flags: []",
convertedContent: "{\"flags\":[]}",
},
}
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)
blobSync := &Sync{
Bucket: tt.scheme + "://" + tt.bucket,
Object: tt.object,
Cron: mockCron,
Logger: logger.NewLogger(nil, false),
}
blobMock := NewMockBlob(tt.scheme, func() *Sync {
return blobSync
})
blobSync.BlobURLMux = blobMock.URLMux()
ctx := context.Background()
dataSyncChan := make(chan sync.DataSync, 1)
blobMock.AddObject(tt.object, tt.content)
go func() {
err := blobSync.Sync(ctx, dataSyncChan)
if err != nil {
log.Fatalf("Error start sync: %s", err.Error())
return
}
}()
data := <-dataSyncChan // initial sync
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)
})
}
}
func tickWithConfigChange(t *testing.T, mockCron *synctesting.MockCron, 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()
select {
case data, ok := <-dataSyncChan:
if ok {
if data.FlagData != newConfig {
t.Errorf("expected content: %s, but received content: %s", newConfig, data.FlagData)
}
} else {
t.Errorf("data channel unexpectedly closed")
}
default:
t.Errorf("data channel has no expected update")
}
}
func tickWithoutConfigChange(t *testing.T, mockCron *synctesting.MockCron, dataSyncChan chan sync.DataSync) {
mockCron.Tick()
select {
case data, ok := <-dataSyncChan:
if ok {
t.Errorf("unexpected update: %s", data.FlagData)
} else {
t.Errorf("data channel unexpectedly closed")
}
default:
}
}
func TestReSync(t *testing.T) {
const (
scheme = "xyz"
bucket = "b"
object = "flags.json"
)
ctrl := gomock.NewController(t)
mockCron := synctesting.NewMockCron(ctrl)
blobSync := &Sync{
Bucket: scheme + "://" + bucket,
Object: object,
Cron: mockCron,
Logger: logger.NewLogger(nil, false),
}
blobMock := NewMockBlob(scheme, func() *Sync {
return blobSync
})
blobSync.BlobURLMux = blobMock.URLMux()
ctx := context.Background()
dataSyncChan := make(chan sync.DataSync, 1)
config := "my-config"
blobMock.AddObject(object, config)
err := blobSync.ReSync(ctx, dataSyncChan)
if err != nil {
log.Fatalf("Error start sync: %s", err.Error())
return
}
data := <-dataSyncChan
if data.FlagData != config {
t.Errorf("expected content: %s, but received content: %s", config, data.FlagData)
}
}

View File

@ -1,72 +0,0 @@
package blob
import (
"context"
"log"
"net/url"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
)
type MockBlob struct {
mux *blob.URLMux
scheme string
opener *fakeOpener
}
type fakeOpener struct {
object string
content string
keepModTime bool
getSync func() *Sync
}
func (f *fakeOpener) OpenBucketURL(ctx context.Context, _ *url.URL) (*blob.Bucket, error) {
bucketURL, err := url.Parse("mem://")
if err != nil {
log.Fatalf("couldn't parse url: %s: %v", "mem://", err)
}
opener := &memblob.URLOpener{}
bucket, err := opener.OpenBucketURL(ctx, bucketURL)
if err != nil {
log.Fatalf("couldn't open in memory bucket: %v", err)
}
if f.object != "" {
err = bucket.WriteAll(ctx, f.object, []byte(f.content), nil)
if err != nil {
log.Fatalf("couldn't write in memory file: %v", err)
}
}
if f.keepModTime && f.object != "" {
attrs, err := bucket.Attributes(ctx, f.object)
if err != nil {
log.Fatalf("couldn't get memory file attributes: %v", err)
}
f.getSync().lastUpdated = attrs.ModTime
} else {
f.keepModTime = true
}
return bucket, nil
}
func NewMockBlob(scheme string, getSync func() *Sync) *MockBlob {
mux := new(blob.URLMux)
opener := &fakeOpener{getSync: getSync}
mux.RegisterBucket(scheme, opener)
return &MockBlob{
mux: mux,
scheme: scheme,
opener: opener,
}
}
func (mb *MockBlob) URLMux() *blob.URLMux {
return mb.mux
}
func (mb *MockBlob) AddObject(object, content string) {
mb.opener.object = object
mb.opener.content = content
mb.opener.keepModTime = false
}

View File

@ -1,10 +1,5 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: pkg/sync/builder/syncbuilder.go
//
// Generated by this command:
//
// mockgen -source=pkg/sync/builder/syncbuilder.go -destination=pkg/sync/builder/mock/syncbuilder.go -package=middlewaremocksyncbuildermock
//
// Package middlewaremocksyncbuildermock is a generated GoMock package.
package middlewaremocksyncbuildermock
@ -12,9 +7,9 @@ package middlewaremocksyncbuildermock
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
logger "github.com/open-feature/flagd/core/pkg/logger"
sync "github.com/open-feature/flagd/core/pkg/sync"
gomock "go.uber.org/mock/gomock"
dynamic "k8s.io/client-go/dynamic"
)
@ -51,7 +46,7 @@ func (m *MockISyncBuilder) SyncFromURI(uri string, logger *logger.Logger) (sync.
}
// SyncFromURI indicates an expected call of SyncFromURI.
func (mr *MockISyncBuilderMockRecorder) SyncFromURI(uri, logger any) *gomock.Call {
func (mr *MockISyncBuilderMockRecorder) SyncFromURI(uri, logger interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncFromURI", reflect.TypeOf((*MockISyncBuilder)(nil).SyncFromURI), uri, logger)
}
@ -66,7 +61,7 @@ func (m *MockISyncBuilder) SyncsFromConfig(sourceConfig []sync.SourceConfig, log
}
// SyncsFromConfig indicates an expected call of SyncsFromConfig.
func (mr *MockISyncBuilderMockRecorder) SyncsFromConfig(sourceConfig, logger any) *gomock.Call {
func (mr *MockISyncBuilderMockRecorder) SyncsFromConfig(sourceConfig, logger interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncsFromConfig", reflect.TypeOf((*MockISyncBuilder)(nil).SyncsFromConfig), sourceConfig, logger)
}
@ -94,7 +89,7 @@ func (m *MockIK8sClientBuilder) EXPECT() *MockIK8sClientBuilderMockRecorder {
return m.recorder
}
// GetK8sClient mocks base method.
// GetK8sClients mocks base method.
func (m *MockIK8sClientBuilder) GetK8sClient() (dynamic.Interface, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetK8sClient")
@ -103,8 +98,8 @@ func (m *MockIK8sClientBuilder) GetK8sClient() (dynamic.Interface, error) {
return ret0, ret1
}
// GetK8sClient indicates an expected call of GetK8sClient.
func (mr *MockIK8sClientBuilderMockRecorder) GetK8sClient() *gomock.Call {
// GetK8sClients indicates an expected call of GetK8sClients.
func (mr *MockIK8sClientBuilderMockRecorder) GetK8sClients() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetK8sClient", reflect.TypeOf((*MockIK8sClientBuilder)(nil).GetK8sClient))
}

View File

@ -5,11 +5,11 @@ import (
"net/http"
"os"
"regexp"
msync "sync"
"time"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/sync"
blobSync "github.com/open-feature/flagd/core/pkg/sync/blob"
"github.com/open-feature/flagd/core/pkg/sync/file"
"github.com/open-feature/flagd/core/pkg/sync/grpc"
"github.com/open-feature/flagd/core/pkg/sync/grpc/credentials"
@ -17,7 +17,6 @@ import (
"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"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
@ -25,26 +24,17 @@ import (
const (
syncProviderFile = "file"
syncProviderFsNotify = "fsnotify"
syncProviderFileInfo = "fileinfo"
syncProviderGrpc = "grpc"
syncProviderKubernetes = "kubernetes"
syncProviderHTTP = "http"
syncProviderGcs = "gcs"
syncProviderAzblob = "azblob"
syncProviderS3 = "s3"
)
var (
regCrd *regexp.Regexp
regURL *regexp.Regexp
regGRPC *regexp.Regexp
regGRPCSecure *regexp.Regexp
regGRPCCustomResolver *regexp.Regexp
regFile *regexp.Regexp
regGcs *regexp.Regexp
regAzblob *regexp.Regexp
regS3 *regexp.Regexp
regCrd *regexp.Regexp
regURL *regexp.Regexp
regGRPC *regexp.Regexp
regGRPCSecure *regexp.Regexp
regFile *regexp.Regexp
)
func init() {
@ -52,11 +42,7 @@ func init() {
regURL = regexp.MustCompile("^https?://")
regGRPC = regexp.MustCompile("^" + grpc.Prefix)
regGRPCSecure = regexp.MustCompile("^" + grpc.PrefixSecure)
regGRPCCustomResolver = regexp.MustCompile("^" + grpc.SupportedScheme)
regFile = regexp.MustCompile("^file:")
regGcs = regexp.MustCompile("^gs://.+?/")
regAzblob = regexp.MustCompile("^azblob://.+?/")
regS3 = regexp.MustCompile("^s3://.+?/")
}
type ISyncBuilder interface {
@ -100,13 +86,8 @@ func (sb *SyncBuilder) SyncsFromConfig(sourceConfigs []sync.SourceConfig, logger
func (sb *SyncBuilder) syncFromConfig(sourceConfig sync.SourceConfig, logger *logger.Logger) (sync.ISync, error) {
switch sourceConfig.Provider {
case syncProviderFile:
logger.Debug(fmt.Sprintf("using filepath sync-provider for: %q", sourceConfig.URI))
return sb.newFile(sourceConfig.URI, logger), nil
case syncProviderFsNotify:
logger.Debug(fmt.Sprintf("using fsnotify sync-provider for: %q", sourceConfig.URI))
return sb.newFsNotify(sourceConfig.URI, logger), nil
case syncProviderFileInfo:
logger.Debug(fmt.Sprintf("using fileinfo sync-provider for: %q", sourceConfig.URI))
return sb.newFileInfo(sourceConfig.URI, logger), nil
case syncProviderKubernetes:
logger.Debug(fmt.Sprintf("using kubernetes sync-provider for: %s", sourceConfig.URI))
return sb.newK8s(sourceConfig.URI, logger)
@ -116,60 +97,24 @@ func (sb *SyncBuilder) syncFromConfig(sourceConfig sync.SourceConfig, logger *lo
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
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
default:
return nil, fmt.Errorf("invalid sync provider: %s, must be one of with "+
"'%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' or '%s'",
sourceConfig.Provider, syncProviderFile, syncProviderFsNotify, syncProviderFileInfo,
syncProviderKubernetes, syncProviderHTTP, syncProviderGrpc, syncProviderGcs, syncProviderAzblob, syncProviderS3)
return nil, fmt.Errorf("invalid sync provider: %s, must be one of with '%s', '%s', '%s' or '%s'",
sourceConfig.Provider, syncProviderFile, syncProviderKubernetes, syncProviderHTTP, syncProviderKubernetes)
}
}
// newFile returns an fsinfo sync if we are in k8s or fileinfo if not
func (sb *SyncBuilder) newFile(uri string, logger *logger.Logger) *file.Sync {
switch os.Getenv("KUBERNETES_SERVICE_HOST") {
case "":
// no k8s service host env; use fileinfo
return sb.newFileInfo(uri, logger)
default:
// default to fsnotify
return sb.newFsNotify(uri, logger)
return &file.Sync{
URI: regFile.ReplaceAllString(uri, ""),
Logger: logger.WithFields(
zap.String("component", "sync"),
zap.String("sync", "filepath"),
),
Mux: &msync.RWMutex{},
}
}
// return a new file.Sync that uses fsnotify under the hood
func (sb *SyncBuilder) newFsNotify(uri string, logger *logger.Logger) *file.Sync {
return file.NewFileSync(
regFile.ReplaceAllString(uri, ""),
file.FSNOTIFY,
logger.WithFields(
zap.String("component", "sync"),
zap.String("sync", syncProviderFsNotify),
),
)
}
// return a new file.Sync that uses os.Stat/fs.FileInfo under the hood
func (sb *SyncBuilder) newFileInfo(uri string, logger *logger.Logger) *file.Sync {
return file.NewFileSync(
regFile.ReplaceAllString(uri, ""),
file.FILEINFO,
logger.WithFields(
zap.String("component", "sync"),
zap.String("sync", syncProviderFileInfo),
),
)
}
func (sb *SyncBuilder) newK8s(uri string, logger *logger.Logger) (*kubernetes.Sync, error) {
dynamicClient, err := sb.k8sClientBuilder.GetK8sClient()
if err != nil {
@ -221,100 +166,6 @@ func (sb *SyncBuilder) newGRPC(config sync.SourceConfig, logger *logger.Logger)
ProviderID: config.ProviderID,
Secure: config.TLS,
Selector: config.Selector,
MaxMsgSize: config.MaxMsgSize,
}
}
func (sb *SyncBuilder) newGcs(config sync.SourceConfig, logger *logger.Logger) *blobSync.Sync {
// 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, "")
// Defaults to 5 seconds if interval is not set.
var interval uint32 = 5
if config.Interval != 0 {
interval = config.Interval
}
return &blobSync.Sync{
Bucket: bucketURI,
Object: objectName,
BlobURLMux: blob.DefaultURLMux(),
Logger: logger.WithFields(
zap.String("component", "sync"),
zap.String("sync", "gcs"),
),
Interval: interval,
Cron: cron.New(),
}
}
func (sb *SyncBuilder) newAzblob(config sync.SourceConfig, logger *logger.Logger) (*blobSync.Sync, error) {
// Required to generate the azblob service URL
storageAccountName := os.Getenv("AZURE_STORAGE_ACCOUNT")
if storageAccountName == "" {
return nil, fmt.Errorf("environment variable AZURE_STORAGE_ACCOUNT not set or is blank")
}
if regexp.MustCompile(`\s`).MatchString(storageAccountName) {
return nil, fmt.Errorf("environment variable AZURE_STORAGE_ACCOUNT contains whitespace")
}
// 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, "")
// Defaults to 5 seconds if interval is not set.
var interval uint32 = 5
if config.Interval != 0 {
interval = config.Interval
}
return &blobSync.Sync{
Bucket: bucketURI,
Object: objectName,
BlobURLMux: blob.DefaultURLMux(),
Logger: logger.WithFields(
zap.String("component", "sync"),
zap.String("sync", "azblob"),
),
Interval: interval,
Cron: cron.New(),
}, nil
}
func (sb *SyncBuilder) newS3(config sync.SourceConfig, logger *logger.Logger) *blobSync.Sync {
// 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, "")
// Defaults to 5 seconds if interval is not set.
var interval uint32 = 5
if config.Interval != 0 {
interval = config.Interval
}
return &blobSync.Sync{
Bucket: bucketURI,
Object: objectName,
BlobURLMux: blob.DefaultURLMux(),
Logger: logger.WithFields(
zap.String("component", "sync"),
zap.String("sync", "s3"),
),
Interval: interval,
Cron: cron.New(),
}
}

View File

@ -4,16 +4,15 @@ import (
"errors"
"testing"
"github.com/golang/mock/gomock"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/sync"
"github.com/open-feature/flagd/core/pkg/sync/blob"
buildermock "github.com/open-feature/flagd/core/pkg/sync/builder/mock"
"github.com/open-feature/flagd/core/pkg/sync/file"
"github.com/open-feature/flagd/core/pkg/sync/grpc"
"github.com/open-feature/flagd/core/pkg/sync/http"
"github.com/open-feature/flagd/core/pkg/sync/kubernetes"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
func TestSyncBuilder_SyncFromURI(t *testing.T) {
@ -38,7 +37,7 @@ func TestSyncBuilder_SyncFromURI(t *testing.T) {
ctrl := gomock.NewController(t)
mockClientBuilder := buildermock.NewMockIK8sClientBuilder(ctrl)
mockClientBuilder.EXPECT().GetK8sClient().Times(1).Return(nil, nil)
mockClientBuilder.EXPECT().GetK8sClients().Times(1).Return(nil, nil)
builder.k8sClientBuilder = mockClientBuilder
},
@ -55,7 +54,7 @@ func TestSyncBuilder_SyncFromURI(t *testing.T) {
ctrl := gomock.NewController(t)
mockClientBuilder := buildermock.NewMockIK8sClientBuilder(ctrl)
mockClientBuilder.EXPECT().GetK8sClient().Times(1).Return(nil, errors.New("oops"))
mockClientBuilder.EXPECT().GetK8sClients().Times(1).Return(nil, errors.New("oops"))
builder.k8sClientBuilder = mockClientBuilder
},
@ -174,35 +173,13 @@ func Test_SyncsFromFromConfig(t *testing.T) {
},
wantErr: false,
},
{
name: "grpc-with-msg-size",
args: args{
logger: lg,
sources: []sync.SourceConfig{
{
URI: "grpc://host:port",
Provider: syncProviderGrpc,
ProviderID: "myapp",
CertPath: "/tmp/ca.cert",
Selector: "source=database",
MaxMsgSize: 10,
},
},
},
wantSyncs: []sync.ISync{
&grpc.Sync{},
},
wantErr: false,
},
{
name: "combined",
injectFunc: func(builder *SyncBuilder) {
t.Setenv("AZURE_STORAGE_ACCOUNT", "myaccount")
ctrl := gomock.NewController(t)
mockClientBuilder := buildermock.NewMockIK8sClientBuilder(ctrl)
mockClientBuilder.EXPECT().GetK8sClient().Times(1).Return(nil, nil)
mockClientBuilder.EXPECT().GetK8sClients().Times(1).Return(nil, nil)
builder.k8sClientBuilder = mockClientBuilder
},
@ -234,18 +211,6 @@ func Test_SyncsFromFromConfig(t *testing.T) {
URI: "my-namespace/my-flags",
Provider: syncProviderKubernetes,
},
{
URI: "gs://bucket/path/to/file",
Provider: syncProviderGcs,
},
{
URI: "azblob://bucket/path/to/file",
Provider: syncProviderAzblob,
},
{
URI: "s3://bucket/path/to/file",
Provider: syncProviderS3,
},
},
},
wantSyncs: []sync.ISync{
@ -254,9 +219,6 @@ func Test_SyncsFromFromConfig(t *testing.T) {
&http.Sync{},
&file.Sync{},
&kubernetes.Sync{},
&blob.Sync{},
&blob.Sync{},
&blob.Sync{},
},
wantErr: false,
},
@ -282,198 +244,3 @@ func Test_SyncsFromFromConfig(t *testing.T) {
})
}
}
func Test_GcsConfig(t *testing.T) {
lg := logger.NewLogger(nil, false)
defaultInterval := uint32(5)
tests := []struct {
name string
uri string
interval uint32
expectedBucket string
expectedObject string
expectedInterval uint32
}{
{
name: "simple path",
uri: "gs://bucket/path/to/object",
interval: 10,
expectedBucket: "gs://bucket/",
expectedObject: "path/to/object",
expectedInterval: 10,
},
{
name: "default interval",
uri: "gs://bucket/path/to/object",
expectedBucket: "gs://bucket/",
expectedObject: "path/to/object",
expectedInterval: defaultInterval,
},
{
name: "no object set", // Blob syncer will return error when fetching
uri: "gs://bucket/",
expectedBucket: "gs://bucket/",
expectedObject: "",
expectedInterval: defaultInterval,
},
{
name: "malformed uri", // Blob syncer will return error when opening bucket
uri: "malformed",
expectedBucket: "",
expectedObject: "malformed",
expectedInterval: defaultInterval,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gcsSync := NewSyncBuilder().newGcs(sync.SourceConfig{
URI: tt.uri,
Interval: tt.interval,
}, lg)
require.Equal(t, tt.expectedBucket, gcsSync.Bucket)
require.Equal(t, tt.expectedObject, gcsSync.Object)
require.Equal(t, int(tt.expectedInterval), int(gcsSync.Interval))
})
}
}
func Test_AzblobConfig(t *testing.T) {
lg := logger.NewLogger(nil, false)
defaultInterval := uint32(5)
tests := []struct {
name string
uri string
interval uint32
storageAccount string
expectedBucket string
expectedObject string
expectedInterval uint32
wantErr bool
}{
{
name: "simple path",
uri: "azblob://bucket/path/to/object",
interval: 10,
storageAccount: "myaccount",
expectedBucket: "azblob://bucket/",
expectedObject: "path/to/object",
expectedInterval: 10,
wantErr: false,
},
{
name: "default interval",
uri: "azblob://bucket/path/to/object",
storageAccount: "myaccount",
expectedBucket: "azblob://bucket/",
expectedObject: "path/to/object",
expectedInterval: defaultInterval,
wantErr: false,
},
{
name: "no object set", // Blob syncer will return error when fetching
uri: "azblob://bucket/",
storageAccount: "myaccount",
expectedBucket: "azblob://bucket/",
expectedObject: "",
expectedInterval: defaultInterval,
wantErr: false,
},
{
name: "malformed uri", // Blob syncer will return error when opening bucket
uri: "malformed",
storageAccount: "myaccount",
expectedBucket: "",
expectedObject: "malformed",
expectedInterval: defaultInterval,
wantErr: false,
},
{
name: "storage account not set", // Sync builder will fail and return error
uri: "azblob://bucket/path/to/object",
storageAccount: "",
wantErr: true,
},
{
name: "storage account contains whitespace", // Sync builder will fail and return error
uri: "azblob://bucket/path/to/object",
storageAccount: "my account",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("AZURE_STORAGE_ACCOUNT", tt.storageAccount)
azblobSync, err := NewSyncBuilder().newAzblob(sync.SourceConfig{
URI: tt.uri,
Interval: tt.interval,
}, lg)
if (err != nil) != tt.wantErr {
t.Errorf("newAzblob() error = %v, wantErr %v", err, tt.wantErr)
return
}
if (err != nil) && (tt.wantErr == true) {
return
}
require.Equal(t, tt.expectedBucket, azblobSync.Bucket)
require.Equal(t, tt.expectedObject, azblobSync.Object)
require.Equal(t, int(tt.expectedInterval), int(azblobSync.Interval))
})
}
}
func Test_S3Config(t *testing.T) {
lg := logger.NewLogger(nil, false)
defaultInterval := uint32(5)
tests := []struct {
name string
uri string
interval uint32
expectedBucket string
expectedObject string
expectedInterval uint32
}{
{
name: "simple path",
uri: "s3://bucket/path/to/object",
interval: 10,
expectedBucket: "s3://bucket/",
expectedObject: "path/to/object",
expectedInterval: 10,
},
{
name: "default interval",
uri: "s3://bucket/path/to/object",
expectedBucket: "s3://bucket/",
expectedObject: "path/to/object",
expectedInterval: defaultInterval,
},
{
name: "no object set", // Blob syncer will return error when fetching
uri: "s3://bucket/",
expectedBucket: "s3://bucket/",
expectedObject: "",
expectedInterval: defaultInterval,
},
{
name: "malformed uri", // Blob syncer will return error when opening bucket
uri: "malformed",
expectedBucket: "",
expectedObject: "malformed",
expectedInterval: defaultInterval,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s3Sync := NewSyncBuilder().newS3(sync.SourceConfig{
URI: tt.uri,
Interval: tt.interval,
}, lg)
require.Equal(t, tt.expectedBucket, s3Sync.Bucket)
require.Equal(t, tt.expectedObject, s3Sync.Object)
require.Equal(t, int(tt.expectedInterval), int(s3Sync.Interval))
})
}
}

View File

@ -64,29 +64,9 @@ func ParseSyncProviderURIs(uris []string) ([]sync.SourceConfig, error) {
Provider: syncProviderGrpc,
TLS: true,
})
case regGRPCCustomResolver.Match(uriB):
syncProvidersParsed = append(syncProvidersParsed, sync.SourceConfig{
URI: uri,
Provider: syncProviderGrpc,
})
case regGcs.Match(uriB):
syncProvidersParsed = append(syncProvidersParsed, sync.SourceConfig{
URI: uri,
Provider: syncProviderGcs,
})
case regAzblob.Match(uriB):
syncProvidersParsed = append(syncProvidersParsed, sync.SourceConfig{
URI: uri,
Provider: syncProviderAzblob,
})
case regS3.Match(uriB):
syncProvidersParsed = append(syncProvidersParsed, sync.SourceConfig{
URI: uri,
Provider: syncProviderS3,
})
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)://', or 'core.openfeature.dev'", uri)
}
}
return syncProvidersParsed, nil

View File

@ -28,10 +28,7 @@ func TestParseSource(t *testing.T) {
{"uri":"config/samples/example_flags.json","provider":"file"},
{"uri":"http://test.com","provider":"http","bearerToken":":)"},
{"uri":"host:port","provider":"grpc"},
{"uri":"default/my-crd","provider":"kubernetes"},
{"uri":"gs://bucket-name/path/to/file","provider":"gcs"},
{"uri":"azblob://bucket-name/path/to/file","provider":"azblob"},
{"uri":"s3://bucket-name/path/to/file","provider":"s3"}
{"uri":"default/my-crd","provider":"kubernetes"}
]`,
expectErr: false,
out: []sync.SourceConfig{
@ -52,18 +49,6 @@ func TestParseSource(t *testing.T) {
URI: "default/my-crd",
Provider: syncProviderKubernetes,
},
{
URI: "gs://bucket-name/path/to/file",
Provider: syncProviderGcs,
},
{
URI: "azblob://bucket-name/path/to/file",
Provider: syncProviderAzblob,
},
{
URI: "s3://bucket-name/path/to/file",
Provider: syncProviderS3,
},
},
},
"multiple-syncs-with-options": {
@ -197,9 +182,6 @@ func TestParseSyncProviderURIs(t *testing.T) {
"grpc://host:port",
"grpcs://secure-grpc",
"core.openfeature.dev/default/my-crd",
"gs://bucket-name/path/to/file",
"azblob://bucket-name/path/to/file",
"s3://bucket-name/path/to/file",
},
expectErr: false,
out: []sync.SourceConfig{
@ -225,18 +207,6 @@ func TestParseSyncProviderURIs(t *testing.T) {
URI: "default/my-crd",
Provider: "kubernetes",
},
{
URI: "gs://bucket-name/path/to/file",
Provider: syncProviderGcs,
},
{
URI: "azblob://bucket-name/path/to/file",
Provider: syncProviderAzblob,
},
{
URI: "s3://bucket-name/path/to/file",
Provider: syncProviderS3,
},
},
},
"empty": {

View File

@ -1,202 +0,0 @@
package file
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/open-feature/flagd/core/pkg/logger"
)
// Implements file.Watcher using a timer and os.FileInfo
type fileInfoWatcher struct {
// Event Chan
evChan chan fsnotify.Event
// Errors Chan
erChan chan error
// logger
logger *logger.Logger
// Func to wrap os.Stat (injection point for test helpers)
statFunc func(string) (fs.FileInfo, error)
// thread-safe interface to underlying files we are watching
mu sync.RWMutex
watches map[string]fs.FileInfo // filename -> info
}
// NewFsNotifyWatcher returns a new fsNotifyWatcher
func NewFileInfoWatcher(ctx context.Context, logger *logger.Logger) Watcher {
fiw := &fileInfoWatcher{
evChan: make(chan fsnotify.Event, 32),
erChan: make(chan error, 32),
statFunc: getFileInfo,
logger: logger,
watches: make(map[string]fs.FileInfo),
}
fiw.run(ctx, (1 * time.Second))
return fiw
}
// fileInfoWatcher explicitly implements file.Watcher
var _ Watcher = &fileInfoWatcher{}
// Close calls close on the underlying fsnotify.Watcher
func (f *fileInfoWatcher) Close() error {
// close all channels and exit
close(f.evChan)
close(f.erChan)
return nil
}
// Add calls Add on the underlying fsnotify.Watcher
func (f *fileInfoWatcher) Add(name string) error {
f.mu.Lock()
defer f.mu.Unlock()
// exit early if name already exists
if _, ok := f.watches[name]; ok {
return nil
}
info, err := f.statFunc(name)
if err != nil {
return err
}
f.watches[name] = info
return nil
}
// Remove calls Remove on the underlying fsnotify.Watcher
func (f *fileInfoWatcher) Remove(name string) error {
f.mu.Lock()
defer f.mu.Unlock()
// no need to exit early, deleting non-existent key is a no-op
delete(f.watches, name)
return nil
}
// Watchlist calls watchlist on the underlying fsnotify.Watcher
func (f *fileInfoWatcher) WatchList() []string {
f.mu.RLock()
defer f.mu.RUnlock()
out := []string{}
for name := range f.watches {
n := name
out = append(out, n)
}
return out
}
// Events returns the underlying watcher's Events chan
func (f *fileInfoWatcher) Events() chan fsnotify.Event {
return f.evChan
}
// Errors returns the underlying watcher's Errors chan
func (f *fileInfoWatcher) Errors() chan error {
return f.erChan
}
// run is a blocking function that starts the filewatcher's timer thread
func (f *fileInfoWatcher) run(ctx context.Context, s time.Duration) {
// timer thread
go func() {
// execute update on the configured interval of time
ticker := time.NewTicker(s)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := f.update(); err != nil {
f.erChan <- err
return
}
}
}
}()
}
func (f *fileInfoWatcher) update() error {
f.mu.Lock()
defer f.mu.Unlock()
for path, info := range f.watches {
newInfo, err := f.statFunc(path)
if err != nil {
// if the file isn't there, it must have been removed
// fire off a remove event and remove it from the watches
if errors.Is(err, os.ErrNotExist) {
f.evChan <- fsnotify.Event{
Name: path,
Op: fsnotify.Remove,
}
delete(f.watches, path)
continue
}
return err
}
// if the new stat doesn't match the old stat, figure out what changed
if info != newInfo {
event := f.generateEvent(path, newInfo)
if event != nil {
f.evChan <- *event
}
f.watches[path] = newInfo
}
}
return nil
}
// generateEvent figures out what changed and generates an fsnotify.Event for it. (if we care)
// file removal are handled above in the update() method
func (f *fileInfoWatcher) generateEvent(path string, newInfo fs.FileInfo) *fsnotify.Event {
info := f.watches[path]
switch {
// new mod time is more recent than old mod time, generate a write event
case newInfo.ModTime().After(info.ModTime()):
return &fsnotify.Event{
Name: path,
Op: fsnotify.Write,
}
// the file modes changed, generate a chmod event
case info.Mode() != newInfo.Mode():
return &fsnotify.Event{
Name: path,
Op: fsnotify.Chmod,
}
// nothing changed that we care about
default:
return nil
}
}
// getFileInfo returns the fs.FileInfo for the given path
func getFileInfo(path string) (fs.FileInfo, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("error from os.Open(%s): %w", path, err)
}
info, err := f.Stat()
if err != nil {
return info, fmt.Errorf("error from fs.Stat(%s): %w", path, err)
}
if err := f.Close(); err != nil {
return info, fmt.Errorf("err from fs.Close(%s): %w", path, err)
}
return info, nil
}

View File

@ -1,248 +0,0 @@
package file
import (
"errors"
"fmt"
"io/fs"
"os"
"testing"
"time"
"github.com/fsnotify/fsnotify"
"github.com/google/go-cmp/cmp"
)
func Test_fileInfoWatcher_Close(t *testing.T) {
tests := []struct {
name string
watcher *fileInfoWatcher
wantErr bool
}{
{
name: "all chans close",
watcher: makeTestWatcher(t, map[string]fs.FileInfo{}),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.watcher.Close(); (err != nil) != tt.wantErr {
t.Errorf("fileInfoWatcher.Close() error = %v, wantErr %v", err, tt.wantErr)
}
if _, ok := (<-tt.watcher.Errors()); ok != false {
t.Error("fileInfoWatcher.Close() failed to close error chan")
}
if _, ok := (<-tt.watcher.Events()); ok != false {
t.Error("fileInfoWatcher.Close() failed to close events chan")
}
})
}
}
func Test_fileInfoWatcher_Add(t *testing.T) {
tests := []struct {
name string
watcher *fileInfoWatcher
add []string
want map[string]fs.FileInfo
wantErr bool
}{
{
name: "add one watch",
watcher: makeTestWatcher(t, map[string]fs.FileInfo{}),
add: []string{"/foo"},
want: map[string]fs.FileInfo{
"/foo": &mockFileInfo{},
},
},
}
for _, tt := range tests {
tt.watcher.statFunc = makeStatFunc(t, &mockFileInfo{})
t.Run(tt.name, func(t *testing.T) {
for _, path := range tt.add {
if err := tt.watcher.Add(path); (err != nil) != tt.wantErr {
t.Errorf("fileInfoWatcher.Add() error = %v, wantErr %v", err, tt.wantErr)
}
}
if !cmp.Equal(tt.watcher.watches, tt.want, cmp.AllowUnexported(mockFileInfo{})) {
t.Errorf("fileInfoWatcher.Add(): want-, got+: %v ", cmp.Diff(tt.want, tt.watcher.watches))
}
})
}
}
func Test_fileInfoWatcher_Remove(t *testing.T) {
tests := []struct {
name string
watcher *fileInfoWatcher
removeThis string
want []string
}{{
name: "remove foo",
watcher: makeTestWatcher(t, map[string]fs.FileInfo{"foo": &mockFileInfo{}, "bar": &mockFileInfo{}}),
removeThis: "foo",
want: []string{"bar"},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.watcher.Remove(tt.removeThis)
if err != nil {
t.Errorf("fileInfoWatcher.Remove() error = %v", err)
}
if !cmp.Equal(tt.watcher.WatchList(), tt.want) {
t.Errorf("fileInfoWatcher.Add(): want-, got+: %v ", cmp.Diff(tt.want, tt.watcher.WatchList()))
}
})
}
}
func Test_fileInfoWatcher_update(t *testing.T) {
tests := []struct {
name string
watcher *fileInfoWatcher
statFunc func(string) (fs.FileInfo, error)
wantErr bool
want *fsnotify.Event
}{
{
name: "chmod",
watcher: makeTestWatcher(t,
map[string]fs.FileInfo{
"foo": &mockFileInfo{
name: "foo",
mode: 0,
},
},
),
statFunc: func(_ string) (fs.FileInfo, error) {
return &mockFileInfo{
name: "foo",
mode: 1,
}, nil
},
want: &fsnotify.Event{Name: "foo", Op: fsnotify.Chmod},
},
{
name: "write",
watcher: makeTestWatcher(t,
map[string]fs.FileInfo{
"foo": &mockFileInfo{
name: "foo",
modTime: time.Now().Local(),
},
},
),
statFunc: func(_ string) (fs.FileInfo, error) {
return &mockFileInfo{
name: "foo",
modTime: (time.Now().Local().Add(5 * time.Minute)),
}, nil
},
want: &fsnotify.Event{Name: "foo", Op: fsnotify.Write},
},
{
name: "remove",
watcher: makeTestWatcher(t,
map[string]fs.FileInfo{
"foo": &mockFileInfo{
name: "foo",
},
},
),
statFunc: func(_ string) (fs.FileInfo, error) {
return nil, fmt.Errorf("mock file-no-existy error: %w", os.ErrNotExist)
},
want: &fsnotify.Event{Name: "foo", Op: fsnotify.Remove},
},
{
name: "unknown error",
watcher: makeTestWatcher(t,
map[string]fs.FileInfo{
"foo": &mockFileInfo{
name: "foo",
},
},
),
statFunc: func(_ string) (fs.FileInfo, error) {
return nil, errors.New("unhandled error")
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// set the statFunc
tt.watcher.statFunc = tt.statFunc
// run an update
// this also flexes fileinfowatcher.generateEvent()
err := tt.watcher.update()
if err != nil {
if tt.wantErr {
return
}
t.Errorf("fileInfoWatcher.update() unexpected error = %v, wantErr %v", err, tt.wantErr)
}
// slurp an event off the event chan
out := <-tt.watcher.Events()
if out != *tt.want {
t.Errorf("fileInfoWatcher.update() wanted %v, got %v", tt.want, out)
}
})
}
}
// Helpers
// makeTestWatcher returns a pointer to a fileInfoWatcher suitable for testing
func makeTestWatcher(t *testing.T, watches map[string]fs.FileInfo) *fileInfoWatcher {
t.Helper()
return &fileInfoWatcher{
evChan: make(chan fsnotify.Event, 512),
erChan: make(chan error, 512),
watches: watches,
}
}
// makeStateFunc returns an os.Stat wrapper that parrots back whatever its
// constructor is given
func makeStatFunc(t *testing.T, fi fs.FileInfo) func(string) (fs.FileInfo, error) {
t.Helper()
return func(_ string) (fs.FileInfo, error) {
return fi, nil
}
}
// mockFileInfo implements fs.FileInfo for mocks
type mockFileInfo struct {
name string // base name of the file
size int64 // length in bytes for regular files; system-dependent for others
mode fs.FileMode // file mode bits
modTime time.Time // modification time
}
// explicitly impements fs.FileInfo
var _ fs.FileInfo = &mockFileInfo{}
func (mfi *mockFileInfo) Name() string {
return mfi.name
}
func (mfi *mockFileInfo) Size() int64 {
return mfi.size
}
func (mfi *mockFileInfo) Mode() fs.FileMode {
return mfi.mode
}
func (mfi *mockFileInfo) ModTime() time.Time {
return mfi.modTime
}
func (mfi *mockFileInfo) IsDir() bool {
return false
}
func (mfi *mockFileInfo) Sys() any {
return "foo"
}

View File

@ -2,49 +2,34 @@ package file
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
msync "sync"
"github.com/fsnotify/fsnotify"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/sync"
"github.com/open-feature/flagd/core/pkg/utils"
"gopkg.in/yaml.v3"
)
const (
FSNOTIFY = "fsnotify"
FILEINFO = "fileinfo"
)
type Watcher interface {
Close() error
Add(name string) error
Remove(name string) error
WatchList() []string
Events() chan fsnotify.Event
Errors() chan error
}
type Sync struct {
URI string
Logger *logger.Logger
// watchType indicates how to watch the file FSNOTIFY|FILEINFO
watchType string
watcher Watcher
ready bool
Mux *msync.RWMutex
// FileType indicates the file type e.g., json, yaml/yml etc.,
fileType string
watcher *fsnotify.Watcher
ready bool
Mux *msync.RWMutex
}
func NewFileSync(uri string, watchType string, logger *logger.Logger) *Sync {
func NewFileSync(uri string, logger *logger.Logger) *Sync {
return &Sync{
URI: uri,
watchType: watchType,
Logger: logger,
Mux: &msync.RWMutex{},
URI: uri,
Logger: logger,
Mux: &msync.RWMutex{},
}
}
@ -52,28 +37,18 @@ func NewFileSync(uri string, watchType string, logger *logger.Logger) *Sync {
const defaultState = "{}"
func (fs *Sync) ReSync(ctx context.Context, dataSync chan<- sync.DataSync) error {
fs.sendDataSync(ctx, dataSync)
fs.sendDataSync(ctx, sync.ALL, dataSync)
return nil
}
func (fs *Sync) Init(ctx context.Context) error {
func (fs *Sync) Init(_ context.Context) error {
fs.Logger.Info("Starting filepath sync notifier")
switch fs.watchType {
case FSNOTIFY, "":
w, err := NewFSNotifyWatcher()
if err != nil {
return fmt.Errorf("error creating fsnotify watcher: %w", err)
}
fs.watcher = w
case FILEINFO:
w := NewFileInfoWatcher(ctx, fs.Logger)
fs.watcher = w
default:
return fmt.Errorf("unknown watcher type: '%s'", fs.watchType)
w, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("error creating filepath watcher: %w", err)
}
if err := fs.watcher.Add(fs.URI); err != nil {
fs.watcher = w
if err = fs.watcher.Add(fs.URI); err != nil {
return fmt.Errorf("error adding watcher %s: %w", fs.URI, err)
}
return nil
@ -94,12 +69,12 @@ 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.sendDataSync(ctx, dataSync)
fs.sendDataSync(ctx, sync.ALL, dataSync)
fs.setReady(true)
fs.Logger.Info(fmt.Sprintf("watching filepath: %s", fs.URI))
for {
select {
case event, ok := <-fs.watcher.Events():
case event, ok := <-fs.watcher.Events:
if !ok {
fs.Logger.Info("filepath notifier closed")
return errors.New("filepath notifier closed")
@ -108,7 +83,7 @@ func (fs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error {
fs.Logger.Info(fmt.Sprintf("filepath event: %s %s", event.Name, event.Op.String()))
switch {
case event.Has(fsnotify.Create) || event.Has(fsnotify.Write):
fs.sendDataSync(ctx, dataSync)
fs.sendDataSync(ctx, sync.ALL, dataSync)
case event.Has(fsnotify.Remove):
// K8s exposes config maps as symlinks.
// Updates cause a remove event, we need to re-add the watcher in this case.
@ -116,24 +91,24 @@ func (fs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error {
if err != nil {
// the watcher could not be re-added, so the file must have been deleted
fs.Logger.Error(fmt.Sprintf("error restoring watcher, file may have been deleted: %s", err.Error()))
fs.sendDataSync(ctx, dataSync)
fs.sendDataSync(ctx, sync.DELETE, dataSync)
continue
}
// Counterintuitively, remove events are the only meaningful ones seen in K8s.
// K8s handles mounted ConfigMap updates by modifying symbolic links, which is an atomic operation.
// At the point the remove event is fired, we have our new data, so we can send it down the channel.
fs.sendDataSync(ctx, dataSync)
fs.sendDataSync(ctx, sync.ALL, dataSync)
case event.Has(fsnotify.Chmod):
// on linux the REMOVE event will not fire until all file descriptors are closed, this cannot happen
// while the file is being watched, os.Stat is used here to infer deletion
if _, err := os.Stat(fs.URI); errors.Is(err, os.ErrNotExist) {
fs.Logger.Error(fmt.Sprintf("file has been deleted: %s", err.Error()))
fs.sendDataSync(ctx, dataSync)
fs.sendDataSync(ctx, sync.DELETE, dataSync)
}
}
case err, ok := <-fs.watcher.Errors():
case err, ok := <-fs.watcher.Errors:
if !ok {
fs.setReady(false)
return errors.New("watcher error")
@ -147,8 +122,14 @@ func (fs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error {
}
}
func (fs *Sync) sendDataSync(ctx context.Context, dataSync chan<- sync.DataSync) {
fs.Logger.Debug(fmt.Sprintf("Data sync received for %s", fs.URI))
func (fs *Sync) sendDataSync(ctx context.Context, syncType sync.Type, dataSync chan<- sync.DataSync) {
fs.Logger.Debug(fmt.Sprintf("Configuration %s: %s", fs.URI, syncType.String()))
if syncType == sync.DELETE {
// Skip fetching and emit default state to avoid EOF errors
dataSync <- sync.DataSync{FlagData: defaultState, Source: fs.URI, Type: syncType}
return
}
msg := defaultState
m, err := fs.fetch(ctx)
@ -161,29 +142,45 @@ func (fs *Sync) sendDataSync(ctx context.Context, dataSync chan<- sync.DataSync)
msg = m
}
dataSync <- sync.DataSync{FlagData: msg, Source: fs.URI}
dataSync <- sync.DataSync{FlagData: msg, Source: fs.URI, Type: syncType}
}
func (fs *Sync) fetch(_ context.Context) (string, error) {
if fs.URI == "" {
return "", errors.New("no filepath string set")
}
file, err := os.Open(fs.URI)
if err != nil {
return "", fmt.Errorf("error opening file %s: %w", fs.URI, err)
if fs.fileType == "" {
uriSplit := strings.Split(fs.URI, ".")
fs.fileType = uriSplit[len(uriSplit)-1]
}
defer file.Close()
data, err := io.ReadAll(file)
rawFile, err := os.ReadFile(fs.URI)
if err != nil {
return "", fmt.Errorf("error reading file %s: %w", fs.URI, err)
}
// File extension is used to determine the content type, so media type is unnecessary
json, err := utils.ConvertToJSON(data, filepath.Ext(fs.URI), "")
if err != nil {
return "", fmt.Errorf("error converting file content to json: %w", err)
switch fs.fileType {
case "yaml", "yml":
return yamlToJSON(rawFile)
case "json":
return string(rawFile), nil
default:
return "", fmt.Errorf("filepath extension for URI: '%s' is not supported", fs.URI)
}
return json, nil
}
// yamlToJSON is a generic helper function to convert
// yaml to json
func yamlToJSON(rawFile []byte) (string, error) {
var ms map[string]interface{}
// yaml.Unmarshal unmarshals to map[interface]interface{}
if err := yaml.Unmarshal(rawFile, &ms); err != nil {
return "", fmt.Errorf("unmarshal yaml: %w", err)
}
r, err := json.Marshal(ms)
if err != nil {
return "", fmt.Errorf("convert yaml to json: %w", err)
}
return string(r), err
}

View File

@ -26,6 +26,7 @@ func TestSimpleReSync(t *testing.T) {
expectedDataSync := sync.DataSync{
FlagData: "hello",
Source: source,
Type: sync.ALL,
}
handler := Sync{
URI: source,
@ -75,6 +76,7 @@ func TestSimpleSync(t *testing.T) {
{
FlagData: fetchFileContents,
Source: fmt.Sprintf("%s/%s", readDirName, fetchFileName),
Type: sync.ALL,
},
},
},
@ -92,10 +94,12 @@ func TestSimpleSync(t *testing.T) {
{
FlagData: fetchFileContents,
Source: fmt.Sprintf("%s/%s", updateDirName, fetchFileName),
Type: sync.ALL,
},
{
FlagData: "new content",
Source: fmt.Sprintf("%s/%s", updateDirName, fetchFileName),
Type: sync.ALL,
},
},
},
@ -113,10 +117,12 @@ func TestSimpleSync(t *testing.T) {
{
FlagData: fetchFileContents,
Source: fmt.Sprintf("%s/%s", deleteDirName, fetchFileName),
Type: sync.ALL,
},
{
FlagData: defaultState,
Source: fmt.Sprintf("%s/%s", deleteDirName, fetchFileName),
Type: sync.DELETE,
},
},
},
@ -166,6 +172,9 @@ func TestSimpleSync(t *testing.T) {
if data.Source != syncEvent.Source {
t.Errorf("expected source: %s, but received source: %s", syncEvent.Source, data.Source)
}
if data.Type != syncEvent.Type {
t.Errorf("expected type: %b, but received type: %b", syncEvent.Type, data.Type)
}
case <-time.After(10 * time.Second):
t.Errorf("event not found, timeout out after 10 seconds")
}
@ -181,7 +190,7 @@ func TestSimpleSync(t *testing.T) {
func TestFilePathSync_Fetch(t *testing.T) {
successDirName := t.TempDir()
failureDirName := t.TempDir()
falureDirName := t.TempDir()
tests := map[string]struct {
fpSync Sync
handleResponse func(t *testing.T, fetched string, err error)
@ -204,9 +213,9 @@ func TestFilePathSync_Fetch(t *testing.T) {
},
},
"not found": {
fetchDirName: failureDirName,
fetchDirName: falureDirName,
fpSync: Sync{
URI: fmt.Sprintf("%s/%s", failureDirName, "not_found"),
URI: fmt.Sprintf("%s/%s", falureDirName, "not_found"),
Logger: logger.NewLogger(nil, false),
},
handleResponse: func(t *testing.T, fetched string, err error) {

View File

@ -1,67 +0,0 @@
package file
import (
"fmt"
"github.com/fsnotify/fsnotify"
)
// Implements file.Watcher by wrapping fsnotify.Watcher
// This is only necessary because fsnotify.Watcher directly exposes its Errors
// and Events channels rather than returning them by method invocation
type fsNotifyWatcher struct {
watcher *fsnotify.Watcher
}
// NewFsNotifyWatcher returns a new fsNotifyWatcher
func NewFSNotifyWatcher() (Watcher, error) {
fsn, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("fsnotify: %w", err)
}
return &fsNotifyWatcher{
watcher: fsn,
}, nil
}
// explicitly implements file.Watcher
var _ Watcher = &fsNotifyWatcher{}
// Close calls close on the underlying fsnotify.Watcher
func (f *fsNotifyWatcher) Close() error {
if err := f.watcher.Close(); err != nil {
return fmt.Errorf("fsnotify: %w", err)
}
return nil
}
// Add calls Add on the underlying fsnotify.Watcher
func (f *fsNotifyWatcher) Add(name string) error {
if err := f.watcher.Add(name); err != nil {
return fmt.Errorf("fsnotify: %w", err)
}
return nil
}
// Remove calls Remove on the underlying fsnotify.Watcher
func (f *fsNotifyWatcher) Remove(name string) error {
if err := f.watcher.Remove(name); err != nil {
return fmt.Errorf("fsnotify: %w", err)
}
return nil
}
// Watchlist calls watchlist on the underlying fsnotify.Watcher
func (f *fsNotifyWatcher) WatchList() []string {
return f.watcher.WatchList()
}
// Events returns the underlying watcher's Events chan
func (f *fsNotifyWatcher) Events() chan fsnotify.Event {
return f.watcher.Events
}
// Errors returns the underlying watcher's Errors chan
func (f *fsNotifyWatcher) Errors() chan error {
return f.watcher.Errors
}

View File

@ -1,10 +1,5 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: pkg/sync/grpc/credentials/builder.go
//
// Generated by this command:
//
// mockgen -source=pkg/sync/grpc/credentials/builder.go -destination=pkg/sync/grpc/credentials/mock/builder.go -package=credendialsmock
//
// Package credendialsmock is a generated GoMock package.
package credendialsmock
@ -12,7 +7,7 @@ package credendialsmock
import (
reflect "reflect"
gomock "go.uber.org/mock/gomock"
gomock "github.com/golang/mock/gomock"
credentials "google.golang.org/grpc/credentials"
)
@ -49,7 +44,7 @@ func (m *MockBuilder) Build(secure bool, certPath string) (credentials.Transport
}
// Build indicates an expected call of Build.
func (mr *MockBuilderMockRecorder) Build(secure, certPath any) *gomock.Call {
func (mr *MockBuilderMockRecorder) Build(secure, certPath interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Build", reflect.TypeOf((*MockBuilder)(nil).Build), secure, certPath)
}

View File

@ -7,22 +7,19 @@ import (
msync "sync"
"time"
"buf.build/gen/go/open-feature/flagd/grpc/go/flagd/sync/v1/syncv1grpc"
v1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/sync/v1"
"buf.build/gen/go/open-feature/flagd/grpc/go/sync/v1/syncv1grpc"
v1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/sync/v1"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/sync"
grpccredential "github.com/open-feature/flagd/core/pkg/sync/grpc/credentials"
_ "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"
)
const (
// Prefix for GRPC URL inputs. GRPC does not define a standard prefix. This prefix helps to differentiate remote
// URLs for REST APIs (i.e - HTTP) from GRPC endpoints.
Prefix = "grpc://"
PrefixSecure = "grpcs://"
SupportedScheme = "(envoy|dns|uds|xds)"
Prefix = "grpc://"
PrefixSecure = "grpcs://"
// Connection retry constants
// Back off period is calculated with backOffBase ^ #retry-iteration. However, when #retry-iteration count reach
@ -44,46 +41,28 @@ type FlagSyncServiceClientResponse interface {
var once msync.Once
type Sync struct {
GrpcDialOptionsOverride []grpc.DialOption
CertPath string
CredentialBuilder grpccredential.Builder
Logger *logger.Logger
ProviderID string
Secure bool
Selector string
URI string
MaxMsgSize int
CertPath string
CredentialBuilder grpccredential.Builder
Logger *logger.Logger
ProviderID string
Secure bool
Selector string
URI string
client FlagSyncServiceClient
ready bool
}
func (g *Sync) Init(_ context.Context) error {
var rpcCon *grpc.ClientConn // Reusable client connection
var err error
if len(g.GrpcDialOptionsOverride) > 0 {
g.Logger.Debug("GRPC DialOptions override provided")
rpcCon, err = grpc.NewClient(g.URI, g.GrpcDialOptionsOverride...)
} else {
var tCredentials credentials.TransportCredentials
tCredentials, err = g.CredentialBuilder.Build(g.Secure, g.CertPath)
if err != nil {
err = fmt.Errorf("error building transport credentials: %w", err)
g.Logger.Error(err.Error())
return err
}
// Set MaxMsgSize if passed
if g.MaxMsgSize > 0 {
g.Logger.Info(fmt.Sprintf("setting max receive message size %d bytes default 4MB", g.MaxMsgSize))
dialOptions := grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(g.MaxMsgSize))
rpcCon, err = grpc.NewClient(g.URI, grpc.WithTransportCredentials(tCredentials), dialOptions)
} else {
rpcCon, err = grpc.NewClient(g.URI, grpc.WithTransportCredentials(tCredentials))
}
func (g *Sync) Init(ctx context.Context) error {
tCredentials, err := g.CredentialBuilder.Build(g.Secure, g.CertPath)
if err != nil {
err := fmt.Errorf("error building transport credentials: %w", err)
g.Logger.Error(err.Error())
return err
}
// Derive reusable client connection
rpcCon, err := grpc.DialContext(ctx, g.URI, grpc.WithTransportCredentials(tCredentials))
if err != nil {
err := fmt.Errorf("error initiating grpc client connection: %w", err)
g.Logger.Error(err.Error())
@ -97,7 +76,7 @@ func (g *Sync) Init(_ context.Context) error {
}
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(ctx, &v1.FetchAllFlagsRequest{})
if err != nil {
err = fmt.Errorf("error fetching all flags: %w", err)
g.Logger.Error(err.Error())
@ -106,6 +85,7 @@ func (g *Sync) ReSync(ctx context.Context, dataSync chan<- sync.DataSync) error
dataSync <- sync.DataSync{
FlagData: res.GetFlagConfiguration(),
Source: g.URI,
Type: sync.ALL,
}
return nil
}
@ -188,6 +168,7 @@ func (g *Sync) connectWithRetry(
// handleFlagSync wraps the stream listening and push updates through dataSync channel
func (g *Sync) handleFlagSync(stream syncv1grpc.FlagSyncService_SyncFlagsClient, dataSync chan<- sync.DataSync) error {
// Set ready state once only
once.Do(func() {
g.ready = true
})
@ -198,13 +179,45 @@ func (g *Sync) handleFlagSync(stream syncv1grpc.FlagSyncService_SyncFlagsClient,
return fmt.Errorf("error receiving payload from stream: %w", err)
}
dataSync <- sync.DataSync{
FlagData: data.FlagConfiguration,
SyncContext: data.SyncContext,
Source: g.URI,
Selector: g.Selector,
}
switch data.State {
case v1.SyncState_SYNC_STATE_ALL:
dataSync <- sync.DataSync{
FlagData: data.FlagConfiguration,
Source: g.URI,
Type: sync.ALL,
}
g.Logger.Debug("received full configuration payload")
g.Logger.Debug("received full configuration payload")
case v1.SyncState_SYNC_STATE_ADD:
dataSync <- sync.DataSync{
FlagData: data.FlagConfiguration,
Source: g.URI,
Type: sync.ADD,
}
g.Logger.Debug("received an add payload")
case v1.SyncState_SYNC_STATE_UPDATE:
dataSync <- sync.DataSync{
FlagData: data.FlagConfiguration,
Source: g.URI,
Type: sync.UPDATE,
}
g.Logger.Debug("received an update payload")
case v1.SyncState_SYNC_STATE_DELETE:
dataSync <- sync.DataSync{
FlagData: data.FlagConfiguration,
Source: g.URI,
Type: sync.DELETE,
}
g.Logger.Debug("received a delete payload")
case v1.SyncState_SYNC_STATE_PING:
g.Logger.Debug("received server ping")
case v1.SyncState_SYNC_STATE_UNSPECIFIED:
g.Logger.Debug("received unspecified state")
default:
g.Logger.Debug(fmt.Sprintf("received unknown state: %s", data.State.String()))
}
}
}

View File

@ -11,22 +11,19 @@ import (
"testing"
"time"
"buf.build/gen/go/open-feature/flagd/grpc/go/flagd/sync/v1/syncv1grpc"
v1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/sync/v1"
"buf.build/gen/go/open-feature/flagd/grpc/go/sync/v1/syncv1grpc"
v1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/sync/v1"
"github.com/golang/mock/gomock"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/sync"
credendialsmock "github.com/open-feature/flagd/core/pkg/sync/grpc/credentials/mock"
grpcmock "github.com/open-feature/flagd/core/pkg/sync/grpc/mock"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
)
func Test_InitWithMockCredentialBuilder(t *testing.T) {
@ -81,30 +78,6 @@ func Test_InitWithMockCredentialBuilder(t *testing.T) {
}
}
func Test_InitWithSizeOverride(t *testing.T) {
observedZapCore, observedLogs := observer.New(zap.InfoLevel)
observedLogger := zap.New(observedZapCore)
mockCtrl := gomock.NewController(t)
mockCredentialBulder := credendialsmock.NewMockBuilder(mockCtrl)
mockCredentialBulder.EXPECT().
Build(gomock.Any(), gomock.Any()).
Return(insecure.NewCredentials(), nil)
grpcSync := Sync{
URI: "grpc-target",
Logger: logger.NewLogger(observedLogger, false),
CredentialBuilder: mockCredentialBulder,
MaxMsgSize: 10,
}
err := grpcSync.Init(context.Background())
require.Nilf(t, err, "%s: expected no error, but got non nil error", t.Name())
require.Equal(t, "setting max receive message size 10 bytes default 4MB", observedLogs.All()[0].Message)
}
func Test_ReSyncTests(t *testing.T) {
const target = "localBufCon"
@ -123,6 +96,7 @@ func Test_ReSyncTests(t *testing.T) {
notifications: []sync.DataSync{
{
FlagData: "success",
Type: sync.ALL,
},
},
shouldError: false,
@ -179,6 +153,9 @@ func Test_ReSyncTests(t *testing.T) {
for _, expected := range test.notifications {
out := <-syncChan
if expected.Type != out.Type {
t.Errorf("Returned sync type = %v, wanted %v", out.Type, expected.Type)
}
if expected.FlagData != out.FlagData {
t.Errorf("Returned sync data = %v, wanted %v", out.FlagData, expected.FlagData)
@ -192,14 +169,159 @@ func Test_ReSyncTests(t *testing.T) {
}
}
func TestSync_BasicFlagSyncStates(t *testing.T) {
grpcSyncImpl := Sync{
URI: "grpc://test",
ProviderID: "",
Logger: logger.NewLogger(nil, false),
}
mockError := errors.New("could not sync")
tests := []struct {
name string
stream syncv1grpc.FlagSyncService_SyncFlagsClient
setup func(t *testing.T, client *grpcmock.MockFlagSyncServiceClient, clientResponse *grpcmock.MockFlagSyncServiceClientResponse)
want sync.Type
wantError error
ready bool
}{
{
name: "State All maps to Sync All",
setup: func(t *testing.T, client *grpcmock.MockFlagSyncServiceClient, clientResponse *grpcmock.MockFlagSyncServiceClientResponse) {
client.EXPECT().SyncFlags(gomock.Any(), gomock.Any(), gomock.Any()).Return(clientResponse, nil)
gomock.InOrder(
clientResponse.EXPECT().Recv().Return(
&v1.SyncFlagsResponse{
FlagConfiguration: "{}",
State: v1.SyncState_SYNC_STATE_ALL,
},
nil,
),
clientResponse.EXPECT().Recv().Return(
nil, io.EOF,
),
)
},
want: sync.ALL,
ready: true,
},
{
name: "State Add maps to Sync Add",
setup: func(t *testing.T, client *grpcmock.MockFlagSyncServiceClient, clientResponse *grpcmock.MockFlagSyncServiceClientResponse) {
client.EXPECT().SyncFlags(gomock.Any(), gomock.Any(), gomock.Any()).Return(clientResponse, nil)
gomock.InOrder(
clientResponse.EXPECT().Recv().Return(
&v1.SyncFlagsResponse{
FlagConfiguration: "{}",
State: v1.SyncState_SYNC_STATE_ADD,
},
nil,
),
clientResponse.EXPECT().Recv().Return(
nil, io.EOF,
),
)
},
want: sync.ADD,
ready: true,
},
{
name: "State Update maps to Sync Update",
setup: func(t *testing.T, client *grpcmock.MockFlagSyncServiceClient, clientResponse *grpcmock.MockFlagSyncServiceClientResponse) {
client.EXPECT().SyncFlags(gomock.Any(), gomock.Any(), gomock.Any()).Return(clientResponse, nil)
gomock.InOrder(
clientResponse.EXPECT().Recv().Return(
&v1.SyncFlagsResponse{
FlagConfiguration: "{}",
State: v1.SyncState_SYNC_STATE_UPDATE,
},
nil,
),
clientResponse.EXPECT().Recv().Return(
nil, io.EOF,
),
)
},
want: sync.UPDATE,
ready: true,
},
{
name: "State Delete maps to Sync Delete",
setup: func(t *testing.T, client *grpcmock.MockFlagSyncServiceClient, clientResponse *grpcmock.MockFlagSyncServiceClientResponse) {
client.EXPECT().SyncFlags(gomock.Any(), gomock.Any(), gomock.Any()).Return(clientResponse, nil)
gomock.InOrder(
clientResponse.EXPECT().Recv().Return(
&v1.SyncFlagsResponse{
FlagConfiguration: "{}",
State: v1.SyncState_SYNC_STATE_DELETE,
},
nil,
),
clientResponse.EXPECT().Recv().Return(
nil, io.EOF,
),
)
},
want: sync.DELETE,
ready: true,
},
{
name: "Error during flag sync",
setup: func(t *testing.T, client *grpcmock.MockFlagSyncServiceClient, clientResponse *grpcmock.MockFlagSyncServiceClientResponse) {
client.EXPECT().SyncFlags(gomock.Any(), gomock.Any(), gomock.Any()).Return(clientResponse, nil)
clientResponse.EXPECT().Recv().Return(
nil,
mockError,
)
},
ready: true,
want: -1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
syncChan := make(chan sync.DataSync, 1)
mockClient := grpcmock.NewMockFlagSyncServiceClient(ctrl)
mockClientResponse := grpcmock.NewMockFlagSyncServiceClientResponse(ctrl)
test.setup(t, mockClient, mockClientResponse)
waitChan := make(chan struct{})
go func() {
grpcSyncImpl.client = mockClient
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
err := grpcSyncImpl.Sync(ctx, syncChan)
if err != nil {
t.Errorf("Error handling flag sync: %v", err)
}
close(waitChan)
}()
<-waitChan
if test.want < 0 {
require.Empty(t, syncChan)
return
}
data := <-syncChan
if grpcSyncImpl.IsReady() != test.ready {
t.Errorf("expected grpcSyncImpl.ready to be: '%v', got: '%v'", test.ready, grpcSyncImpl.ready)
}
if data.Type != test.want {
t.Errorf("Returned data sync state = %v, wanted %v", data.Type, test.want)
}
})
}
}
func Test_StreamListener(t *testing.T) {
const target = "localBufCon"
metadata, err := structpb.NewStruct(map[string]any{"sources": "A,B,C"})
if err != nil {
t.Fatalf("Failed to create sync context: %v", err)
}
tests := []struct {
name string
input []serverPayload
@ -210,12 +332,13 @@ func Test_StreamListener(t *testing.T) {
input: []serverPayload{
{
flags: "{\"flags\": {}}",
state: v1.SyncState_SYNC_STATE_ALL,
},
},
output: []sync.DataSync{
{
FlagData: "{\"flags\": {}}",
SyncContext: metadata,
FlagData: "{\"flags\": {}}",
Type: sync.ALL,
},
},
},
@ -224,19 +347,67 @@ func Test_StreamListener(t *testing.T) {
input: []serverPayload{
{
flags: "{}",
state: v1.SyncState_SYNC_STATE_ALL,
},
{
flags: "{\"flags\": {}}",
state: v1.SyncState_SYNC_STATE_DELETE,
},
},
output: []sync.DataSync{
{
FlagData: "{}",
SyncContext: metadata,
FlagData: "{}",
Type: sync.ALL,
},
{
FlagData: "{\"flags\": {}}",
SyncContext: metadata,
FlagData: "{\"flags\": {}}",
Type: sync.DELETE,
},
},
},
{
name: "Pings are ignored & not written to channel",
input: []serverPayload{
{
flags: "",
state: v1.SyncState_SYNC_STATE_PING,
},
{
flags: "",
state: v1.SyncState_SYNC_STATE_PING,
},
{
flags: "{\"flags\": {}}",
state: v1.SyncState_SYNC_STATE_DELETE,
},
},
output: []sync.DataSync{
{
FlagData: "{\"flags\": {}}",
Type: sync.DELETE,
},
},
},
{
name: "Unknown states are & not written to channel",
input: []serverPayload{
{
flags: "",
state: 42,
},
{
flags: "",
state: -1,
},
{
flags: "{\"flags\": {}}",
state: v1.SyncState_SYNC_STATE_ALL,
},
},
output: []sync.DataSync{
{
FlagData: "{\"flags\": {}}",
Type: sync.ALL,
},
},
},
@ -289,12 +460,12 @@ func Test_StreamListener(t *testing.T) {
for _, expected := range test.output {
out := <-syncChan
if expected.FlagData != out.FlagData {
t.Errorf("Returned sync data = %v, wanted %v", out.FlagData, expected.FlagData)
if expected.Type != out.Type {
t.Errorf("Returned sync type = %v, wanted %v", out.Type, expected.Type)
}
if !proto.Equal(expected.SyncContext, out.SyncContext) {
t.Errorf("Returned sync context = %v, wanted = %v", out.SyncContext, expected.SyncContext)
if expected.FlagData != out.FlagData {
t.Errorf("Returned sync data = %v, wanted %v", out.FlagData, expected.FlagData)
}
}
@ -379,12 +550,14 @@ func Test_SyncRetry(t *testing.T) {
// Setup
target := "grpc://local"
bufListener := bufconn.Listen(1)
emptyFlagData := "{}"
expectType := sync.ALL
// buffer based server. response ignored purposefully
bServer := bufferedServer{listener: bufListener, mockResponses: []serverPayload{
{
flags: "{}",
state: v1.SyncState_SYNC_STATE_ALL,
},
}}
@ -433,7 +606,7 @@ func Test_SyncRetry(t *testing.T) {
t.Errorf("timeout waiting for conditions to fulfil")
break
case data := <-syncChan:
if data.FlagData != emptyFlagData {
if data.Type != expectType {
t.Errorf("sync start error: %s", err.Error())
}
}
@ -453,9 +626,9 @@ func Test_SyncRetry(t *testing.T) {
case <-tCtx.Done():
cancelFunc()
t.Error("timeout waiting for conditions to fulfil")
case data := <-syncChan:
if data.FlagData != emptyFlagData {
t.Errorf("sync start error: %s", err.Error())
case rsp := <-syncChan:
if rsp.Type != expectType {
t.Errorf("expected response: %s, but got: %s", expectType, rsp.Type)
}
}
}
@ -475,6 +648,7 @@ func serve(bServer *bufferedServer) {
type serverPayload struct {
flags string
state v1.SyncState
}
// bufferedServer - a mock grpc service backed by buffered connection
@ -487,10 +661,9 @@ type bufferedServer struct {
func (b *bufferedServer) SyncFlags(_ *v1.SyncFlagsRequest, stream syncv1grpc.FlagSyncService_SyncFlagsServer) error {
for _, response := range b.mockResponses {
metadata, _ := structpb.NewStruct(map[string]any{"sources": "A,B,C"})
err := stream.Send(&v1.SyncFlagsResponse{
FlagConfiguration: response.flags,
SyncContext: metadata,
State: response.state,
})
if err != nil {
fmt.Printf("Error with stream: %s", err.Error())
@ -504,7 +677,3 @@ func (b *bufferedServer) SyncFlags(_ *v1.SyncFlagsRequest, stream syncv1grpc.Fla
func (b *bufferedServer) FetchAllFlags(_ context.Context, _ *v1.FetchAllFlagsRequest) (*v1.FetchAllFlagsResponse, error) {
return b.fetchAllFlagsResponse, b.fetchAllFlagsError
}
func (b *bufferedServer) GetMetadata(_ context.Context, _ *v1.GetMetadataRequest) (*v1.GetMetadataResponse, error) {
return &v1.GetMetadataResponse{}, nil
}

View File

@ -1,10 +1,5 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: pkg/sync/grpc/grpc_sync.go
//
// Generated by this command:
//
// mockgen -source=pkg/sync/grpc/grpc_sync.go -destination=pkg/sync/grpc/mock/grpc.go -package=grpcmock
//
// Package grpcmock is a generated GoMock package.
package grpcmock
@ -13,9 +8,9 @@ import (
context "context"
reflect "reflect"
syncv1grpc "buf.build/gen/go/open-feature/flagd/grpc/go/flagd/sync/v1/syncv1grpc"
syncv1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/sync/v1"
gomock "go.uber.org/mock/gomock"
syncv1grpc "buf.build/gen/go/open-feature/flagd/grpc/go/sync/v1/syncv1grpc"
syncv1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/sync/v1"
gomock "github.com/golang/mock/gomock"
grpc "google.golang.org/grpc"
metadata "google.golang.org/grpc/metadata"
)
@ -46,7 +41,7 @@ func (m *MockFlagSyncServiceClient) EXPECT() *MockFlagSyncServiceClientMockRecor
// FetchAllFlags mocks base method.
func (m *MockFlagSyncServiceClient) FetchAllFlags(ctx context.Context, in *syncv1.FetchAllFlagsRequest, opts ...grpc.CallOption) (*syncv1.FetchAllFlagsResponse, error) {
m.ctrl.T.Helper()
varargs := []any{ctx, in}
varargs := []interface{}{ctx, in}
for _, a := range opts {
varargs = append(varargs, a)
}
@ -57,36 +52,16 @@ func (m *MockFlagSyncServiceClient) FetchAllFlags(ctx context.Context, in *syncv
}
// FetchAllFlags indicates an expected call of FetchAllFlags.
func (mr *MockFlagSyncServiceClientMockRecorder) FetchAllFlags(ctx, in any, opts ...any) *gomock.Call {
func (mr *MockFlagSyncServiceClientMockRecorder) FetchAllFlags(ctx, in interface{}, opts ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx, in}, opts...)
varargs := append([]interface{}{ctx, in}, opts...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchAllFlags", reflect.TypeOf((*MockFlagSyncServiceClient)(nil).FetchAllFlags), varargs...)
}
// GetMetadata mocks base method.
func (m *MockFlagSyncServiceClient) GetMetadata(ctx context.Context, in *syncv1.GetMetadataRequest, opts ...grpc.CallOption) (*syncv1.GetMetadataResponse, error) {
m.ctrl.T.Helper()
varargs := []any{ctx, in}
for _, a := range opts {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "GetMetadata", varargs...)
ret0, _ := ret[0].(*syncv1.GetMetadataResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetMetadata indicates an expected call of GetMetadata.
func (mr *MockFlagSyncServiceClientMockRecorder) GetMetadata(ctx, in any, opts ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx, in}, opts...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMetadata", reflect.TypeOf((*MockFlagSyncServiceClient)(nil).GetMetadata), varargs...)
}
// SyncFlags mocks base method.
func (m *MockFlagSyncServiceClient) SyncFlags(ctx context.Context, in *syncv1.SyncFlagsRequest, opts ...grpc.CallOption) (syncv1grpc.FlagSyncService_SyncFlagsClient, error) {
m.ctrl.T.Helper()
varargs := []any{ctx, in}
varargs := []interface{}{ctx, in}
for _, a := range opts {
varargs = append(varargs, a)
}
@ -97,9 +72,9 @@ func (m *MockFlagSyncServiceClient) SyncFlags(ctx context.Context, in *syncv1.Sy
}
// SyncFlags indicates an expected call of SyncFlags.
func (mr *MockFlagSyncServiceClientMockRecorder) SyncFlags(ctx, in any, opts ...any) *gomock.Call {
func (mr *MockFlagSyncServiceClientMockRecorder) SyncFlags(ctx, in interface{}, opts ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx, in}, opts...)
varargs := append([]interface{}{ctx, in}, opts...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncFlags", reflect.TypeOf((*MockFlagSyncServiceClient)(nil).SyncFlags), varargs...)
}
@ -193,7 +168,7 @@ func (m_2 *MockFlagSyncServiceClientResponse) RecvMsg(m any) error {
}
// RecvMsg indicates an expected call of RecvMsg.
func (mr *MockFlagSyncServiceClientResponseMockRecorder) RecvMsg(m any) *gomock.Call {
func (mr *MockFlagSyncServiceClientResponseMockRecorder) RecvMsg(m interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecvMsg", reflect.TypeOf((*MockFlagSyncServiceClientResponse)(nil).RecvMsg), m)
}
@ -207,7 +182,7 @@ func (m_2 *MockFlagSyncServiceClientResponse) SendMsg(m any) error {
}
// SendMsg indicates an expected call of SendMsg.
func (mr *MockFlagSyncServiceClientResponseMockRecorder) SendMsg(m any) *gomock.Call {
func (mr *MockFlagSyncServiceClientResponseMockRecorder) SendMsg(m interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMsg", reflect.TypeOf((*MockFlagSyncServiceClientResponse)(nil).SendMsg), m)
}

View File

@ -1,84 +0,0 @@
package nameresolvers
import (
"fmt"
"strings"
"google.golang.org/grpc/resolver"
)
const scheme = "envoy"
type envoyBuilder struct{}
// Build A custom NameResolver to resolve gRPC target uri for envoy in the
// format of.
//
// Custom URI Scheme:
//
// envoy://[proxy-agent-host]:[proxy-agent-port]/[service-name]
func (*envoyBuilder) Build(target resolver.Target,
cc resolver.ClientConn, _ resolver.BuildOptions,
) (resolver.Resolver, error) {
_, err := isValidTarget(target)
if err != nil {
return nil, err
}
r := &envoyResolver{
target: target,
cc: cc,
}
r.start()
return r, nil
}
func (*envoyBuilder) Scheme() string {
return scheme
}
type envoyResolver struct {
target resolver.Target
cc resolver.ClientConn
}
// Envoy NameResolver, will always override the authority with the specified authority i.e. URL.path and
// use the socketAddress i.e. Host:Port to connect.
func (r *envoyResolver) start() {
addr := fmt.Sprintf("%s:%s", r.target.URL.Hostname(), r.target.URL.Port())
err := r.cc.UpdateState(resolver.State{Addresses: []resolver.Address{{Addr: addr}}})
if err != nil {
return
}
}
func (*envoyResolver) ResolveNow(resolver.ResolveNowOptions) {}
func (*envoyResolver) Close() {}
// Validate user specified target
//
// Sample target string: envoy://localhost:9211/test.service
//
// return `true` if the target string used match the scheme and format
func isValidTarget(target resolver.Target) (bool, error) {
// make sure and host and port not empty
// used as resolver.Address
if target.URL.Scheme != "envoy" || target.URL.Hostname() == "" || target.URL.Port() == "" {
return false, fmt.Errorf("envoy-resolver: invalid scheme or missing host/port, target: %s",
target)
}
// make sure the path is valid
// used as :authority e.g. test.service
path := target.Endpoint()
if path == "" || strings.Contains(path, "/") {
return false, fmt.Errorf("envoy-resolver: invalid path %s", path)
}
return true, nil
}
func init() {
resolver.Register(&envoyBuilder{})
}

View File

@ -1,103 +0,0 @@
package nameresolvers
import (
"net/url"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/resolver"
)
func Test_EnvoyTargetString(t *testing.T) {
tests := []struct {
name string
mockURL url.URL
mockError string
shouldError bool
}{
{
name: "Should be valid string",
mockURL: url.URL{
Scheme: "envoy",
Host: "localhost:8080",
Path: "/test.service",
},
mockError: "",
shouldError: false,
},
{
name: "Should be valid scheme",
mockURL: url.URL{
Scheme: "invalid",
Host: "localhost:8080",
Path: "/test.service",
},
mockError: "envoy-resolver: invalid scheme or missing host/port, target: invalid://localhost:8080/test.service",
shouldError: true,
},
{
name: "Should be valid path",
mockURL: url.URL{
Scheme: "envoy",
Host: "localhost:8080",
Path: "/test.service/test",
},
mockError: "envoy-resolver: invalid path test.service/test",
shouldError: true,
},
{
name: "Should be valid path",
mockURL: url.URL{
Scheme: "envoy",
Host: "localhost:8080",
Path: "/test.service/",
},
mockError: "envoy-resolver: invalid path test.service/",
shouldError: true,
},
{
name: "Hostname should not be empty",
mockURL: url.URL{
Scheme: "envoy",
Host: ":8080",
Path: "/test.service",
},
mockError: "envoy-resolver: invalid scheme or missing host/port, target: envoy://:8080/test.service",
shouldError: true,
},
{
name: "Port should not be empty",
mockURL: url.URL{
Scheme: "envoy",
Host: "localhost",
Path: "/test.service",
},
mockError: "envoy-resolver: invalid scheme or missing host/port, target: envoy://localhost/test.service",
shouldError: true,
},
{
name: "Hostname and Port should not be empty",
mockURL: url.URL{
Scheme: "envoy",
Path: "/test.service",
},
mockError: "envoy-resolver: invalid scheme or missing host/port, target: envoy:///test.service",
shouldError: true,
},
}
for _, test := range tests {
target := resolver.Target{URL: test.mockURL}
isValid, err := isValidTarget(target)
if test.shouldError {
require.False(t, isValid, "Should not be valid")
require.NotNilf(t, err, "Error should not be nil")
require.Containsf(t, err.Error(), test.mockError, "Error should contains %s", test.mockError)
} else {
require.True(t, isValid, "Should be valid")
require.NoErrorf(t, err, "Error should be nil")
}
}
}

View File

@ -8,12 +8,9 @@ import (
"fmt"
"io"
"net/http"
parseUrl "net/url"
"path/filepath"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/sync"
"github.com/open-feature/flagd/core/pkg/utils"
"golang.org/x/crypto/sha3" //nolint:gosec
)
@ -27,7 +24,6 @@ type Sync struct {
AuthHeader string
Interval uint32
ready bool
eTag string
}
// Client defines the behaviour required of a http client
@ -43,11 +39,11 @@ type Cron interface {
}
func (hs *Sync) ReSync(ctx context.Context, dataSync chan<- sync.DataSync) error {
msg, _, err := hs.fetchBody(ctx, true)
msg, err := hs.Fetch(ctx)
if err != nil {
return err
}
dataSync <- sync.DataSync{FlagData: msg, Source: hs.URI}
dataSync <- sync.DataSync{FlagData: msg, Source: hs.URI, Type: sync.ALL}
return nil
}
@ -64,7 +60,7 @@ func (hs *Sync) IsReady() bool {
func (hs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error {
// Initial fetch
fetch, _, err := hs.fetchBody(ctx, true)
fetch, err := hs.Fetch(ctx)
if err != nil {
return err
}
@ -75,30 +71,43 @@ func (hs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error {
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("fetching configuration from %s", hs.URI))
previousBodySHA := hs.LastBodySHA
body, noChange, err := hs.fetchBody(ctx, false)
body, err := hs.fetchBodyFromURL(ctx, hs.URI)
if err != nil {
hs.Logger.Error(fmt.Sprintf("error fetching: %s", err.Error()))
hs.Logger.Error(err.Error())
return
}
if body == "" && !noChange {
if len(body) == 0 {
hs.Logger.Debug("configuration deleted")
return
}
} else {
if hs.LastBodySHA == "" {
hs.Logger.Debug("new configuration created")
msg, err := hs.Fetch(ctx)
if err != nil {
hs.Logger.Error(fmt.Sprintf("error fetching: %s", err.Error()))
} else {
dataSync <- sync.DataSync{FlagData: msg, Source: hs.URI, Type: sync.ALL}
}
} else {
currentSHA := hs.generateSha(body)
if hs.LastBodySHA != currentSHA {
hs.Logger.Debug("configuration modified")
msg, err := hs.Fetch(ctx)
if err != nil {
hs.Logger.Error(fmt.Sprintf("error fetching: %s", err.Error()))
} else {
dataSync <- sync.DataSync{FlagData: msg, Source: hs.URI, Type: sync.ALL}
}
}
if previousBodySHA == "" {
hs.Logger.Debug("configuration created")
dataSync <- sync.DataSync{FlagData: body, Source: hs.URI}
} else if previousBodySHA != hs.LastBodySHA {
hs.Logger.Debug("configuration updated")
dataSync <- sync.DataSync{FlagData: body, Source: hs.URI}
hs.LastBodySHA = currentSHA
}
}
})
hs.Cron.Start()
dataSync <- sync.DataSync{FlagData: fetch, Source: hs.URI}
dataSync <- sync.DataSync{FlagData: fetch, Source: hs.URI, Type: sync.ALL}
<-ctx.Done()
hs.Cron.Stop()
@ -106,18 +115,13 @@ func (hs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error {
return nil
}
func (hs *Sync) fetchBody(ctx context.Context, fetchAll bool) (string, bool, error) {
if hs.URI == "" {
return "", false, errors.New("no HTTP URL string set")
}
req, err := http.NewRequestWithContext(ctx, "GET", hs.URI, bytes.NewBuffer(nil))
func (hs *Sync) fetchBodyFromURL(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, bytes.NewBuffer(nil))
if err != nil {
return "", false, fmt.Errorf("error creating request for url %s: %w", hs.URI, err)
return nil, fmt.Errorf("error creating request for url %s: %w", url, err)
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Accept", "application/yaml")
if hs.AuthHeader != "" {
req.Header.Set("Authorization", hs.AuthHeader)
@ -126,60 +130,23 @@ func (hs *Sync) fetchBody(ctx context.Context, fetchAll bool) (string, bool, err
req.Header.Set("Authorization", bearer)
}
if hs.eTag != "" && !fetchAll {
req.Header.Set("If-None-Match", hs.eTag)
}
resp, err := hs.Client.Do(req)
if err != nil {
return "", false, fmt.Errorf("error calling endpoint %s: %w", hs.URI, err)
return nil, fmt.Errorf("error calling endpoint %s: %w", url, err)
}
defer func() {
err = resp.Body.Close()
if err != nil {
hs.Logger.Error(fmt.Sprintf("error closing the response body: %s", err.Error()))
hs.Logger.Debug(fmt.Sprintf("error closing the response body: %s", err.Error()))
}
}()
if resp.StatusCode == 304 {
hs.Logger.Debug("no changes detected")
return "", true, nil
}
statusOK := resp.StatusCode >= 200 && resp.StatusCode < 300
if !statusOK {
return "", false, fmt.Errorf("error fetching from url %s: %s", hs.URI, resp.Status)
}
if resp.Header.Get("ETag") != "" {
hs.eTag = resp.Header.Get("ETag")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", false, fmt.Errorf("unable to read body to bytes: %w", err)
return nil, fmt.Errorf("unable to read body to bytes: %w", err)
}
json, err := utils.ConvertToJSON(body, getFileExtensions(hs.URI), resp.Header.Get("Content-Type"))
if err != nil {
return "", false, fmt.Errorf("error converting response body to json: %w", err)
}
if json != "" {
hs.LastBodySHA = hs.generateSha([]byte(body))
}
return json, false, nil
}
// getFileExtensions returns the file extension from the URL path
func getFileExtensions(url string) string {
u, err := parseUrl.Parse(url)
if err != nil {
return ""
}
return filepath.Ext(u.Path)
return body, nil
}
func (hs *Sync) generateSha(body []byte) string {
@ -189,6 +156,17 @@ func (hs *Sync) generateSha(body []byte) string {
}
func (hs *Sync) Fetch(ctx context.Context) (string, error) {
body, _, err := hs.fetchBody(ctx, false)
return body, err
if hs.URI == "" {
return "", errors.New("no HTTP URL string set")
}
body, err := hs.fetchBodyFromURL(ctx, hs.URI)
if err != nil {
return "", err
}
if len(body) != 0 {
hs.LastBodySHA = hs.generateSha(body)
}
return string(body), nil
}

View File

@ -5,36 +5,32 @@ import (
"io"
"log"
"net/http"
"reflect"
"strings"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/sync"
syncmock "github.com/open-feature/flagd/core/pkg/sync/http/mock"
synctesting "github.com/open-feature/flagd/core/pkg/sync/testing"
"go.uber.org/mock/gomock"
)
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 {
resp := "test response"
mockCron := syncmock.NewMockCron(ctrl)
mockCron.EXPECT().AddFunc(gomock.Any(), gomock.Any()).DoAndReturn(func(spec string, cmd func()) error {
return nil
})
mockCron.EXPECT().Start().Times(1)
mockClient := syncmock.NewMockClient(ctrl)
responseBody := "test response"
resp := &http.Response{
Header: map[string][]string{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(responseBody)),
StatusCode: http.StatusOK,
}
mockClient.EXPECT().Do(gomock.Any()).Return(resp, nil)
mockClient.EXPECT().Do(gomock.Any()).Return(&http.Response{Body: io.NopCloser(strings.NewReader(resp))}, nil)
httpSync := Sync{
URI: "http://localhost/flags",
URI: "http://localhost",
Client: mockClient,
Cron: mockCron,
LastBodySHA: "",
@ -54,51 +50,8 @@ func TestSimpleSync(t *testing.T) {
data := <-dataSyncChan
if data.FlagData != responseBody {
t.Errorf("expected content: %s, but received content: %s", responseBody, data.FlagData)
}
}
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)
mockClient := syncmock.NewMockClient(ctrl)
responseBody := "test response"
resp := &http.Response{
Header: map[string][]string{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(responseBody)),
StatusCode: http.StatusOK,
}
mockClient.EXPECT().Do(gomock.Any()).Return(resp, nil)
httpSync := Sync{
URI: "http://localhost/flags.json?env=dev",
Client: mockClient,
Cron: mockCron,
LastBodySHA: "",
Logger: logger.NewLogger(nil, false),
}
ctx := context.Background()
dataSyncChan := make(chan sync.DataSync)
go func() {
err := httpSync.Sync(ctx, dataSyncChan)
if err != nil {
log.Fatalf("Error start sync: %s", err.Error())
return
}
}()
data := <-dataSyncChan
if data.FlagData != responseBody {
t.Errorf("expected content: %s, but received content: %s", responseBody, data.FlagData)
if data.FlagData != resp {
t.Errorf("expected content: %s, but received content: %s", resp, data.FlagData)
}
}
@ -110,16 +63,13 @@ func TestHTTPSync_Fetch(t *testing.T) {
uri string
bearerToken string
authHeader string
eTagHeader string
lastBodySHA string
handleResponse func(*testing.T, Sync, string, error)
}{
"success": {
setup: func(_ *testing.T, client *syncmock.MockClient) {
setup: func(t *testing.T, client *syncmock.MockClient) {
client.EXPECT().Do(gomock.Any()).Return(&http.Response{
Header: map[string][]string{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader("test response")),
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("test response")),
}, nil)
},
uri: "http://localhost",
@ -134,19 +84,17 @@ func TestHTTPSync_Fetch(t *testing.T) {
},
},
"return an error if no uri": {
setup: func(_ *testing.T, _ *syncmock.MockClient) {},
handleResponse: func(t *testing.T, _ Sync, _ string, err error) {
setup: func(t *testing.T, client *syncmock.MockClient) {},
handleResponse: func(t *testing.T, _ Sync, fetched string, err error) {
if err == nil {
t.Error("expected err, got nil")
}
},
},
"update last body sha": {
setup: func(_ *testing.T, client *syncmock.MockClient) {
setup: func(t *testing.T, client *syncmock.MockClient) {
client.EXPECT().Do(gomock.Any()).Return(&http.Response{
Header: map[string][]string{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader("test response")),
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("test response")),
}, nil)
},
uri: "http://localhost",
@ -172,11 +120,7 @@ func TestHTTPSync_Fetch(t *testing.T) {
if actualAuthHeader != "Bearer "+expectedToken {
t.Fatalf("expected Authorization header to be 'Bearer %s', got %s", expectedToken, actualAuthHeader)
}
return &http.Response{
Header: map[string][]string{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader("test response")),
StatusCode: http.StatusOK,
}, nil
return &http.Response{Body: io.NopCloser(strings.NewReader("test response"))}, nil
})
},
uri: "http://localhost",
@ -203,11 +147,7 @@ func TestHTTPSync_Fetch(t *testing.T) {
if actualAuthHeader != expectedHeader {
t.Fatalf("expected Authorization header to be '%s', got %s", expectedHeader, actualAuthHeader)
}
return &http.Response{
Header: map[string][]string{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader("test response")),
StatusCode: http.StatusOK,
}, nil
return &http.Response{Body: io.NopCloser(strings.NewReader("test response"))}, nil
})
},
uri: "http://localhost",
@ -226,100 +166,6 @@ func TestHTTPSync_Fetch(t *testing.T) {
}
},
},
"unauthorized request": {
setup: func(_ *testing.T, client *syncmock.MockClient) {
client.EXPECT().Do(gomock.Any()).Return(&http.Response{
Header: map[string][]string{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader("test response")),
StatusCode: http.StatusUnauthorized,
}, nil)
},
uri: "http://localhost",
handleResponse: func(t *testing.T, _ Sync, _ string, err error) {
if err == nil {
t.Fatalf("expected unauthorized request to return an error")
}
},
},
"not modified response etag matched": {
setup: func(t *testing.T, client *syncmock.MockClient) {
expectedIfNoneMatch := `"1af17a664e3fa8e419b8ba05c2a173169df76162a5a286e0c405b460d478f7ef"`
client.EXPECT().Do(gomock.Any()).DoAndReturn(func(req *http.Request) (*http.Response, error) {
actualIfNoneMatch := req.Header.Get("If-None-Match")
if actualIfNoneMatch != expectedIfNoneMatch {
t.Fatalf("expected If-None-Match header to be '%s', got %s", expectedIfNoneMatch, actualIfNoneMatch)
}
return &http.Response{
Header: map[string][]string{"ETag": {expectedIfNoneMatch}},
Body: io.NopCloser(strings.NewReader("")),
StatusCode: http.StatusNotModified,
}, nil
})
},
uri: "http://localhost",
eTagHeader: `"1af17a664e3fa8e419b8ba05c2a173169df76162a5a286e0c405b460d478f7ef"`,
handleResponse: func(t *testing.T, httpSync Sync, _ string, err error) {
if err != nil {
t.Fatalf("fetch: %v", err)
}
expectedLastBodySHA := ""
expectedETag := `"1af17a664e3fa8e419b8ba05c2a173169df76162a5a286e0c405b460d478f7ef"`
if httpSync.LastBodySHA != expectedLastBodySHA {
t.Errorf(
"expected last body sha to be: '%s', got: '%s'", expectedLastBodySHA, httpSync.LastBodySHA,
)
}
if httpSync.eTag != expectedETag {
t.Errorf(
"expected last etag to be: '%s', got: '%s'", expectedETag, httpSync.eTag,
)
}
},
},
"modified response etag mismatched": {
setup: func(t *testing.T, client *syncmock.MockClient) {
expectedIfNoneMatch := `"1af17a664e3fa8e419b8ba05c2a173169df76162a5a286e0c405b460d478f7ef"`
client.EXPECT().Do(gomock.Any()).DoAndReturn(func(req *http.Request) (*http.Response, error) {
actualIfNoneMatch := req.Header.Get("If-None-Match")
if actualIfNoneMatch != expectedIfNoneMatch {
t.Fatalf("expected If-None-Match header to be '%s', got %s", expectedIfNoneMatch, actualIfNoneMatch)
}
newContent := "\"Hey there!\""
newETag := `"c2e01ce63d90109c4c7f4f6dcea97ed1bb2b51e3647f36caf5acbe27413a24bb"`
return &http.Response{
Header: map[string][]string{
"Content-Type": {"application/json"},
"Etag": {newETag},
},
Body: io.NopCloser(strings.NewReader(newContent)),
StatusCode: http.StatusOK,
}, nil
})
},
uri: "http://localhost",
eTagHeader: `"1af17a664e3fa8e419b8ba05c2a173169df76162a5a286e0c405b460d478f7ef"`,
handleResponse: func(t *testing.T, httpSync Sync, _ string, err error) {
if err != nil {
t.Fatalf("fetch: %v", err)
}
expectedLastBodySHA := "wuAc5j2QEJxMf09tzql-0bsrUeNkfzbK9ay-J0E6JLs="
expectedETag := `"c2e01ce63d90109c4c7f4f6dcea97ed1bb2b51e3647f36caf5acbe27413a24bb"`
if httpSync.LastBodySHA != expectedLastBodySHA {
t.Errorf(
"expected last body sha to be: '%s', got: '%s'", expectedLastBodySHA, httpSync.LastBodySHA,
)
}
if httpSync.eTag != expectedETag {
t.Errorf(
"expected last etag to be: '%s', got: '%s'", expectedETag, httpSync.eTag,
)
}
},
},
}
for name, tt := range tests {
@ -335,7 +181,6 @@ func TestHTTPSync_Fetch(t *testing.T) {
AuthHeader: tt.authHeader,
LastBodySHA: tt.lastBodySHA,
Logger: logger.NewLogger(nil, false),
eTag: tt.eTagHeader,
}
fetched, err := httpSync.Fetch(context.Background())
@ -369,8 +214,6 @@ func TestSync_Init(t *testing.T) {
func TestHTTPSync_Resync(t *testing.T) {
ctrl := gomock.NewController(t)
source := "http://localhost"
emptyFlagData := "{}"
tests := map[string]struct {
setup func(t *testing.T, client *syncmock.MockClient)
@ -382,14 +225,12 @@ func TestHTTPSync_Resync(t *testing.T) {
wantNotifications []sync.DataSync
}{
"success": {
setup: func(_ *testing.T, client *syncmock.MockClient) {
setup: func(t *testing.T, client *syncmock.MockClient) {
client.EXPECT().Do(gomock.Any()).Return(&http.Response{
Header: map[string][]string{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(emptyFlagData)),
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("test response")),
}, nil)
},
uri: source,
uri: "http://localhost",
handleResponse: func(t *testing.T, _ Sync, fetched string, err error) {
if err != nil {
t.Fatalf("fetch: %v", err)
@ -402,14 +243,15 @@ func TestHTTPSync_Resync(t *testing.T) {
wantErr: false,
wantNotifications: []sync.DataSync{
{
FlagData: emptyFlagData,
Source: source,
Type: sync.ALL,
FlagData: "",
Source: "",
},
},
},
"error response": {
setup: func(_ *testing.T, _ *syncmock.MockClient) {},
handleResponse: func(t *testing.T, _ Sync, _ string, err error) {
setup: func(t *testing.T, client *syncmock.MockClient) {},
handleResponse: func(t *testing.T, _ Sync, fetched string, err error) {
if err == nil {
t.Error("expected err, got nil")
}
@ -445,8 +287,8 @@ func TestHTTPSync_Resync(t *testing.T) {
for _, dataSync := range tt.wantNotifications {
select {
case x := <-d:
if x.FlagData != dataSync.FlagData || x.Source != dataSync.Source {
t.Errorf("unexpected datasync received %v vs %v", x, dataSync)
if !reflect.DeepEqual(x.String(), dataSync.String()) {
t.Error("unexpected datasync received", x, dataSync)
}
case <-time.After(2 * time.Second):
t.Error("expected datasync not received", dataSync)

Some files were not shown because too many files have changed in this diff Show More