├── .github ├── ISSUE_TEMPLATE │ ├── bug.yaml │ ├── documentation.yaml │ └── feature.yaml └── workflows │ ├── build.yaml │ ├── dev-benches-page.yml │ ├── lint-pr.yaml │ ├── markdown-checks.yaml │ └── release-please.yaml ├── .gitignore ├── .gitmodules ├── .golangci.yml ├── .goreleaser.yaml ├── .markdownlint-cli2.yaml ├── .nvmrc ├── .pre-commit-config.yaml ├── .release-please-manifest.json ├── CHANGELOG.md ├── CODEOWNERS ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── config ├── deployments │ ├── flagd │ │ ├── deployment.yaml │ │ └── service.yaml │ └── kube-flagd-proxy │ │ ├── crb.yaml │ │ ├── deployment.yaml │ │ └── service.yaml └── samples │ ├── example_flags.flagd.json │ ├── example_flags.flagd.yaml │ ├── example_flags.json │ ├── example_flags.yaml │ ├── example_flags_secondary.flagd.json │ └── example_flags_secondary.json ├── core ├── CHANGELOG.md ├── go.mod ├── go.sum └── pkg │ ├── certreloader │ ├── certreloader.go │ └── certreloader_test.go │ ├── evaluator │ ├── fractional.go │ ├── fractional_test.go │ ├── ievaluator.go │ ├── ievaluator_test.go │ ├── json.go │ ├── json_model.go │ ├── json_test.go │ ├── legacy_fractional.go │ ├── legacy_fractional_test.go │ ├── mock │ │ └── ievaluator.go │ ├── semver.go │ ├── semver_test.go │ ├── string_comparison.go │ └── string_comparison_test.go │ ├── logger │ ├── logger.go │ └── logger_test.go │ ├── model │ ├── error.go │ ├── flag.go │ ├── notification.go │ └── reason.go │ ├── service │ ├── iservice.go │ └── ofrep │ │ ├── models.go │ │ └── models_test.go │ ├── store │ ├── flags.go │ └── flags_test.go │ ├── sync │ ├── blob │ │ ├── blob_sync.go │ │ ├── blob_sync_test.go │ │ └── mock_blob.go │ ├── builder │ │ ├── mock │ │ │ └── syncbuilder.go │ │ ├── syncbuilder.go │ │ ├── syncbuilder_test.go │ │ ├── utils.go │ │ └── utils_test.go │ ├── file │ │ ├── fileinfo_watcher.go │ │ ├── fileinfo_watcher_test.go │ │ ├── filepath_sync.go │ │ ├── filepath_sync_test.go │ │ └── fsnotify_watcher.go │ ├── grpc │ │ ├── credentials │ │ │ ├── builder.go │ │ │ ├── builder_test.go │ │ │ └── mock │ │ │ │ └── builder.go │ │ ├── grpc_sync.go │ │ ├── grpc_sync_test.go │ │ ├── mock │ │ │ └── grpc.go │ │ └── nameresolvers │ │ │ ├── envoy_resolver.go │ │ │ └── envoy_resolver_test.go │ ├── http │ │ ├── http_sync.go │ │ ├── http_sync_test.go │ │ └── mock │ │ │ └── http.go │ ├── isync.go │ ├── kubernetes │ │ ├── event.go │ │ ├── inotify.go │ │ ├── kubernetes_sync.go │ │ └── kubernetes_sync_test.go │ └── testing │ │ └── mock_cron.go │ ├── telemetry │ ├── builder.go │ ├── builder_test.go │ ├── metrics.go │ ├── metrics_test.go │ ├── utils.go │ └── utils_test.go │ └── utils │ ├── convert_to_json.go │ ├── convert_to_json_test.go │ ├── yaml.go │ └── yaml_test.go ├── docs ├── architecture-decisions │ └── .template.md ├── architecture.md ├── assets │ ├── demo.flagd.json │ ├── extra.css │ └── logo-white.svg ├── concepts │ ├── feature-flagging.md │ └── syncs.md ├── faq.md ├── images │ ├── flag-merge-1.svg │ ├── flag-merge-2.svg │ ├── flag-merge-3.svg │ ├── flag-merge-4.svg │ ├── flagd-logical-architecture.jpg │ ├── flagd-telemetry.png │ ├── of-flagd-0.png │ └── of-flagd-1.png ├── index.md ├── installation.md ├── playground │ ├── index.md │ └── playground.js ├── providers │ ├── dotnet.md │ ├── go.md │ ├── index.md │ ├── java.md │ ├── nodejs.md │ ├── php.md │ ├── python.md │ ├── rust.md │ └── web.md ├── quick-start.md ├── reference │ ├── custom-operations │ │ ├── fractional-operation.md │ │ ├── semver-operation.md │ │ └── string-comparison-operation.md │ ├── flag-definitions.md │ ├── flagd-cli │ │ ├── flagd.md │ │ ├── flagd_start.md │ │ └── flagd_version.md │ ├── flagd-ofrep.md │ ├── grpc-sync-service.md │ ├── monitoring.md │ ├── naming.md │ ├── openfeature-operator │ │ └── overview.md │ ├── schema.md │ ├── specifications │ │ ├── custom-operations │ │ │ ├── fractional-operation-spec.md │ │ │ ├── semver-operation-spec.md │ │ │ └── string-comparison-operation-spec.md │ │ ├── proposal │ │ │ └── rfc-grpc-custom-name-resolver.md │ │ ├── protos.md │ │ └── providers.md │ └── sync-configuration.md ├── schema │ └── v0 │ │ ├── flags.json │ │ └── targeting.json └── troubleshooting.md ├── flagd-proxy ├── CHANGELOG.md ├── README.md ├── build.Dockerfile ├── cmd │ ├── root.go │ └── start.go ├── go.mod ├── go.sum ├── main.go ├── pkg │ └── service │ │ ├── handler.go │ │ ├── server.go │ │ ├── subscriptions │ │ ├── manager.go │ │ ├── manager_test.go │ │ └── multiplexer.go │ │ └── sync_metrics.go └── tests │ └── loadtest │ ├── .gitignore │ ├── README.md │ ├── config │ ├── config.json │ ├── end-spec.json │ └── start-spec.json │ ├── go.mod │ ├── go.sum │ ├── main.go │ ├── pkg │ ├── client │ │ └── client.go │ ├── config │ │ └── config.go │ ├── handler │ │ └── handler.go │ ├── trigger │ │ ├── file │ │ │ └── trigger.go │ │ └── trigger.go │ └── watcher │ │ └── watcher.go │ └── target.json ├── flagd ├── CHANGELOG.md ├── build.Dockerfile ├── cmd │ ├── doc.go │ ├── doc │ │ └── main.go │ ├── root.go │ ├── start.go │ └── version.go ├── go.mod ├── go.sum ├── main.go ├── pkg │ ├── runtime │ │ ├── from_config.go │ │ └── runtime.go │ └── service │ │ ├── flag-evaluation │ │ ├── connect_service.go │ │ ├── connect_service_test.go │ │ ├── eventing.go │ │ ├── eventing_test.go │ │ ├── flag_evaluator.go │ │ ├── flag_evaluator_test.go │ │ ├── flag_evaluator_types.go │ │ ├── flag_evaluator_v2.go │ │ ├── flag_evaluator_v2_test.go │ │ ├── json_codec.go │ │ ├── json_codec_test.go │ │ └── ofrep │ │ │ ├── handler.go │ │ │ ├── handler_test.go │ │ │ ├── ofrep_service.go │ │ │ └── ofrep_service_test.go │ │ ├── flag-sync │ │ ├── handler.go │ │ ├── sync-multiplexer.go │ │ ├── sync-multiplexer_test.go │ │ ├── sync_service.go │ │ ├── sync_service_test.go │ │ ├── test-cert │ │ │ ├── ca-cert.pem │ │ │ ├── gen.sh │ │ │ ├── server-cert.pem │ │ │ ├── server-ext.cnf │ │ │ └── server-key.pem │ │ └── util_test.go │ │ └── middleware │ │ ├── cors │ │ ├── cors.go │ │ └── cors_test.go │ │ ├── h2c │ │ ├── h2c.go │ │ └── h2c_test.go │ │ ├── interface.go │ │ ├── metrics │ │ ├── http_metrics.go │ │ └── http_metrics_test.go │ │ └── mock │ │ └── interface.go ├── profile.Dockerfile ├── profiler.go └── snap │ └── snapcraft.yaml ├── images ├── flagD.png ├── flagd-proxy.png └── loadTestResults.png ├── mkdocs.yml ├── netlify.toml ├── playground-app ├── .eslintrc.cjs ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── src │ ├── App.tsx │ ├── main.tsx │ ├── scenarios │ │ ├── basic-boolean.ts │ │ ├── basic-number.ts │ │ ├── basic-object.ts │ │ ├── basic-string.ts │ │ ├── boolean-shorthand.ts │ │ ├── chainable-conditions.ts │ │ ├── enable-by-domain.ts │ │ ├── enable-by-locale.ts │ │ ├── enable-by-time.ts │ │ ├── enable-by-version.ts │ │ ├── flag-metadata.ts │ │ ├── fraction-string.ts │ │ ├── index.ts │ │ ├── progressive-rollout.ts │ │ ├── share-evaluators.ts │ │ └── targeting-key.ts │ ├── types.ts │ ├── utils.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── release-please-config.json ├── renovate.json ├── requirements.txt ├── runtime.txt ├── samples ├── example_flags.flagd.json ├── example_flags.flagd.yaml ├── example_flags.json ├── example_flags.yaml ├── example_flags_secondary.flagd.json └── example_flags_secondary.json ├── snap └── snapcraft.yaml ├── systemd ├── flagd.service └── flags.json └── test ├── README.MD ├── integration ├── README.md ├── config │ └── envoy.yaml ├── evaluation_test.go ├── go.mod ├── go.sum ├── integration_test.go └── json_evaluator_test.go ├── loadtest ├── .gitignore ├── README.MD ├── ff_gen.go ├── go.mod ├── go.sum └── sample_k6.js ├── zero-downtime-flagd-proxy ├── README.md ├── go.mod ├── go.sum ├── grpc.go ├── main.go ├── manifests │ ├── pod.yaml │ └── proxy │ │ ├── deployment.yaml │ │ ├── flag-config.yaml │ │ └── service.yaml └── zd_test.sh └── zero-downtime ├── README.md ├── test-pod.yaml └── zd_test.sh /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug 2 | description: Found a bug? We are sorry about that! Let us know! 🐛 3 | title: "[BUG] " 4 | labels: [bug, Needs Triage] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Observed behavior 9 | description: What are you trying to do? Describe what you think went wrong during this. 10 | validations: 11 | required: false 12 | - type: textarea 13 | attributes: 14 | label: Expected Behavior 15 | description: A concise description of what you expected to happen. 16 | validations: 17 | required: false 18 | - type: textarea 19 | attributes: 20 | label: Steps to reproduce 21 | description: Describe as much as you can the problem. Please provide us scenario file, logs or anything you have that can help us to understand. 22 | validations: 23 | required: false 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: 📓 Documentation 2 | description: Any documentation related issue/addition. 3 | title: "[DOC] " 4 | labels: [documentation, Needs Triage] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Change in the documentation 9 | description: What should we add/remove/update in the documentation? 10 | validations: 11 | required: false 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yaml: -------------------------------------------------------------------------------- 1 | name: 💡 Feature 2 | description: Add new functionality to the project. 3 | title: "[FEATURE] " 4 | labels: [enhancement, Needs Triage] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Requirements 9 | description: | 10 | Ask us what do you want! Please provide as many details as possible and describe how it should work. 11 | 12 | Note: Spec and architecture changes require an [OFEP](https://github.com/open-feature/ofep). 13 | validations: 14 | required: false 15 | -------------------------------------------------------------------------------- /.github/workflows/dev-benches-page.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the gh-pages branch 6 | push: 7 | branches: ["gh-pages"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 33 | with: 34 | ref: gh-pages 35 | - name: Setup Pages 36 | uses: actions/configure-pages@1f0c5cde4bc74cd7e1254d0cb4de8d49e9068c7d # v4 37 | - name: Upload artifact 38 | uses: actions/upload-pages-artifact@0252fc4ba7626f0298f0cf00902a25c6afc77fa8 # v3 39 | with: 40 | path: './dev/bench' 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@87c3283f01cd6fe19a0ab93a23b2f6fcba5a8e42 # v4 44 | -------------------------------------------------------------------------------- /.github/workflows/lint-pr.yaml: -------------------------------------------------------------------------------- 1 | name: 'Lint PR' 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | name: Validate PR title 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: amannn/action-semantic-pull-request@e9fabac35e210fea40ca5b14c0da95a099eff26f # v5 16 | id: lint_pr_title 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | - uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 # v2 21 | # When the previous steps fails, the workflow would stop. By adding this 22 | # condition you can continue the execution with the populated error message. 23 | if: always() && (steps.lint_pr_title.outputs.error_message != null) 24 | with: 25 | header: pr-title-lint-error 26 | message: | 27 | Hey there and thank you for opening this pull request! 👋🏼 28 | 29 | We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted. 30 | Details: 31 | 32 | ``` 33 | ${{ steps.lint_pr_title.outputs.error_message }} 34 | ``` 35 | # Delete a previous comment when the issue has been resolved 36 | - if: ${{ steps.lint_pr_title.outputs.error_message == null }} 37 | uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 # v2 38 | with: 39 | header: pr-title-lint-error 40 | delete: true 41 | -------------------------------------------------------------------------------- /.github/workflows/markdown-checks.yaml: -------------------------------------------------------------------------------- 1 | name: Markdown checks 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**.md' 7 | pull_request: 8 | paths: 9 | - '**.md' 10 | 11 | jobs: 12 | markdown-lint: 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - name: Check out code 16 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 17 | 18 | - name: Lint Markdown files 19 | run: make markdownlint 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __debug_bin 2 | .DS_Store 3 | .vscode 4 | dist/ 5 | .idea 6 | *.pem 7 | # ignore generated code 8 | pkg/eval/flagd-definitions.json 9 | *.crt 10 | *.key 11 | core-coverage.out 12 | go.work 13 | go.work.sum 14 | bin/ 15 | node_modules/ 16 | .venv 17 | 18 | # built documentation 19 | site 20 | .cache/ 21 | 22 | # coverage results 23 | *coverage.out -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test-harness"] 2 | path = test-harness 3 | url = https://github.com/open-feature/test-harness.git 4 | [submodule "spec"] 5 | path = spec 6 | url = https://github.com/open-feature/spec.git 7 | [submodule "schemas"] 8 | path = schemas 9 | url = https://github.com/open-feature/flagd-schemas.git 10 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 3m 3 | linters-settings: 4 | funlen: 5 | statements: 50 6 | golint: 7 | min-confidence: 0.6 8 | enable-all: true 9 | issues: 10 | exclude: 11 | - pkg/generated 12 | exclude-rules: 13 | - path: _test.go 14 | linters: 15 | - funlen 16 | - maligned 17 | - noctx 18 | - scopelint 19 | - bodyclose 20 | - lll 21 | - goconst 22 | - gocognit 23 | - gocyclo 24 | - dupl 25 | - staticcheck 26 | exclude-dirs: 27 | - (^|/)bin($|/) 28 | - (^|/)examples($|/) 29 | - (^|/)schemas($|/) 30 | - (^|/)test-harness($|/) 31 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod tidy 7 | # you may remove this if you don't need go generate 8 | - go generate ./... 9 | builds: 10 | - env: 11 | - CGO_ENABLED=0 12 | goos: 13 | - linux 14 | - windows 15 | - darwin 16 | archives: 17 | - name_template: >- 18 | {{ .ProjectName }}_{{ .Version }}_{{- title .Os }}_{{- if eq .Arch "amd64" }}x86_64{{- else if eq .Arch "386" }}i386{{- else }}{{ .Arch }}{{ end }} 19 | # Generate SBOM per each archive 20 | sboms: 21 | - artifacts: archive 22 | checksum: 23 | name_template: 'checksums.txt' 24 | snapshot: 25 | name_template: "{{ incpatch .Version }}-next" 26 | changelog: 27 | sort: asc 28 | filters: 29 | exclude: 30 | - '^docs:' 31 | - '^test:' 32 | -------------------------------------------------------------------------------- /.markdownlint-cli2.yaml: -------------------------------------------------------------------------------- 1 | config: 2 | line-length: 3 | line_length: 500 4 | tables: false 5 | code_blocks: false 6 | no-inline-html: 7 | allowed_elements: 8 | - details 9 | - summary 10 | - img 11 | - br 12 | github-admonition: true 13 | max-one-sentence-per-line: true 14 | code-block-style: false # not compatible with mkdocs "details" panes 15 | no-alt-text: false 16 | descriptive-link-text: false 17 | MD007: 18 | indent: 4 19 | 20 | ignores: 21 | - "**/CHANGELOG.md" 22 | - ".venv" 23 | - "node_modules" 24 | - "playground-app/node_modules" 25 | - "tmp" 26 | - "**/protos.md" # auto-generated 27 | - "docs/playground" # auto-generated 28 | - "docs/providers/*.md" # auto-generated 29 | - "schemas" # submodule 30 | - "spec" # submodule 31 | - "test-harness" # submodule 32 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/Bahjat/pre-commit-golang 3 | rev: v1.0.1 4 | hooks: 5 | - id: gofumpt 6 | 7 | - repo: https://github.com/golangci/golangci-lint 8 | rev: v1.50.1 9 | hooks: 10 | - id: golangci-lint 11 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "flagd": "0.12.3", 3 | "flagd-proxy": "0.7.3", 4 | "core": "0.11.3" 5 | } -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence 3 | # 4 | # Managed by Peribolos: https://github.com/open-feature/community/blob/main/config/open-feature/cloud-native/workgroup.yaml 5 | # 6 | * @open-feature/cloud-native-maintainers @open-feature/maintainers 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM squidfunk/mkdocs-material:9.5 2 | RUN pip install mkdocs-include-markdown-plugin 3 | -------------------------------------------------------------------------------- /config/deployments/flagd/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: flagd 6 | name: flagd 7 | spec: 8 | replicas: 1 9 | strategy: 10 | type: RollingUpdate 11 | rollingUpdate: 12 | maxSurge: 1 13 | maxUnavailable: 0 14 | selector: 15 | matchLabels: 16 | app: flagd 17 | template: 18 | metadata: 19 | labels: 20 | app.kubernetes.io/name: flagd 21 | app: flagd 22 | spec: 23 | containers: 24 | - name: flagd 25 | image: ${IMG} 26 | volumeMounts: 27 | - name: config-volume 28 | mountPath: /etc/flagd 29 | readinessProbe: 30 | httpGet: 31 | path: /readyz 32 | port: 8014 33 | initialDelaySeconds: 5 34 | periodSeconds: 5 35 | livenessProbe: 36 | httpGet: 37 | path: /healthz 38 | port: 8014 39 | initialDelaySeconds: 5 40 | periodSeconds: 60 41 | ports: 42 | - containerPort: 8013 43 | args: 44 | - start 45 | - --uri 46 | - file:/etc/flagd/config.json 47 | - --debug 48 | volumes: 49 | - name: config-volume 50 | configMap: 51 | name: open-feature-flags 52 | items: 53 | - key: flags 54 | path: config.json 55 | --- 56 | # ConfigMap for Flagd OpenFeatuer provider 57 | apiVersion: v1 58 | kind: ConfigMap 59 | metadata: 60 | name: open-feature-flags 61 | data: 62 | flags: | 63 | { 64 | "$schema": "https://flagd.dev/schema/v0/flags.json", 65 | "flags": { 66 | "myStringFlag": { 67 | "state": "ENABLED", 68 | "variants": { 69 | "key1": "val1", 70 | "key2": "val2" 71 | }, 72 | "defaultVariant": "key1" 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /config/deployments/flagd/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: flagd-svc 5 | spec: 6 | selector: 7 | app.kubernetes.io/name: flagd 8 | ports: 9 | - port: 8013 10 | targetPort: 8013 11 | -------------------------------------------------------------------------------- /config/deployments/kube-flagd-proxy/crb.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | annotations: 5 | kubectl.kubernetes.io/last-applied-configuration: | 6 | {"apiVersion":"rbac.authorization.k8s.io/v1","kind":"ClusterRoleBinding","metadata":{"annotations":{},"name":"open-feature-operator-flagd-kubernetes-sync"},"roleRef":{"apiGroup":"","kind":"ClusterRole","name":"open-feature-operator-flagd-kubernetes-sync"},"subjects":[{"apiGroup":"","kind":"ServiceAccount","name":"open-feature-operator-controller-manager","namespace":"system"}]} 7 | name: open-feature-operator-flagd-kubernetes-sync 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: ClusterRole 11 | name: open-feature-operator-flagd-kubernetes-sync 12 | subjects: 13 | - kind: ServiceAccount 14 | name: open-feature-operator-controller-manager 15 | namespace: system 16 | - kind: ServiceAccount 17 | name: default 18 | namespace: flagd-proxy 19 | -------------------------------------------------------------------------------- /config/deployments/kube-flagd-proxy/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | creationTimestamp: null 5 | namespace: flagd-proxy 6 | labels: 7 | app: flagd-proxy 8 | name: flagd-proxy 9 | annotations: 10 | openfeature.dev/allowkubernetessync: "true" 11 | spec: 12 | replicas: 1 13 | selector: 14 | matchLabels: 15 | app: flagd-proxy 16 | template: 17 | metadata: 18 | creationTimestamp: null 19 | labels: 20 | app.kubernetes.io/name: flagd-proxy 21 | app: flagd-proxy 22 | annotations: 23 | openfeature.dev/allowkubernetessync: "true" 24 | spec: 25 | containers: 26 | - image: ghcr.io/open-feature/flagd-proxy:latest 27 | name: flagd-proxy 28 | ports: 29 | - containerPort: 8015 30 | args: 31 | - start 32 | -------------------------------------------------------------------------------- /config/deployments/kube-flagd-proxy/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: flagd-proxy-svc 5 | namespace: flagd-proxy 6 | spec: 7 | selector: 8 | app.kubernetes.io/name: flagd-proxy 9 | ports: 10 | - port: 8015 11 | targetPort: 8015 -------------------------------------------------------------------------------- /config/samples/example_flags.flagd.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://flagd.dev/schema/v0/flags.json 2 | flags: 3 | myBoolFlag: 4 | state: ENABLED 5 | variants: 6 | 'on': true 7 | 'off': false 8 | defaultVariant: 'on' 9 | myStringFlag: 10 | state: ENABLED 11 | variants: 12 | key1: val1 13 | key2: val2 14 | defaultVariant: key1 15 | myFloatFlag: 16 | state: ENABLED 17 | variants: 18 | one: 1.23 19 | two: 2.34 20 | defaultVariant: one 21 | myIntFlag: 22 | state: ENABLED 23 | variants: 24 | one: 1 25 | two: 2 26 | defaultVariant: one 27 | myObjectFlag: 28 | state: ENABLED 29 | variants: 30 | object1: 31 | key: val 32 | object2: 33 | key: true 34 | defaultVariant: object1 35 | isColorYellow: 36 | state: ENABLED 37 | variants: 38 | 'on': true 39 | 'off': false 40 | defaultVariant: 'off' 41 | targeting: 42 | if: 43 | - "==": 44 | - var: 45 | - color 46 | - yellow 47 | - 'on' 48 | - 'off' 49 | fibAlgo: 50 | variants: 51 | recursive: recursive 52 | memo: memo 53 | loop: loop 54 | binet: binet 55 | defaultVariant: recursive 56 | state: ENABLED 57 | targeting: 58 | if: 59 | - "$ref": emailWithFaas 60 | - binet 61 | - null 62 | headerColor: 63 | variants: 64 | red: "#FF0000" 65 | blue: "#0000FF" 66 | green: "#00FF00" 67 | yellow: "#FFFF00" 68 | defaultVariant: red 69 | state: ENABLED 70 | targeting: 71 | if: 72 | - "$ref": emailWithFaas 73 | - fractional: 74 | - var: email 75 | - - red 76 | - 25 77 | - - blue 78 | - 25 79 | - - green 80 | - 25 81 | - - yellow 82 | - 25 83 | - null 84 | targetedFlag: 85 | variants: 86 | first: "AAA" 87 | second: "BBB" 88 | third: "CCC" 89 | defaultVariant: first 90 | state: ENABLED 91 | targeting: 92 | if: 93 | - in: 94 | - "@openfeature.dev" 95 | - var: email 96 | - second 97 | - first 98 | 99 | "$evaluators": 100 | emailWithFaas: 101 | in: 102 | - "@faas.com" 103 | - var: 104 | - email 105 | -------------------------------------------------------------------------------- /config/samples/example_flags.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://flagd.dev/schema/v0/flags.json 2 | flags: 3 | myBoolFlag: 4 | state: ENABLED 5 | variants: 6 | 'on': true 7 | 'off': false 8 | defaultVariant: 'on' 9 | myStringFlag: 10 | state: ENABLED 11 | variants: 12 | key1: val1 13 | key2: val2 14 | defaultVariant: key1 15 | myFloatFlag: 16 | state: ENABLED 17 | variants: 18 | one: 1.23 19 | two: 2.34 20 | defaultVariant: one 21 | myIntFlag: 22 | state: ENABLED 23 | variants: 24 | one: 1 25 | two: 2 26 | defaultVariant: one 27 | myObjectFlag: 28 | state: ENABLED 29 | variants: 30 | object1: 31 | key: val 32 | object2: 33 | key: true 34 | defaultVariant: object1 35 | isColorYellow: 36 | state: ENABLED 37 | variants: 38 | 'on': true 39 | 'off': false 40 | defaultVariant: 'off' 41 | targeting: 42 | if: 43 | - "==": 44 | - var: 45 | - color 46 | - yellow 47 | - 'on' 48 | - 'off' 49 | fibAlgo: 50 | variants: 51 | recursive: recursive 52 | memo: memo 53 | loop: loop 54 | binet: binet 55 | defaultVariant: recursive 56 | state: ENABLED 57 | targeting: 58 | if: 59 | - "$ref": emailWithFaas 60 | - binet 61 | - null 62 | headerColor: 63 | variants: 64 | red: "#FF0000" 65 | blue: "#0000FF" 66 | green: "#00FF00" 67 | yellow: "#FFFF00" 68 | defaultVariant: red 69 | state: ENABLED 70 | targeting: 71 | if: 72 | - "$ref": emailWithFaas 73 | - fractionalEvaluation: 74 | - var: email 75 | - - red 76 | - 25 77 | - - blue 78 | - 25 79 | - - green 80 | - 25 81 | - - yellow 82 | - 25 83 | - null 84 | "$evaluators": 85 | emailWithFaas: 86 | in: 87 | - "@faas.com" 88 | - var: 89 | - email -------------------------------------------------------------------------------- /config/samples/example_flags_secondary.flagd.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://flagd.dev/schema/v0/flags.json", 3 | "metadata": { 4 | "version": "v2" 5 | }, 6 | "flags": { 7 | "myBoolFlag": { 8 | "state": "ENABLED", 9 | "variants": { 10 | "on": true, 11 | "off": false 12 | }, 13 | "defaultVariant": "off" 14 | }, 15 | "isColorGreen": { 16 | "state": "ENABLED", 17 | "variants": { 18 | "on": true, 19 | "off": false 20 | }, 21 | "defaultVariant": "off", 22 | "targeting": { 23 | "if": [ 24 | { 25 | "==": [ 26 | { 27 | "var": [ 28 | "color" 29 | ] 30 | }, 31 | "yellow" 32 | ] 33 | }, 34 | "on", 35 | "off" 36 | ] 37 | } 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /config/samples/example_flags_secondary.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://flagd.dev/schema/v0/flags.json", 3 | "flags": { 4 | "myBoolFlag": { 5 | "state": "ENABLED", 6 | "variants": { 7 | "on": true, 8 | "off": false 9 | }, 10 | "defaultVariant": "off" 11 | }, 12 | "isColorGreen": { 13 | "state": "ENABLED", 14 | "variants": { 15 | "on": true, 16 | "off": false 17 | }, 18 | "defaultVariant": "off", 19 | "targeting": { 20 | "if": [ 21 | { 22 | "==": [ 23 | { 24 | "var": [ 25 | "color" 26 | ] 27 | }, 28 | "yellow" 29 | ] 30 | }, 31 | "on", 32 | "off" 33 | ] 34 | } 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /core/pkg/certreloader/certreloader.go: -------------------------------------------------------------------------------- 1 | package certreloader 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | type Config struct { 11 | KeyPath string 12 | CertPath string 13 | ReloadInterval time.Duration 14 | } 15 | 16 | type CertReloader struct { 17 | cert *tls.Certificate 18 | mu sync.RWMutex 19 | nextReload time.Time 20 | Config 21 | } 22 | 23 | func NewCertReloader(config Config) (*CertReloader, error) { 24 | reloader := CertReloader{ 25 | Config: config, 26 | } 27 | 28 | reloader.mu.Lock() 29 | defer reloader.mu.Unlock() 30 | cert, err := reloader.loadCertificate() 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to load initial certificate: %w", err) 33 | } 34 | reloader.cert = &cert 35 | 36 | return &reloader, nil 37 | } 38 | 39 | func (r *CertReloader) GetCertificate() (*tls.Certificate, error) { 40 | now := time.Now() 41 | // Read locking here before we do the time comparison 42 | // If a reload is in progress this will block and we will skip reloading in the current 43 | // call once we can continue 44 | r.mu.RLock() 45 | shouldReload := r.ReloadInterval != 0 && r.nextReload.Before(now) 46 | r.mu.RUnlock() 47 | if shouldReload { 48 | // Need to release the read lock, otherwise we deadlock 49 | r.mu.Lock() 50 | defer r.mu.Unlock() 51 | cert, err := r.loadCertificate() 52 | if err != nil { 53 | return nil, fmt.Errorf("failed to load TLS cert and key: %w", err) 54 | } 55 | r.cert = &cert 56 | r.nextReload = now.Add(r.ReloadInterval) 57 | return r.cert, nil 58 | } 59 | return r.cert, nil 60 | } 61 | 62 | func (r *CertReloader) loadCertificate() (tls.Certificate, error) { 63 | newCert, err := tls.LoadX509KeyPair(r.CertPath, r.KeyPath) 64 | if err != nil { 65 | return tls.Certificate{}, fmt.Errorf("failed to load key pair: %w", err) 66 | } 67 | 68 | return newCert, nil 69 | } 70 | -------------------------------------------------------------------------------- /core/pkg/evaluator/ievaluator.go: -------------------------------------------------------------------------------- 1 | package evaluator 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/open-feature/flagd/core/pkg/model" 7 | "github.com/open-feature/flagd/core/pkg/sync" 8 | ) 9 | 10 | type AnyValue struct { 11 | Value interface{} 12 | Variant string 13 | Reason string 14 | FlagKey string 15 | Metadata model.Metadata 16 | Error error 17 | } 18 | 19 | func NewAnyValue( 20 | value interface{}, variant string, reason string, flagKey string, metadata model.Metadata, 21 | err error, 22 | ) AnyValue { 23 | return AnyValue{ 24 | Value: value, 25 | Variant: variant, 26 | Reason: reason, 27 | FlagKey: flagKey, 28 | Metadata: metadata, 29 | Error: err, 30 | } 31 | } 32 | 33 | /* 34 | IEvaluator is an extension of IResolver, allowing storage updates and retrievals 35 | */ 36 | type IEvaluator interface { 37 | GetState() (string, error) 38 | SetState(payload sync.DataSync) (model.Metadata, bool, error) 39 | IResolver 40 | } 41 | 42 | // IResolver focuses on resolving of the known flags 43 | type IResolver interface { 44 | ResolveBooleanValue( 45 | ctx context.Context, 46 | reqID string, 47 | flagKey string, 48 | context map[string]any) (value bool, variant string, reason string, metadata model.Metadata, err error) 49 | ResolveStringValue( 50 | ctx context.Context, 51 | reqID string, 52 | flagKey string, 53 | context map[string]any) ( 54 | value string, variant string, reason string, metadata model.Metadata, err error) 55 | ResolveIntValue( 56 | ctx context.Context, 57 | reqID string, 58 | flagKey string, 59 | context map[string]any) ( 60 | value int64, variant string, reason string, metadata model.Metadata, err error) 61 | ResolveFloatValue( 62 | ctx context.Context, 63 | reqID string, 64 | flagKey string, 65 | context map[string]any) ( 66 | value float64, variant string, reason string, metadata model.Metadata, err error) 67 | ResolveObjectValue( 68 | ctx context.Context, 69 | reqID string, 70 | flagKey string, 71 | context map[string]any) ( 72 | value map[string]any, variant string, reason string, metadata model.Metadata, err error) 73 | ResolveAsAnyValue( 74 | ctx context.Context, 75 | reqID string, 76 | flagKey string, 77 | context map[string]any) AnyValue 78 | ResolveAllValues( 79 | ctx context.Context, 80 | reqID string, 81 | context map[string]any) (resolutions []AnyValue, metadata model.Metadata, err error) 82 | } 83 | -------------------------------------------------------------------------------- /core/pkg/evaluator/ievaluator_test.go: -------------------------------------------------------------------------------- 1 | package evaluator 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestAnyValue(t *testing.T) { 11 | obj := AnyValue{ 12 | Value: "val", 13 | Variant: "variant", 14 | Reason: "reason", 15 | FlagKey: "key", 16 | Metadata: map[string]interface{}{}, 17 | Error: fmt.Errorf("err"), 18 | } 19 | 20 | require.Equal(t, obj, NewAnyValue("val", "variant", "reason", "key", map[string]interface{}{}, fmt.Errorf("err"))) 21 | } 22 | -------------------------------------------------------------------------------- /core/pkg/evaluator/json_model.go: -------------------------------------------------------------------------------- 1 | package evaluator 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/open-feature/flagd/core/pkg/model" 7 | ) 8 | 9 | type Evaluators struct { 10 | Evaluators map[string]json.RawMessage `json:"$evaluators"` 11 | } 12 | 13 | type Definition struct { 14 | Flags map[string]model.Flag `json:"flags"` 15 | Metadata map[string]interface{} `json:"metadata"` 16 | } 17 | 18 | type Flags struct { 19 | Flags map[string]model.Flag `json:"flags"` 20 | } 21 | -------------------------------------------------------------------------------- /core/pkg/logger/logger_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | func TestFieldStorageAndRetrieval(t *testing.T) { 12 | tests := map[string]struct { 13 | fields []zap.Field 14 | }{ 15 | "happyPath": { 16 | fields: []zap.Field{ 17 | zap.String("this", "that"), 18 | zap.Strings("this2", []string{"that2", "that2"}), 19 | }, 20 | }, 21 | } 22 | for name, test := range tests { 23 | l := NewLogger(&zap.Logger{}, true) 24 | l.WriteFields(name, test.fields...) 25 | returnedFields := l.getFields(name) 26 | if !reflect.DeepEqual(returnedFields, test.fields) { 27 | t.Error("returned fields to not match the input", test.fields, returnedFields) 28 | } 29 | } 30 | } 31 | 32 | func TestLoggerChildOperation(t *testing.T) { 33 | id := "test" 34 | // create parent logger 35 | p := NewLogger(&zap.Logger{}, true) 36 | // add field 1 37 | field1 := zap.Int("field", 1) 38 | p.WriteFields(id, field1) 39 | 40 | // create child logger with field 2 41 | field2 := zap.Int("field", 2) 42 | c := p.WithFields(field2) 43 | 44 | if !reflect.DeepEqual(c.getFields(id), []zapcore.Field{field1}) { 45 | t.Error("1: child logger contains incorrect fields ", c.getFieldsForLog(id)) 46 | } 47 | if !reflect.DeepEqual(p.getFields(id), []zapcore.Field{field1}) { 48 | t.Error("1: parent logger contains incorrect fields ", c.getFields(id)) 49 | } 50 | 51 | // add field 3 to the child, should be present in both 52 | field3 := zap.Int("field", 3) 53 | c.WriteFields(id, field3) 54 | 55 | if !reflect.DeepEqual(c.getFields(id), []zapcore.Field{field1, field3}) { 56 | t.Error("1: child logger contains incorrect fields ", c.getFieldsForLog(id)) 57 | } 58 | if !reflect.DeepEqual(p.getFields(id), []zapcore.Field{field1, field3}) { 59 | t.Error("1: parent logger contains incorrect fields ", c.getFields(id)) 60 | } 61 | 62 | // ensure child logger appends field 2 63 | logFields := c.getFieldsForLog(id) 64 | field2Found := false 65 | for _, field := range logFields { 66 | if field == field2 { 67 | field2Found = true 68 | } 69 | } 70 | if !field2Found { 71 | t.Error("field 2 is missing from the child logger getFieldsForLog response") 72 | } 73 | 74 | // ensure parent logger does not 75 | logFields = p.getFieldsForLog(id) 76 | field2Found = false 77 | for _, field := range logFields { 78 | if field == field2 { 79 | field2Found = true 80 | } 81 | } 82 | if field2Found { 83 | t.Error("field 2 is present in the parent logger getFieldsForLog response") 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /core/pkg/model/error.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "fmt" 4 | 5 | const ( 6 | FlagNotFoundErrorCode = "FLAG_NOT_FOUND" 7 | ParseErrorCode = "PARSE_ERROR" 8 | TypeMismatchErrorCode = "TYPE_MISMATCH" 9 | GeneralErrorCode = "GENERAL" 10 | FlagDisabledErrorCode = "FLAG_DISABLED" 11 | InvalidContextCode = "INVALID_CONTEXT" 12 | ) 13 | 14 | var ReadableErrorMessage = map[string]string{ 15 | FlagNotFoundErrorCode: "Flag not found", 16 | ParseErrorCode: "Error parsing input or configuration", 17 | TypeMismatchErrorCode: "Type mismatch error", 18 | GeneralErrorCode: "General error", 19 | FlagDisabledErrorCode: "Flag is disabled", 20 | InvalidContextCode: "Invalid context provided", 21 | } 22 | 23 | func GetErrorMessage(code string) string { 24 | if msg, exists := ReadableErrorMessage[code]; exists { 25 | return msg 26 | } 27 | return fmt.Sprintf("Unknown error code: %s", code) 28 | } 29 | -------------------------------------------------------------------------------- /core/pkg/model/flag.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "encoding/json" 4 | 5 | type Flag struct { 6 | State string `json:"state"` 7 | DefaultVariant string `json:"defaultVariant"` 8 | Variants map[string]any `json:"variants"` 9 | Targeting json.RawMessage `json:"targeting,omitempty"` 10 | Source string `json:"source"` 11 | Selector string `json:"selector"` 12 | Metadata Metadata `json:"metadata,omitempty"` 13 | } 14 | 15 | type Evaluators struct { 16 | Evaluators map[string]json.RawMessage `json:"$evaluators"` 17 | } 18 | 19 | type Metadata = map[string]interface{} 20 | -------------------------------------------------------------------------------- /core/pkg/model/notification.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type StateChangeNotificationType string 4 | 5 | const ( 6 | NotificationDelete StateChangeNotificationType = "delete" 7 | NotificationCreate StateChangeNotificationType = "write" 8 | NotificationUpdate StateChangeNotificationType = "update" 9 | ) 10 | 11 | type StateChangeNotification struct { 12 | Type StateChangeNotificationType `json:"type"` 13 | Source string `json:"source"` 14 | FlagKey string `json:"flagKey"` 15 | } 16 | -------------------------------------------------------------------------------- /core/pkg/model/reason.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type EvaluationReason string 4 | 5 | const ( 6 | TargetingMatchReason = "TARGETING_MATCH" 7 | SplitReason = "SPLIT" 8 | DisabledReason = "DISABLED" 9 | DefaultReason = "DEFAULT" 10 | UnknownReason = "UNKNOWN" 11 | ErrorReason = "ERROR" 12 | StaticReason = "STATIC" 13 | ) 14 | -------------------------------------------------------------------------------- /core/pkg/service/iservice.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | 6 | "connectrpc.com/connect" 7 | ) 8 | 9 | type NotificationType string 10 | 11 | const ( 12 | ConfigurationChange NotificationType = "configuration_change" 13 | Shutdown NotificationType = "provider_shutdown" 14 | ProviderReady NotificationType = "provider_ready" 15 | KeepAlive NotificationType = "keep_alive" 16 | ) 17 | 18 | type Notification struct { 19 | Type NotificationType `json:"type"` 20 | Data map[string]interface{} `json:"data"` 21 | } 22 | 23 | type ReadinessProbe func() bool 24 | 25 | type Configuration struct { 26 | ReadinessProbe ReadinessProbe 27 | Port uint16 28 | ManagementPort uint16 29 | ServiceName string 30 | CertPath string 31 | KeyPath string 32 | SocketPath string 33 | CORS []string 34 | Options []connect.HandlerOption 35 | ContextValues map[string]any 36 | } 37 | 38 | /* 39 | IFlagEvaluationService implementations define handlers for a particular transport, 40 | which call the IEvaluator implementation. 41 | */ 42 | type IFlagEvaluationService interface { 43 | Serve(ctx context.Context, svcConf Configuration) error 44 | Notify(n Notification) 45 | Shutdown() 46 | } 47 | 48 | /* 49 | IFlagEvaluationService implementations define handlers for a particular transport, 50 | which call the IEvaluator implementation. 51 | */ 52 | type IKubeSyncService interface { 53 | Serve(ctx context.Context, svcConf Configuration) error 54 | } 55 | -------------------------------------------------------------------------------- /core/pkg/sync/blob/mock_blob.go: -------------------------------------------------------------------------------- 1 | package blob 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/url" 7 | 8 | "gocloud.dev/blob" 9 | "gocloud.dev/blob/memblob" 10 | ) 11 | 12 | type MockBlob struct { 13 | mux *blob.URLMux 14 | scheme string 15 | opener *fakeOpener 16 | } 17 | 18 | type fakeOpener struct { 19 | object string 20 | content string 21 | keepModTime bool 22 | getSync func() *Sync 23 | } 24 | 25 | func (f *fakeOpener) OpenBucketURL(ctx context.Context, _ *url.URL) (*blob.Bucket, error) { 26 | bucketURL, err := url.Parse("mem://") 27 | if err != nil { 28 | log.Fatalf("couldn't parse url: %s: %v", "mem://", err) 29 | } 30 | opener := &memblob.URLOpener{} 31 | bucket, err := opener.OpenBucketURL(ctx, bucketURL) 32 | if err != nil { 33 | log.Fatalf("couldn't open in memory bucket: %v", err) 34 | } 35 | if f.object != "" { 36 | err = bucket.WriteAll(ctx, f.object, []byte(f.content), nil) 37 | if err != nil { 38 | log.Fatalf("couldn't write in memory file: %v", err) 39 | } 40 | } 41 | if f.keepModTime && f.object != "" { 42 | attrs, err := bucket.Attributes(ctx, f.object) 43 | if err != nil { 44 | log.Fatalf("couldn't get memory file attributes: %v", err) 45 | } 46 | f.getSync().lastUpdated = attrs.ModTime 47 | } else { 48 | f.keepModTime = true 49 | } 50 | return bucket, nil 51 | } 52 | 53 | func NewMockBlob(scheme string, getSync func() *Sync) *MockBlob { 54 | mux := new(blob.URLMux) 55 | opener := &fakeOpener{getSync: getSync} 56 | mux.RegisterBucket(scheme, opener) 57 | return &MockBlob{ 58 | mux: mux, 59 | scheme: scheme, 60 | opener: opener, 61 | } 62 | } 63 | 64 | func (mb *MockBlob) URLMux() *blob.URLMux { 65 | return mb.mux 66 | } 67 | 68 | func (mb *MockBlob) AddObject(object, content string) { 69 | mb.opener.object = object 70 | mb.opener.content = content 71 | mb.opener.keepModTime = false 72 | } 73 | -------------------------------------------------------------------------------- /core/pkg/sync/file/fsnotify_watcher.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fsnotify/fsnotify" 7 | ) 8 | 9 | // Implements file.Watcher by wrapping fsnotify.Watcher 10 | // This is only necessary because fsnotify.Watcher directly exposes its Errors 11 | // and Events channels rather than returning them by method invocation 12 | type fsNotifyWatcher struct { 13 | watcher *fsnotify.Watcher 14 | } 15 | 16 | // NewFsNotifyWatcher returns a new fsNotifyWatcher 17 | func NewFSNotifyWatcher() (Watcher, error) { 18 | fsn, err := fsnotify.NewWatcher() 19 | if err != nil { 20 | return nil, fmt.Errorf("fsnotify: %w", err) 21 | } 22 | return &fsNotifyWatcher{ 23 | watcher: fsn, 24 | }, nil 25 | } 26 | 27 | // explicitly implements file.Watcher 28 | var _ Watcher = &fsNotifyWatcher{} 29 | 30 | // Close calls close on the underlying fsnotify.Watcher 31 | func (f *fsNotifyWatcher) Close() error { 32 | if err := f.watcher.Close(); err != nil { 33 | return fmt.Errorf("fsnotify: %w", err) 34 | } 35 | return nil 36 | } 37 | 38 | // Add calls Add on the underlying fsnotify.Watcher 39 | func (f *fsNotifyWatcher) Add(name string) error { 40 | if err := f.watcher.Add(name); err != nil { 41 | return fmt.Errorf("fsnotify: %w", err) 42 | } 43 | return nil 44 | } 45 | 46 | // Remove calls Remove on the underlying fsnotify.Watcher 47 | func (f *fsNotifyWatcher) Remove(name string) error { 48 | if err := f.watcher.Remove(name); err != nil { 49 | return fmt.Errorf("fsnotify: %w", err) 50 | } 51 | return nil 52 | } 53 | 54 | // Watchlist calls watchlist on the underlying fsnotify.Watcher 55 | func (f *fsNotifyWatcher) WatchList() []string { 56 | return f.watcher.WatchList() 57 | } 58 | 59 | // Events returns the underlying watcher's Events chan 60 | func (f *fsNotifyWatcher) Events() chan fsnotify.Event { 61 | return f.watcher.Events 62 | } 63 | 64 | // Errors returns the underlying watcher's Errors chan 65 | func (f *fsNotifyWatcher) Errors() chan error { 66 | return f.watcher.Errors 67 | } 68 | -------------------------------------------------------------------------------- /core/pkg/sync/grpc/credentials/builder.go: -------------------------------------------------------------------------------- 1 | package credentials 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "fmt" 7 | "os" 8 | 9 | "google.golang.org/grpc/credentials" 10 | "google.golang.org/grpc/credentials/insecure" 11 | ) 12 | 13 | const tlsVersion = tls.VersionTLS12 14 | 15 | type Builder interface { 16 | Build(secure bool, certPath string) (credentials.TransportCredentials, error) 17 | } 18 | 19 | type CredentialBuilder struct{} 20 | 21 | // Build is a helper to build grpc credentials.TransportCredentials based on source and cert path 22 | func (cb *CredentialBuilder) Build(secure bool, certPath string) (credentials.TransportCredentials, error) { 23 | if !secure { 24 | // check if certificate is set & make this an error so that we do not establish an unwanted insecure connection 25 | if certPath != "" { 26 | return nil, fmt.Errorf("provided a non empty certificate %s, but requested an insecure connection."+ 27 | " Please check configurations of the grpc sync source", certPath) 28 | } 29 | 30 | return insecure.NewCredentials(), nil 31 | } 32 | 33 | if certPath == "" { 34 | // Rely on CA certs provided from system 35 | return credentials.NewTLS(&tls.Config{MinVersion: tlsVersion}), nil 36 | } 37 | 38 | // Rely on provided certificate 39 | certBytes, err := os.ReadFile(certPath) 40 | if err != nil { 41 | return nil, fmt.Errorf("unable to read file %s: %w", certPath, err) 42 | } 43 | 44 | cp := x509.NewCertPool() 45 | if !cp.AppendCertsFromPEM(certBytes) { 46 | return nil, fmt.Errorf("invalid certificate provided at path: %s", certPath) 47 | } 48 | 49 | return credentials.NewTLS(&tls.Config{ 50 | MinVersion: tlsVersion, 51 | RootCAs: cp, 52 | }), nil 53 | } 54 | -------------------------------------------------------------------------------- /core/pkg/sync/grpc/credentials/mock/builder.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: pkg/sync/grpc/credentials/builder.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -source=pkg/sync/grpc/credentials/builder.go -destination=pkg/sync/grpc/credentials/mock/builder.go -package=credendialsmock 7 | // 8 | 9 | // Package credendialsmock is a generated GoMock package. 10 | package credendialsmock 11 | 12 | import ( 13 | reflect "reflect" 14 | 15 | gomock "go.uber.org/mock/gomock" 16 | credentials "google.golang.org/grpc/credentials" 17 | ) 18 | 19 | // MockBuilder is a mock of Builder interface. 20 | type MockBuilder struct { 21 | ctrl *gomock.Controller 22 | recorder *MockBuilderMockRecorder 23 | } 24 | 25 | // MockBuilderMockRecorder is the mock recorder for MockBuilder. 26 | type MockBuilderMockRecorder struct { 27 | mock *MockBuilder 28 | } 29 | 30 | // NewMockBuilder creates a new mock instance. 31 | func NewMockBuilder(ctrl *gomock.Controller) *MockBuilder { 32 | mock := &MockBuilder{ctrl: ctrl} 33 | mock.recorder = &MockBuilderMockRecorder{mock} 34 | return mock 35 | } 36 | 37 | // EXPECT returns an object that allows the caller to indicate expected use. 38 | func (m *MockBuilder) EXPECT() *MockBuilderMockRecorder { 39 | return m.recorder 40 | } 41 | 42 | // Build mocks base method. 43 | func (m *MockBuilder) Build(secure bool, certPath string) (credentials.TransportCredentials, error) { 44 | m.ctrl.T.Helper() 45 | ret := m.ctrl.Call(m, "Build", secure, certPath) 46 | ret0, _ := ret[0].(credentials.TransportCredentials) 47 | ret1, _ := ret[1].(error) 48 | return ret0, ret1 49 | } 50 | 51 | // Build indicates an expected call of Build. 52 | func (mr *MockBuilderMockRecorder) Build(secure, certPath any) *gomock.Call { 53 | mr.mock.ctrl.T.Helper() 54 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Build", reflect.TypeOf((*MockBuilder)(nil).Build), secure, certPath) 55 | } 56 | -------------------------------------------------------------------------------- /core/pkg/sync/grpc/nameresolvers/envoy_resolver.go: -------------------------------------------------------------------------------- 1 | package nameresolvers 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "google.golang.org/grpc/resolver" 8 | ) 9 | 10 | const scheme = "envoy" 11 | 12 | type envoyBuilder struct{} 13 | 14 | // Build A custom NameResolver to resolve gRPC target uri for envoy in the 15 | // format of. 16 | // 17 | // Custom URI Scheme: 18 | // 19 | // envoy://[proxy-agent-host]:[proxy-agent-port]/[service-name] 20 | func (*envoyBuilder) Build(target resolver.Target, 21 | cc resolver.ClientConn, _ resolver.BuildOptions, 22 | ) (resolver.Resolver, error) { 23 | _, err := isValidTarget(target) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | r := &envoyResolver{ 29 | target: target, 30 | cc: cc, 31 | } 32 | r.start() 33 | return r, nil 34 | } 35 | 36 | func (*envoyBuilder) Scheme() string { 37 | return scheme 38 | } 39 | 40 | type envoyResolver struct { 41 | target resolver.Target 42 | cc resolver.ClientConn 43 | } 44 | 45 | // Envoy NameResolver, will always override the authority with the specified authority i.e. URL.path and 46 | // use the socketAddress i.e. Host:Port to connect. 47 | func (r *envoyResolver) start() { 48 | addr := fmt.Sprintf("%s:%s", r.target.URL.Hostname(), r.target.URL.Port()) 49 | err := r.cc.UpdateState(resolver.State{Addresses: []resolver.Address{{Addr: addr}}}) 50 | if err != nil { 51 | return 52 | } 53 | } 54 | 55 | func (*envoyResolver) ResolveNow(resolver.ResolveNowOptions) {} 56 | 57 | func (*envoyResolver) Close() {} 58 | 59 | // Validate user specified target 60 | // 61 | // Sample target string: envoy://localhost:9211/test.service 62 | // 63 | // return `true` if the target string used match the scheme and format 64 | func isValidTarget(target resolver.Target) (bool, error) { 65 | // make sure and host and port not empty 66 | // used as resolver.Address 67 | if target.URL.Scheme != "envoy" || target.URL.Hostname() == "" || target.URL.Port() == "" { 68 | return false, fmt.Errorf("envoy-resolver: invalid scheme or missing host/port, target: %s", 69 | target) 70 | } 71 | 72 | // make sure the path is valid 73 | // used as :authority e.g. test.service 74 | path := target.Endpoint() 75 | if path == "" || strings.Contains(path, "/") { 76 | return false, fmt.Errorf("envoy-resolver: invalid path %s", path) 77 | } 78 | 79 | return true, nil 80 | } 81 | 82 | func init() { 83 | resolver.Register(&envoyBuilder{}) 84 | } 85 | -------------------------------------------------------------------------------- /core/pkg/sync/grpc/nameresolvers/envoy_resolver_test.go: -------------------------------------------------------------------------------- 1 | package nameresolvers 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "google.golang.org/grpc/resolver" 9 | ) 10 | 11 | func Test_EnvoyTargetString(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | mockURL url.URL 15 | mockError string 16 | shouldError bool 17 | }{ 18 | { 19 | name: "Should be valid string", 20 | mockURL: url.URL{ 21 | Scheme: "envoy", 22 | Host: "localhost:8080", 23 | Path: "/test.service", 24 | }, 25 | mockError: "", 26 | shouldError: false, 27 | }, 28 | { 29 | name: "Should be valid scheme", 30 | mockURL: url.URL{ 31 | Scheme: "invalid", 32 | Host: "localhost:8080", 33 | Path: "/test.service", 34 | }, 35 | mockError: "envoy-resolver: invalid scheme or missing host/port, target: invalid://localhost:8080/test.service", 36 | shouldError: true, 37 | }, 38 | { 39 | name: "Should be valid path", 40 | mockURL: url.URL{ 41 | Scheme: "envoy", 42 | Host: "localhost:8080", 43 | Path: "/test.service/test", 44 | }, 45 | mockError: "envoy-resolver: invalid path test.service/test", 46 | shouldError: true, 47 | }, 48 | { 49 | name: "Should be valid path", 50 | mockURL: url.URL{ 51 | Scheme: "envoy", 52 | Host: "localhost:8080", 53 | Path: "/test.service/", 54 | }, 55 | mockError: "envoy-resolver: invalid path test.service/", 56 | shouldError: true, 57 | }, 58 | { 59 | name: "Hostname should not be empty", 60 | mockURL: url.URL{ 61 | Scheme: "envoy", 62 | Host: ":8080", 63 | Path: "/test.service", 64 | }, 65 | mockError: "envoy-resolver: invalid scheme or missing host/port, target: envoy://:8080/test.service", 66 | shouldError: true, 67 | }, 68 | { 69 | name: "Port should not be empty", 70 | mockURL: url.URL{ 71 | Scheme: "envoy", 72 | Host: "localhost", 73 | Path: "/test.service", 74 | }, 75 | mockError: "envoy-resolver: invalid scheme or missing host/port, target: envoy://localhost/test.service", 76 | shouldError: true, 77 | }, 78 | { 79 | name: "Hostname and Port should not be empty", 80 | mockURL: url.URL{ 81 | Scheme: "envoy", 82 | Path: "/test.service", 83 | }, 84 | mockError: "envoy-resolver: invalid scheme or missing host/port, target: envoy:///test.service", 85 | shouldError: true, 86 | }, 87 | } 88 | 89 | for _, test := range tests { 90 | target := resolver.Target{URL: test.mockURL} 91 | 92 | isValid, err := isValidTarget(target) 93 | 94 | if test.shouldError { 95 | require.False(t, isValid, "Should not be valid") 96 | require.NotNilf(t, err, "Error should not be nil") 97 | require.Containsf(t, err.Error(), test.mockError, "Error should contains %s", test.mockError) 98 | } else { 99 | require.True(t, isValid, "Should be valid") 100 | require.NoErrorf(t, err, "Error should be nil") 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /core/pkg/sync/http/mock/http.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: pkg/sync/http/http_sync.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -source=pkg/sync/http/http_sync.go -destination=pkg/sync/http/mock/http.go -package=syncmock 7 | // 8 | 9 | // Package syncmock is a generated GoMock package. 10 | package syncmock 11 | 12 | import ( 13 | http "net/http" 14 | reflect "reflect" 15 | 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockClient is a mock of Client interface. 20 | type MockClient struct { 21 | ctrl *gomock.Controller 22 | recorder *MockClientMockRecorder 23 | } 24 | 25 | // MockClientMockRecorder is the mock recorder for MockClient. 26 | type MockClientMockRecorder struct { 27 | mock *MockClient 28 | } 29 | 30 | // NewMockClient creates a new mock instance. 31 | func NewMockClient(ctrl *gomock.Controller) *MockClient { 32 | mock := &MockClient{ctrl: ctrl} 33 | mock.recorder = &MockClientMockRecorder{mock} 34 | return mock 35 | } 36 | 37 | // EXPECT returns an object that allows the caller to indicate expected use. 38 | func (m *MockClient) EXPECT() *MockClientMockRecorder { 39 | return m.recorder 40 | } 41 | 42 | // Do mocks base method. 43 | func (m *MockClient) Do(req *http.Request) (*http.Response, error) { 44 | m.ctrl.T.Helper() 45 | ret := m.ctrl.Call(m, "Do", req) 46 | ret0, _ := ret[0].(*http.Response) 47 | ret1, _ := ret[1].(error) 48 | return ret0, ret1 49 | } 50 | 51 | // Do indicates an expected call of Do. 52 | func (mr *MockClientMockRecorder) Do(req any) *gomock.Call { 53 | mr.mock.ctrl.T.Helper() 54 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Do", reflect.TypeOf((*MockClient)(nil).Do), req) 55 | } 56 | -------------------------------------------------------------------------------- /core/pkg/sync/isync.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Type int 8 | 9 | // Type of the sync operation 10 | const ( 11 | // ALL - All flags of sync provider. This is the default if unset due to primitive default 12 | ALL Type = iota 13 | // ADD - Additional flags from sync provider 14 | ADD 15 | // UPDATE - Update for flag(s) previously provided 16 | UPDATE 17 | // DELETE - Delete for flag(s) previously provided 18 | DELETE 19 | ) 20 | 21 | func (t Type) String() string { 22 | switch t { 23 | case ALL: 24 | return "ALL" 25 | case ADD: 26 | return "ADD" 27 | case UPDATE: 28 | return "UPDATE" 29 | case DELETE: 30 | return "DELETE" 31 | default: 32 | return "UNKNOWN" 33 | } 34 | } 35 | 36 | /* 37 | ISync implementations watch for changes in the flag sources (HTTP backend, local file, K8s CRDs ...),fetch the latest 38 | value and communicate to the Runtime with DataSync channel 39 | */ 40 | type ISync interface { 41 | // Init is used by the sync provider to initialize its data structures and external dependencies. 42 | Init(ctx context.Context) error 43 | 44 | // Sync is the contract between Runtime and sync implementation. 45 | // Note that, it is expected to return the first data sync as soon as possible to fill the store. 46 | Sync(ctx context.Context, dataSync chan<- DataSync) error 47 | 48 | // ReSync is used to fetch the full flag configuration from the sync 49 | // This method should trigger an ALL sync operation then exit 50 | ReSync(ctx context.Context, dataSync chan<- DataSync) error 51 | 52 | // IsReady shall return true if the provider is ready to communicate with the Runtime 53 | IsReady() bool 54 | } 55 | 56 | // DataSync is the data contract between Runtime and sync implementations 57 | type DataSync struct { 58 | FlagData string 59 | Source string 60 | Selector string 61 | Type 62 | } 63 | 64 | // SourceConfig is configuration option for flagd. This maps to startup parameter sources 65 | type SourceConfig struct { 66 | URI string `json:"uri"` 67 | Provider string `json:"provider"` 68 | 69 | BearerToken string `json:"bearerToken,omitempty"` 70 | AuthHeader string `json:"authHeader,omitempty"` 71 | CertPath string `json:"certPath,omitempty"` 72 | TLS bool `json:"tls,omitempty"` 73 | ProviderID string `json:"providerID,omitempty"` 74 | Selector string `json:"selector,omitempty"` 75 | Interval uint32 `json:"interval,omitempty"` 76 | MaxMsgSize int `json:"maxMsgSize,omitempty"` 77 | } 78 | -------------------------------------------------------------------------------- /core/pkg/sync/kubernetes/event.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | type DefaultEventType int32 4 | 5 | const ( 6 | DefaultEventTypeCreate = iota 7 | DefaultEventTypeModify = 1 8 | DefaultEventTypeDelete = 2 9 | DefaultEventTypeReady = 3 10 | ) 11 | 12 | // Event is a struct that represents a single event. 13 | // It is a generic struct that can be cast to a more specific struct. 14 | type Event[T any] struct { 15 | EventType T 16 | } 17 | -------------------------------------------------------------------------------- /core/pkg/sync/kubernetes/inotify.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | type INotify interface { 4 | GetEvent() Event[DefaultEventType] 5 | } 6 | 7 | type Notifier struct { 8 | Event Event[DefaultEventType] 9 | } 10 | 11 | func (w *Notifier) GetEvent() Event[DefaultEventType] { 12 | return w.Event 13 | } 14 | -------------------------------------------------------------------------------- /core/pkg/sync/testing/mock_cron.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "reflect" 5 | 6 | "go.uber.org/mock/gomock" 7 | ) 8 | 9 | // MockCron is a mock of Cron interface. 10 | type MockCron struct { 11 | ctrl *gomock.Controller 12 | recorder *MockCronMockRecorder 13 | cmd func() 14 | } 15 | 16 | // MockCronMockRecorder is the mock recorder for MockCron. 17 | type MockCronMockRecorder struct { 18 | mock *MockCron 19 | } 20 | 21 | // NewMockCron creates a new mock instance. 22 | func NewMockCron(ctrl *gomock.Controller) *MockCron { 23 | mock := &MockCron{ctrl: ctrl} 24 | mock.recorder = &MockCronMockRecorder{mock} 25 | return mock 26 | } 27 | 28 | // EXPECT returns an object that allows the caller to indicate expected use. 29 | func (m *MockCron) EXPECT() *MockCronMockRecorder { 30 | return m.recorder 31 | } 32 | 33 | // AddFunc mocks base method. 34 | func (m *MockCron) AddFunc(spec string, cmd func()) error { 35 | m.ctrl.T.Helper() 36 | ret := m.ctrl.Call(m, "AddFunc", spec, cmd) 37 | ret0, _ := ret[0].(error) 38 | m.cmd = cmd 39 | return ret0 40 | } 41 | 42 | func (m *MockCron) Tick() { 43 | m.cmd() 44 | } 45 | 46 | // AddFunc indicates an expected call of AddFunc. 47 | func (mr *MockCronMockRecorder) AddFunc(spec, cmd any) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddFunc", reflect.TypeOf((*MockCron)(nil).AddFunc), spec, cmd) 50 | } 51 | 52 | // Start mocks base method. 53 | func (m *MockCron) Start() { 54 | m.ctrl.T.Helper() 55 | m.ctrl.Call(m, "Start") 56 | } 57 | 58 | // Start indicates an expected call of Start. 59 | func (mr *MockCronMockRecorder) Start() *gomock.Call { 60 | mr.mock.ctrl.T.Helper() 61 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockCron)(nil).Start)) 62 | } 63 | 64 | // Stop mocks base method. 65 | func (m *MockCron) Stop() { 66 | m.ctrl.T.Helper() 67 | m.ctrl.Call(m, "Stop") 68 | } 69 | 70 | // Stop indicates an expected call of Stop. 71 | func (mr *MockCronMockRecorder) Stop() *gomock.Call { 72 | mr.mock.ctrl.T.Helper() 73 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockCron)(nil).Stop)) 74 | } 75 | -------------------------------------------------------------------------------- /core/pkg/telemetry/utils.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "go.opentelemetry.io/otel/attribute" 5 | semconv "go.opentelemetry.io/otel/semconv/v1.18.0" 6 | ) 7 | 8 | // utils contain common utilities to help with telemetry 9 | 10 | const provider = "flagd" 11 | 12 | // SemConvFeatureFlagAttributes is helper to derive semantic convention adhering feature flag attributes 13 | // refer - https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/ 14 | func SemConvFeatureFlagAttributes(ffKey string, ffVariant string) []attribute.KeyValue { 15 | return []attribute.KeyValue{ 16 | semconv.FeatureFlagKey(ffKey), 17 | semconv.FeatureFlagVariant(ffVariant), 18 | semconv.FeatureFlagProviderName(provider), 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /core/pkg/telemetry/utils_test.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | semconv "go.opentelemetry.io/otel/semconv/v1.18.0" 8 | ) 9 | 10 | func TestSemConvFeatureFlagAttributes(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | key string 14 | variant string 15 | }{ 16 | { 17 | name: "simple flag", 18 | key: "flagA", 19 | variant: "bool", 20 | }, 21 | { 22 | name: "empty variant flag", 23 | key: "flagB", 24 | }, 25 | { 26 | name: "empty key & variant does not panic", 27 | }, 28 | } 29 | 30 | for _, test := range tests { 31 | attributes := SemConvFeatureFlagAttributes(test.key, test.variant) 32 | 33 | for _, attribute := range attributes { 34 | switch attribute.Key { 35 | case semconv.FeatureFlagKeyKey: 36 | require.Equal(t, test.key, attribute.Value.AsString(), 37 | "expected flag key: %s, but received: %s", test.key, attribute.Value.AsString()) 38 | case semconv.FeatureFlagVariantKey: 39 | require.Equal(t, test.variant, attribute.Value.AsString(), 40 | "expected flag variant: %s, but received %s", test.variant, attribute.Value.AsString()) 41 | case semconv.FeatureFlagProviderNameKey: 42 | require.Equal(t, provider, attribute.Value.AsString(), 43 | "expected flag provider: %s, but received %s", provider, attribute.Value.AsString()) 44 | default: 45 | t.Errorf("attributes contains unexpected attribute. with key: %v, with type: %v", 46 | attribute.Key, attribute.Value.Type()) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /core/pkg/utils/convert_to_json.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "mime" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | var alphanumericRegex = regexp.MustCompile("[^a-zA-Z0-9]+") 11 | 12 | // ConvertToJSON attempts to convert the content of a file to JSON based on the file extension. 13 | // The media type is used as a fallback in case the file extension is not recognized. 14 | func ConvertToJSON(data []byte, fileExtension string, mediaType string) (string, error) { 15 | var detectedType string 16 | if fileExtension != "" { 17 | // file extension only contains alphanumeric characters 18 | detectedType = alphanumericRegex.ReplaceAllString(fileExtension, "") 19 | } else { 20 | parsedMediaType, _, err := mime.ParseMediaType(mediaType) 21 | if err != nil { 22 | return "", fmt.Errorf("unable to determine file format: %w", err) 23 | } 24 | detectedType = parsedMediaType 25 | } 26 | 27 | // Normalize the detected type 28 | detectedType = strings.ToLower(detectedType) 29 | 30 | switch detectedType { 31 | case "yaml", "yml", "application/yaml", "application/x-yaml": 32 | str, err := YAMLToJSON(data) 33 | if err != nil { 34 | return "", fmt.Errorf("error converting blob from yaml to json: %w", err) 35 | } 36 | return str, nil 37 | case "json", "application/json": 38 | return string(data), nil 39 | default: 40 | return "", fmt.Errorf("unsupported file format: '%s'", detectedType) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /core/pkg/utils/yaml.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | // converts YAML byte array to JSON string 11 | func YAMLToJSON(rawFile []byte) (string, error) { 12 | if len(rawFile) == 0 { 13 | return "", nil 14 | } 15 | 16 | var ms map[string]interface{} 17 | if err := yaml.Unmarshal(rawFile, &ms); err != nil { 18 | return "", fmt.Errorf("error unmarshaling yaml: %w", err) 19 | } 20 | 21 | r, err := json.Marshal(ms) 22 | if err != nil { 23 | return "", fmt.Errorf("error marshaling json: %w", err) 24 | } 25 | 26 | return string(r), err 27 | } 28 | -------------------------------------------------------------------------------- /core/pkg/utils/yaml_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "testing" 4 | 5 | func TestYAMLToJSON(t *testing.T) { 6 | tests := map[string]struct { 7 | input []byte 8 | expected string 9 | expectedError bool 10 | }{ 11 | "empty": { 12 | input: []byte(""), 13 | expected: "", 14 | expectedError: false, 15 | }, 16 | "simple yaml": { 17 | input: []byte("key: value"), 18 | expected: `{"key":"value"}`, 19 | expectedError: false, 20 | }, 21 | "nested yaml": { 22 | input: []byte("parent:\n child: value"), 23 | expected: `{"parent":{"child":"value"}}`, 24 | expectedError: false, 25 | }, 26 | "invalid yaml": { 27 | input: []byte("invalid: yaml: : :"), 28 | expectedError: true, 29 | }, 30 | "array yaml": { 31 | input: []byte("items:\n - item1\n - item2"), 32 | expected: `{"items":["item1","item2"]}`, 33 | expectedError: false, 34 | }, 35 | "complex yaml": { 36 | input: []byte("bool: true\nnum: 123\nstr: hello\nobj:\n nested: value\narr:\n - 1\n - 2"), 37 | expected: `{"arr":[1,2],"bool":true,"num":123,"obj":{"nested":"value"},"str":"hello"}`, 38 | expectedError: false, 39 | }, 40 | } 41 | 42 | for name, tt := range tests { 43 | t.Run(name, func(t *testing.T) { 44 | output, err := YAMLToJSON(tt.input) 45 | 46 | if tt.expectedError && err == nil { 47 | t.Error("expected error but got none") 48 | } 49 | if !tt.expectedError && err != nil { 50 | t.Errorf("unexpected error: %v", err) 51 | } 52 | if output != tt.expected { 53 | t.Errorf("expected output '%v', got '%v'", tt.expected, output) 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /docs/assets/demo.flagd.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://flagd.dev/schema/v0/flags.json", 3 | "flags": { 4 | "show-welcome-banner": { 5 | "state": "ENABLED", 6 | "variants": { 7 | "on": true, 8 | "off": false 9 | }, 10 | "defaultVariant": "off" 11 | }, 12 | "background-color": { 13 | "state": "ENABLED", 14 | "variants": { 15 | "red": "#FF0000", 16 | "blue": "#0000FF", 17 | "green": "#00FF00", 18 | "yellow": "#FFFF00" 19 | }, 20 | "defaultVariant": "red" 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /docs/assets/extra.css: -------------------------------------------------------------------------------- 1 | /* same trim colors as openfeature.dev */ 2 | :root { 3 | --md-primary-fg-color: #5d5dff; 4 | --md-primary-fg-color--light: #5d5dff; 5 | --md-primary-fg-color--dark: #5d5dff; 6 | --md-accent-fg-color: #8d8dff; 7 | --md-accent-fg-color--transparent:rgba(141,141,255,0.1); 8 | } 9 | 10 | /* override slate (dark) to match openfeature.dev */ 11 | [data-md-color-scheme="slate"] { 12 | --md-default-fg-color: hsla(240, 15%, 90%, 0.82); 13 | --md-default-fg-color--light: hsla(240, 15%, 90%, 0.56); 14 | --md-default-fg-color--lighter: hsla(240, 15%, 90%, 0.32); 15 | --md-default-fg-color--lightest: hsla(240, 15%, 90%, 0.12); 16 | --md-default-bg-color: hsla(240, 4%, 11%); 17 | --md-default-bg-color--light: hsla(240, 4%, 11%, 0.54); 18 | --md-default-bg-color--lighter: hsla(240, 4%, 11%, 0.26); 19 | --md-default-bg-color--lightest: hsla(240, 4%, 11%, 0.07); 20 | } 21 | button:disabled { 22 | opacity: 0.5; 23 | cursor: not-allowed !important; 24 | } 25 | 26 | .output { 27 | transition: opacity .5s ease-in-out; 28 | opacity: 0; 29 | } 30 | 31 | .output.visible { 32 | opacity: 1; 33 | } -------------------------------------------------------------------------------- /docs/assets/logo-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: flagd faq 3 | --- 4 | 5 | # Frequently Asked Questions 6 | 7 | > Why do I need this? Can't I just use environment variables? 8 | 9 | Feature flags are not environment variables. 10 | If you need to update your flag values without restarting your application, target specific users, randomly assign values for experimentation, or perform scheduled roll-outs, you should consider using feature flags. 11 | If the values are always static, an environment variable or static configuration may be sufficient. 12 | 13 | For more information on feature-flagging concepts, see [feature-flagging](./concepts/feature-flagging.md). 14 | 15 | --- 16 | 17 | > Why is it called "flagd"? 18 | 19 | Please see [naming](./reference/naming.md). 20 | 21 | --- 22 | 23 | > What is flagd's relationship to OpenFeature? 24 | 25 | flagd is sub-project of OpenFeature and aims to be fully [OpenFeature-compliant](./concepts/feature-flagging.md#openfeature-compliance). 26 | 27 | --- 28 | 29 | > How do I run flagd? 30 | 31 | You can run flagd as a standalone application, accessible over HTTP or gRPC, or you can embed it into your application. 32 | Please see [architecture](./architecture.md) and [installation](./installation.md) for more information. 33 | 34 | --- 35 | 36 | > How can I access the SBOM for flagd? 37 | 38 | SBOMs for the flagd binary are available as assets on the [GitHub release page](https://github.com/open-feature/flagd/releases). 39 | Container SBOMs can be inspected using the Docker CLI. 40 | 41 | An example of inspecting the SBOM for the latest flagd `linux/amd64` container image: 42 | 43 | ```shell 44 | docker buildx imagetools inspect ghcr.io/open-feature/flagd:latest \ 45 | --format '{{ json (index .SBOM "linux/amd64").SPDX }}' 46 | ``` 47 | 48 | An example of inspecting the SBOM for the latest flagd `linux/arm64` container image: 49 | 50 | ```shell 51 | docker buildx imagetools inspect ghcr.io/open-feature/flagd:latest \ 52 | --format '{{ json (index .SBOM "linux/arm64").SPDX }}' 53 | ``` 54 | 55 | --- 56 | 57 | > Why doesn't flagd support {_my desired feature_}? 58 | 59 | Because you haven't opened a PR or created an issue! 60 | 61 | We're always adding new functionality to flagd, and welcome additions and ideas from new contributors. 62 | Don't hesitate to [open an issue](https://github.com/open-feature/flagd/issues)! 63 | -------------------------------------------------------------------------------- /docs/images/flagd-logical-architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/flagd/761d870a3c563b8eb1b83ee543b41316c98a1d48/docs/images/flagd-logical-architecture.jpg -------------------------------------------------------------------------------- /docs/images/flagd-telemetry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/flagd/761d870a3c563b8eb1b83ee543b41316c98a1d48/docs/images/flagd-telemetry.png -------------------------------------------------------------------------------- /docs/images/of-flagd-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/flagd/761d870a3c563b8eb1b83ee543b41316c98a1d48/docs/images/of-flagd-0.png -------------------------------------------------------------------------------- /docs/images/of-flagd-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/flagd/761d870a3c563b8eb1b83ee543b41316c98a1d48/docs/images/of-flagd-1.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ## What is flagd? 4 | 5 | _flagd_ is a _feature flag evaluation engine_. 6 | Think of it as a ready-made, open source, OpenFeature-compliant feature flag backend system. 7 | 8 | With flagd, you can: 9 | 10 | * modify flags in real-time 11 | * define flags of various types (boolean, string, number, JSON) 12 | * use context-sensitive rules to target specific users or user traits 13 | * perform pseudorandom assignments for experimentation 14 | * perform progressive roll-outs of new features 15 | * aggregate flag definitions from multiple sources 16 | * expose aggregated flags as a [gRPC stream](./reference/grpc-sync-service.md) to be used by in-process providers 17 | * expose [OFREP service](./reference/flagd-ofrep.md) for configured flags 18 | 19 | It doesn't include a UI, management console or a persistence layer. 20 | It's configurable entirely via a [POSIX-style CLI](./reference/flagd-cli/flagd.md). 21 | Thanks to its minimalism, it's _extremely flexible_; you can leverage flagd as a sidecar alongside your application, an engine running in your application process, or as a central service evaluating thousands of flags per second. 22 | 23 | ## How do I deploy flagd? 24 | 25 | flagd is designed to fit well into a variety of infrastructures and can run on various architectures. 26 | It runs as a separate process or directly in your application (see [architecture](./architecture.md)). 27 | It's distributed as a binary, container image, and various libraries (see [installation](./installation.md)). 28 | If you're already leveraging containers in your infrastructure, you can extend the docker image with your required configuration. 29 | You can also run flagd as a service on a VM or a "bare-metal" host. 30 | If you'd prefer not to run an additional process at all, you can run the flagd evaluation engine directly in your application. 31 | No matter how you run flagd, you will need to supply it with feature flags. 32 | The [flag definitions](./reference/flag-definitions.md) supplied to flagd are monitored for changes which will be immediately reflected in flagd's evaluations. 33 | Currently supported sources include files, HTTP endpoints, Kubernetes custom resources, and proto-compliant gRPC services (see [syncs](./concepts/syncs.md), [sync configuration](./reference/sync-configuration.md)). 34 | 35 | ## How do I use flagd? 36 | 37 | flagd is fully [OpenFeature compliant](./concepts/feature-flagging.md#openfeature-compliance). 38 | To leverage it in your application you must use the OpenFeature SDK and flagd provider for your language. 39 | You can configure the provider to connect to a flagd instance you deployed earlier (evaluating flags over gRPC) or use the in-process evaluation engine to do flag evaluations directly in your application. 40 | Once you've configured the OpenFeature SDK, you can start evaluating the feature flags configured in your flagd definitions. 41 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: installing flagd 3 | --- 4 | 5 | # Installation 6 | 7 | ## Docker 8 | 9 | :octicons-terminal-24: Install from the command line: 10 | 11 | ```shell 12 | docker pull ghcr.io/open-feature/flagd:latest 13 | ``` 14 | 15 | :octicons-code-square-24: Use as base image in Dockerfile: 16 | 17 | ```dockerfile 18 | FROM ghcr.io/open-feature/flagd:latest 19 | ``` 20 | 21 | ## Kubernetes 22 | 23 | flagd was designed with cloud-native paradigms in mind. 24 | You can run it as a sidecar, or as a central service in your cluster. 25 | If you're interested in a full-featured solution for using flagd in Kubernetes, consider the [OpenFeature operator](https://github.com/open-feature/open-feature-operator). 26 | 27 | For more information, see [OpenFeature Operator](./reference/openfeature-operator/overview.md). 28 | 29 | --- 30 | 31 | ## Binary 32 | 33 | :fontawesome-brands-linux::fontawesome-brands-windows::fontawesome-brands-apple: Binaries are available in x86/ARM. 34 | 35 | [Release](https://github.com/open-feature/flagd/releases) 36 | 37 | ### systemd 38 | 39 | A systemd wrapper is available [here](https://github.com/open-feature/flagd/blob/main/systemd/flagd.service). 40 | 41 | ### Homebrew 42 | 43 | ```shell 44 | brew install flagd 45 | ``` 46 | 47 | ### Go binary 48 | 49 | ```shell 50 | go install github.com/open-feature/flagd/flagd@latest 51 | ``` 52 | 53 | ## Summary 54 | 55 | Once flagd is installed, you can start using it within your application. 56 | Check out the [OpenFeature providers page](./providers/index.md) to learn more. 57 | -------------------------------------------------------------------------------- /docs/playground/index.md: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | -------------------------------------------------------------------------------- /docs/providers/dotnet.md: -------------------------------------------------------------------------------- 1 | # .NET provider 2 | 3 | ## Installation 4 | 5 | {% 6 | include "https://raw.githubusercontent.com/open-feature/dotnet-sdk-contrib/main/src/OpenFeature.Contrib.Providers.Flagd/README.md" 7 | start="## Install dependencies" 8 | %} 9 | -------------------------------------------------------------------------------- /docs/providers/go.md: -------------------------------------------------------------------------------- 1 | # Go provider 2 | 3 | ## Installation 4 | 5 | {% 6 | include "https://raw.githubusercontent.com/open-feature/go-sdk-contrib/main/providers/flagd/README.md" 7 | start="## Installation" 8 | end="## License" 9 | %} 10 | -------------------------------------------------------------------------------- /docs/providers/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: OpenFeature Providers 3 | description: Overview of the available flagd providers compatible with OpenFeature. 4 | --- 5 | 6 | flagd was built from the ground up to be [Openfeature-compliant](../concepts/feature-flagging.md#openfeature-compliance). 7 | To use it in your application, you must use the [OpenFeature SDK](https://openfeature.dev/docs/reference/technologies/) for your language, along with the associated OpenFeature _provider_. 8 | For more information about Openfeature providers, see the [OpenFeature documentation](https://openfeature.dev/docs/reference/concepts/provider). 9 | 10 | ## Providers 11 | 12 | Providers for flagd come in two flavors: those that are built to communicate with a flagd instance (over HTTP or gRPC) and those that embed flagd's evaluation engine directly (note that some providers are capable of operating in either mode). For more information on how to deploy and use flagd, see [architecture](../architecture.md) and [installation](../installation.md). 13 | 14 | The following table lists all the available flagd providers. 15 | 16 | | Technology | RPC | in-process | 17 | | --------------------------------------------------- | ---------------- | ---------------- | 18 | | :fontawesome-brands-golang: [Go](./go.md) | :material-check: | :material-check: | 19 | | :fontawesome-brands-java: [Java](./java.md) | :material-check: | :material-check: | 20 | | :fontawesome-brands-node-js: [Node.JS](./nodejs.md) | :material-check: | :material-check: | 21 | | :simple-php: [PHP](./php.md) | :material-check: | :material-close: | 22 | | :simple-dotnet: [.NET](./dotnet.md) | :material-check: | :material-check: | 23 | | :simple-python: [Python](./python.md) | :material-check: | :material-check: | 24 | | :fontawesome-brands-rust: [Rust](./rust.md) | :material-check: | :material-check: | 25 | | :material-web: [Web](./web.md) | :material-check: | :material-close: | 26 | 27 | For information on implementing a flagd provider, see the [specification](../reference/specifications/providers.md). -------------------------------------------------------------------------------- /docs/providers/java.md: -------------------------------------------------------------------------------- 1 | # Java provider 2 | 3 | ## Installation 4 | 5 | {% 6 | include "https://raw.githubusercontent.com/open-feature/java-sdk-contrib/main/providers/flagd/README.md" 7 | start="## Installation" 8 | %} 9 | -------------------------------------------------------------------------------- /docs/providers/nodejs.md: -------------------------------------------------------------------------------- 1 | # Node.js provider 2 | 3 | ## Installation 4 | 5 | {% 6 | include "https://raw.githubusercontent.com/open-feature/js-sdk-contrib/main/libs/providers/flagd/README.md" 7 | start="## Installation" 8 | end="## Building" 9 | %} 10 | -------------------------------------------------------------------------------- /docs/providers/php.md: -------------------------------------------------------------------------------- 1 | # PHP provider 2 | 3 | ## Installation 4 | 5 | {% 6 | include "https://raw.githubusercontent.com/open-feature/php-sdk-contrib/main/providers/Flagd/README.md" 7 | start="## Installation" 8 | %} 9 | -------------------------------------------------------------------------------- /docs/providers/python.md: -------------------------------------------------------------------------------- 1 | # Python provider 2 | 3 | ## Installation 4 | 5 | {% 6 | include "https://raw.githubusercontent.com/open-feature/python-sdk-contrib/main/providers/openfeature-provider-flagd/README.md" 7 | start="## Installation" 8 | end="## License" 9 | %} 10 | -------------------------------------------------------------------------------- /docs/providers/rust.md: -------------------------------------------------------------------------------- 1 | # Rust provider 2 | 3 | ## Installation 4 | 5 | {% 6 | include "https://raw.githubusercontent.com/open-feature/rust-sdk-contrib/refs/heads/main/crates/flagd/README.md" 7 | start="### Installation" 8 | end="### License" 9 | %} 10 | -------------------------------------------------------------------------------- /docs/providers/web.md: -------------------------------------------------------------------------------- 1 | 2 | # Web provider 3 | 4 | ## Installation 5 | 6 | {% 7 | include "https://raw.githubusercontent.com/open-feature/js-sdk-contrib/main/libs/providers/flagd-web/README.md" 8 | start="## Installation" 9 | end="## Building" 10 | %} 11 | -------------------------------------------------------------------------------- /docs/reference/custom-operations/semver-operation.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: flagd semver custom operation 3 | --- 4 | 5 | # Semantic Version Operation 6 | 7 | OpenFeature allows clients to pass contextual information which can then be used during a flag evaluation. For example, a client could pass the email address of the user. 8 | 9 | In some scenarios, it is desirable to use that contextual information to segment the user population further and thus return dynamic values. 10 | 11 | The `sem_ver` evaluation checks if the given property matches a semantic versioning condition. 12 | It returns 'true', if the value of the given property meets the condition, 'false' if not. 13 | Note that the 'sem_ver' evaluation rule must contain exactly three items: 14 | 15 | 1. Target property: this needs which both resolve to a semantic versioning string 16 | 2. Operator: One of the following: `=`, `!=`, `>`, `<`, `>=`, `<=`, `~` (match minor version), `^` (match major version) 17 | 3. Target value: this needs which both resolve to a semantic versioning string 18 | 19 | The `sem_ver` evaluation returns a boolean, indicating whether the condition has been met. 20 | 21 | ```js 22 | { 23 | "if": [ 24 | { 25 | "sem_ver": [{"var": "version"}, ">=", "1.0.0"] 26 | }, 27 | "red", null 28 | ] 29 | } 30 | ``` 31 | 32 | ## Example for 'sem_ver' Evaluation 33 | 34 | Flags defined as such: 35 | 36 | ```json 37 | { 38 | "$schema": "https://flagd.dev/schema/v0/flags.json", 39 | "flags": { 40 | "headerColor": { 41 | "variants": { 42 | "red": "#FF0000", 43 | "blue": "#0000FF", 44 | "green": "#00FF00" 45 | }, 46 | "defaultVariant": "blue", 47 | "state": "ENABLED", 48 | "targeting": { 49 | "if": [ 50 | { 51 | "sem_ver": [{"var": "version"}, ">=", "1.0.0"] 52 | }, 53 | "red", "green" 54 | ] 55 | } 56 | } 57 | } 58 | } 59 | ``` 60 | 61 | will return variant `red`, if the value of the `version` is a semantic version that is greater than or equal to `1.0.0`. 62 | 63 | Command: 64 | 65 | ```shell 66 | curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"version": "1.0.1"}}' -H "Content-Type: application/json" 67 | ``` 68 | 69 | Result: 70 | 71 | ```json 72 | {"value":"#00FF00","reason":"TARGETING_MATCH","variant":"red"} 73 | ``` 74 | 75 | Command: 76 | 77 | ```shell 78 | curl -X POST "localhost:8013/flagd.evaluation.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"version": "0.1.0"}}' -H "Content-Type: application/json" 79 | ``` 80 | 81 | Result: 82 | 83 | ```shell 84 | {"value":"#0000FF","reason":"TARGETING_MATCH","variant":"green"} 85 | ``` 86 | -------------------------------------------------------------------------------- /docs/reference/flagd-cli/flagd.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## flagd 4 | 5 | Flagd is a simple command line tool for fetching and presenting feature flags to services. It is designed to conform to Open Feature schema for flag definitions. 6 | 7 | ### Options 8 | 9 | ``` 10 | --config string config file (default is $HOME/.agent.yaml) 11 | -x, --debug verbose logging 12 | -h, --help help for flagd 13 | ``` 14 | 15 | ### SEE ALSO 16 | 17 | * [flagd start](flagd_start.md) - Start flagd 18 | * [flagd version](flagd_version.md) - Print the version number of flagd 19 | 20 | -------------------------------------------------------------------------------- /docs/reference/flagd-cli/flagd_version.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## flagd version 4 | 5 | Print the version number of flagd 6 | 7 | ``` 8 | flagd version [flags] 9 | ``` 10 | 11 | ### Options 12 | 13 | ``` 14 | -h, --help help for version 15 | ``` 16 | 17 | ### Options inherited from parent commands 18 | 19 | ``` 20 | --config string config file (default is $HOME/.agent.yaml) 21 | -x, --debug verbose logging 22 | ``` 23 | 24 | ### SEE ALSO 25 | 26 | * [flagd](flagd.md) - Flagd is a simple command line tool for fetching and presenting feature flags to services. It is designed to conform to Open Feature schema for flag definitions. 27 | 28 | -------------------------------------------------------------------------------- /docs/reference/flagd-ofrep.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: OpenFeature Remote Evaluation Protocol with flagd 3 | --- 4 | 5 | # Overview 6 | 7 | ![EXPERIMENTAL](https://img.shields.io/badge/status-experimental-red) 8 | 9 | flagd supports the [OpenFeature Remote Evaluation Protocol](https://github.com/open-feature/protocol) for flag evaluations. 10 | The service starts on port `8016` by default and this can be changed using startup flag `--ofrep-port` (or `-r` shothand flag). 11 | 12 | ## Usage 13 | 14 | Given flagd is running with flag configuration for `myBoolFlag`, you can evaluate the flag with OFREP API with following curl request, 15 | 16 | ```shell 17 | curl -X POST 'http://localhost:8016/ofrep/v1/evaluate/flags/myBoolFlag' 18 | ``` 19 | 20 | To evaluate all flags currently configured at flagd, use OFREP bulk evaluation request, 21 | 22 | ```shell 23 | curl -X POST 'http://localhost:8016/ofrep/v1/evaluate/flags' 24 | ``` 25 | -------------------------------------------------------------------------------- /docs/reference/grpc-sync-service.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: flagd as a gRPC sync service 3 | --- 4 | 5 | # Overview 6 | 7 | flagd can expose a gRPC sync service, allowing in-process providers to obtain their flag definitions. 8 | The gRPC sync stream contains flag definitions currently configured at flagd as [sync-configurations](./sync-configuration.md). 9 | 10 | ```mermaid 11 | --- 12 | title: gRPC sync 13 | --- 14 | erDiagram 15 | flagd ||--o{ "sync (file)" : watches 16 | flagd ||--o{ "sync (http)" : polls 17 | flagd ||--o{ "sync (grpc)" : "sync.proto (gRPC/stream)" 18 | flagd ||--o{ "sync (kubernetes)" : watches 19 | "In-Process provider" ||--|| flagd : "gRPC sync stream (default port 8015)" 20 | ``` 21 | 22 | You may change the default port of the service using startup flag `--sync-port` (or `-g` shothand flag). 23 | 24 | By default, the gRPC stream exposes all the flag configurations, with conflicting flag keys merged following flag's standard merge strategy. 25 | You can read more about the merge strategy in our dedicated [concepts guide on syncs](../concepts/syncs.md). 26 | 27 | If you specify a `selector` in the gRPC sync request, the gRPC service will attempt match the provided selector value to a source, and stream just the flags identified in that source. 28 | For example, if `selector` is set to `myFlags.json`, service will stream flags observed from `myFlags.json` file. 29 | Note that, to observe flags from `myFlags.json` file, you may use startup option `uri` like `--uri myFlags.json` or `source` option `--sources='[{"uri":"myFlags.json", provider":"file"}]`. 30 | And the request will fail if there is no flag source matching the requested `selector`. 31 | 32 | flagd provider implementations expose the ability to define the `selector` value. Please consider below example for Java, 33 | 34 | ```java 35 | final FlagdProvider flagdProvider = 36 | new FlagdProvider(FlagdOptions.builder() 37 | .resolverType(Config.Evaluator.IN_PROCESS) 38 | .host("localhost") 39 | .port(8015) 40 | .selector("myFlags.json") 41 | .build()); 42 | ``` 43 | -------------------------------------------------------------------------------- /docs/reference/naming.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: naming conventions for flagd and associated artifacts 3 | --- 4 | 5 | # Naming 6 | 7 | _flagd_ was conceived as a simple program with a POSIX-style CLI that's designed to run as a service or [daemon](https://en.wikipedia.org/wiki/Daemon_(computing)). 8 | For this reason, its name is stylized as all lower case: "flagd", consistent with other famous Unix/Linux daemons (_crond_, _sshd_, _systemd_, _ntpd_, _httpd_, etc). 9 | Although the flagd system has expanded beyond the flagd application itself to include libraries which embed flagd's evaluation engine, the "flagd" stylization should be observed for all components relating to flagd. 10 | Where possible, please treat "flagd" as a single word, including in library names, packages, variable names, etc. 11 | -------------------------------------------------------------------------------- /docs/reference/openfeature-operator/overview.md: -------------------------------------------------------------------------------- 1 | # OpenFeature Operator 2 | 3 | The OpenFeature Operator provides a convenient way to use flagd in your Kubernetes cluster. 4 | It allows you to define feature flags as custom resources, inject flagd as a sidecar, and more. 5 | Please see the [installation guide](https://github.com/open-feature/open-feature-operator/blob/main/docs/installation.md) to get started. 6 | -------------------------------------------------------------------------------- /docs/reference/schema.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: flagd flag and targeting schemas 3 | --- 4 | 5 | # Schema 6 | 7 | ## Flags 8 | 9 | A comprehensive JSON schema is available for flagd configuration at [https://flagd.dev/schema/v0/flags.json](https://flagd.dev/schema/v0/flags.json). 10 | It comprises definitions for flags as well as targeting. 11 | You can use this schema to validate flagd configurations by using any JSON Schema validation library compliant with [JSON Schema draft-07](https://json-schema.org/draft-07/schema#). 12 | _Additionally, most IDEs will automatically validate JSON documents if the document contains a `$schema` key and the schema is available at the specified URL_. 13 | 14 | The example below is automatically validated in most IDEs: 15 | 16 | ```json hl_lines="2" 17 | { 18 | "$schema": "https://flagd.dev/schema/v0/flags.json", 19 | "flags": { 20 | "basic-flag": { 21 | "state": "ENABLED", 22 | "variants": { 23 | "on": true, 24 | "off": false 25 | }, 26 | "defaultVariant": "on" 27 | }, 28 | "fractional-flag": { 29 | "state": "ENABLED", 30 | "variants": { 31 | "clubs": "clubs", 32 | "diamonds": "diamonds", 33 | "hearts": "hearts", 34 | "spades": "spades", 35 | "wild": "wild" 36 | }, 37 | "defaultVariant": "wild", 38 | "targeting": { 39 | "fractional": [ 40 | { "var": "email" }, 41 | ["clubs", 25], 42 | ["diamonds", 25], 43 | ["hearts", 25], 44 | ["spades", 25] 45 | ] 46 | } 47 | } 48 | } 49 | } 50 | ``` 51 | 52 | ## Targeting 53 | 54 | In addition to the _flags_ schema above, there's a schema available specifically for flagd _targeting rules_ at [https://flagd.dev/schema/v0/targeting.json](https://flagd.dev/schema/v0/targeting.json). 55 | This validates only the `targeting` property of a flag. 56 | **Please note that the flags schema also validates the targeting for each flag**, so it's not necessary to specifically use the targeting schema unless you which to validate a targeting field individually. 57 | -------------------------------------------------------------------------------- /docs/reference/specifications/custom-operations/semver-operation-spec.md: -------------------------------------------------------------------------------- 1 | # Semantic Versioning Operation Specification 2 | 3 | This evaluator checks if the given property within the evaluation context matches a semantic versioning condition. 4 | It returns 'true', if the value of the given property meets the condition, 'false' if not. 5 | 6 | ```js 7 | { 8 | "if": [ 9 | { 10 | "sem_ver": [{"var": "version"}, ">=", "1.0.0"] 11 | }, 12 | "red", null 13 | ] 14 | } 15 | ``` 16 | 17 | The implementation of this evaluator should accept the object containing the `sem_ver` evaluator 18 | configuration, and a `data` object containing the evaluation context. 19 | The 'sem_ver' evaluation rule contains exactly three items: 20 | 21 | 1. Target property value: the resolved value of the target property referenced in the targeting rule 22 | 2. Operator: One of the following: `=`, `!=`, `>`, `<`, `>=`, `<=`, `~` (match minor version), `^` (match major version) 23 | 3. Target value: this needs to resolve to a semantic versioning string. If this condition is not met, the evaluator should 24 | log an appropriate error message and return `false` 25 | 26 | The `sem_ver` evaluation returns a boolean, indicating whether the condition has been met. 27 | 28 | Please note that the implementation of this evaluator can assume that instead of `{"var": "version"}`, it will receive 29 | the resolved value of that referenced property, as resolving the value will be taken care of by JsonLogic before 30 | applying the evaluator. 31 | 32 | The following flow chart depicts the logic of this evaluator: 33 | 34 | ```mermaid 35 | flowchart TD 36 | A[Parse targetingRule] --> B{Is an array containing exactly three items?}; 37 | B -- Yes --> C{Is targetingRule at index 0 a semantic version string?}; 38 | B -- No --> D[Return false]; 39 | C -- Yes --> E{Is targetingRule at index 1 a supported operator?}; 40 | C -- No --> D; 41 | E -- Yes --> F{Is targetingRule at index 2 a semantic version string?}; 42 | E -- No --> D; 43 | F -- No --> D; 44 | F --> G[Compare the two versions using the operator and return a boolean value indicating if they match]; 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/reference/specifications/custom-operations/string-comparison-operation-spec.md: -------------------------------------------------------------------------------- 1 | # Starts-With / Ends-With Operation Specification 2 | 3 | This evaluator selects a variant based on whether the specified property within the evaluation context 4 | starts/ends with a certain string. 5 | 6 | ```js 7 | // starts_with property name used in a targeting rule 8 | "starts_with": [ 9 | // Evaluation context property the be evaluated 10 | {"var": "email"}, 11 | // prefix that has to be present in the value of the referenced property 12 | "user@faas" 13 | ] 14 | ``` 15 | 16 | The implementation of this evaluator should accept the object containing the `starts_with` or `ends_with` evaluator 17 | configuration, and a `data` object containing the evaluation context. 18 | The `starts_with`/`ends_with` evaluation rule contains exactly two items: 19 | 20 | 1. The resolved string value from the evaluation context 21 | 2. The target string value 22 | 23 | The `starts_with`/`ends_with` evaluation returns a boolean, indicating whether the condition has been met. 24 | 25 | Please note that the implementation of this evaluator can assume that instead of `{"var": "email"}`, it will receive 26 | the resolved value of that referenced property, as resolving the value will be taken care of by JsonLogic before 27 | applying the evaluator. 28 | 29 | The following flow chart depicts the logic of this evaluator: 30 | 31 | ```mermaid 32 | flowchart TD 33 | A[Parse targetingRule] --> B{Is an array containing exactly two items?}; 34 | B -- Yes --> C{Is targetingRule at index 0 a string?}; 35 | B -- No --> D[Return false]; 36 | C -- Yes --> E{Is targetingRule at index 1 a string?}; 37 | C -- No --> D; 38 | E -- No --> D; 39 | E --> F[Return a boolean value indicating if the first string starts/ends with the second string]; 40 | ``` 41 | -------------------------------------------------------------------------------- /flagd-proxy/README.md: -------------------------------------------------------------------------------- 1 | # Kube Flagd Proxy 2 | 3 | [![experimental](http://badges.github.io/stability-badges/dist/experimental.svg)](http://github.com/badges/stability-badges) 4 | 5 | The kube flagd proxy acts as a pub sub for deployed flagd sidecar containers to subscribe to change events in FeatureFlag CRs. 6 | 7 |

8 | 9 |

10 | 11 | On request, the flagd-proxy will spawn a goroutine to watch the CR using the `core` package Kubernetes sync. Each further request for the same resource will add a new stream to the broadcast list. Once all streams have been closed and there are no longer any listeners for a given resource, the sync will be closed. 12 | 13 | The flagd-proxy API follows the flagd grpc spec, found in the [buf schema registry](https://buf.build/open-feature/flagd), as such the existing grpc sync can be used to subscribe to the CR changes. 14 | 15 | ## Deployment 16 | 17 | The proxy can be deployed to any namespace, provided that the associated service account has been added to the `flagd-kubernetes-sync` cluster role binding. A sample deployment can be found in `/config/deployments/flagd-proxy` requiring the namespace `flagd-proxy` to be deployed. 18 | 19 | ```sh 20 | kubectl create namespace flagd-proxy 21 | kubectl apply -f ./config/deployments/flagd-proxy 22 | ``` 23 | 24 | Once the flagd-proxy has been deployed, any flagd instances subscribe to flag changes using the grpc sync, providing the target resource uri using the `selector` configuration field. 25 | 26 | ```yaml 27 | apiVersion: v1 28 | kind: Pod 29 | metadata: 30 | name: flagd 31 | spec: 32 | containers: 33 | - name: flagd 34 | image: ghcr.io/open-feature/flagd:latest 35 | ports: 36 | - containerPort: 8013 37 | args: 38 | - start 39 | - --sources 40 | - '[{"uri":"flagd-proxy-svc.flagd-proxy.svc.cluster.local:8015","provider":"grpc","selector":"core.openfeature.dev/NAMESPACE/NAME"}]' 41 | - --debug 42 | --- 43 | apiVersion: core.openfeature.dev/v1beta1 44 | kind: FeatureFlag 45 | metadata: 46 | name: end-to-end 47 | spec: 48 | flagSpec: 49 | flags: 50 | color: 51 | state: ENABLED 52 | variants: 53 | red: CC0000 54 | green: 00CC00 55 | blue: 0000CC 56 | yellow: yellow 57 | defaultVariant: yellow 58 | ``` 59 | 60 | Once deployed, the client flagd instance will receive almost instant flag configuration change events. 61 | -------------------------------------------------------------------------------- /flagd-proxy/build.Dockerfile: -------------------------------------------------------------------------------- 1 | # Main Dockerfile for flagd builds 2 | # Build the manager binary 3 | FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder 4 | 5 | WORKDIR /src 6 | 7 | ARG TARGETOS 8 | ARG TARGETARCH 9 | ARG VERSION 10 | ARG COMMIT 11 | ARG DATE 12 | 13 | # Download dependencies as a separate step to take advantage of Docker's caching. 14 | # Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. 15 | # Leverage bind mounts to go.sum and go.mod to avoid having to copy them into 16 | # the container. 17 | RUN --mount=type=cache,target=/go/pkg/mod/ \ 18 | --mount=type=bind,source=./core/go.mod,target=./core/go.mod \ 19 | --mount=type=bind,source=./core/go.sum,target=./core/go.sum \ 20 | --mount=type=bind,source=./flagd/go.mod,target=./flagd/go.mod \ 21 | --mount=type=bind,source=./flagd/go.sum,target=./flagd/go.sum \ 22 | --mount=type=bind,source=./flagd-proxy/go.mod,target=./flagd-proxy/go.mod \ 23 | --mount=type=bind,source=./flagd-proxy/go.sum,target=./flagd-proxy/go.sum \ 24 | go work init ./core ./flagd ./flagd-proxy && go mod download 25 | 26 | # Build the application. 27 | # Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. 28 | # Leverage a bind mount to the current directory to avoid having to copy the 29 | # source code into the container. 30 | RUN --mount=type=cache,target=/go/pkg/mod/ \ 31 | --mount=type=cache,target=/root/.cache/go-build \ 32 | --mount=type=bind,source=./core,target=./core \ 33 | --mount=type=bind,source=./flagd,target=./flagd \ 34 | --mount=type=bind,source=./flagd-proxy,target=./flagd-proxy \ 35 | CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /bin/flagd-proxy flagd-proxy/main.go 36 | 37 | # # Use distroless as minimal base image to package the manager binary 38 | # # Refer to https://github.com/GoogleContainerTools/distroless for more details 39 | FROM gcr.io/distroless/static:nonroot 40 | WORKDIR / 41 | COPY --from=builder /bin/flagd-proxy . 42 | USER 65532:65532 43 | 44 | ENTRYPOINT ["/flagd-proxy"] 45 | -------------------------------------------------------------------------------- /flagd-proxy/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/dimiro1/banner" 9 | "github.com/mattn/go-colorable" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | var ( 15 | cfgFile string 16 | Version string 17 | Commit string 18 | Date string 19 | Debug bool 20 | ) 21 | 22 | var rootCmd = &cobra.Command{ 23 | Use: "flagd", 24 | Short: "flagd-proxy allows flagd to subscribe to CRD changes without the required permissions.", 25 | Long: ``, 26 | DisableAutoGenTag: true, 27 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 28 | if viper.GetString(logFormatFlagName) == "console" { 29 | banner.InitString(colorable.NewColorableStdout(), true, true, ` 30 | {{ .AnsiColor.BrightRed }} ______ __ ________ _______ ______ 31 | {{ .AnsiColor.BrightRed }} /_____/\ /_/\ /_______/\ /______/\ /_____/\ 32 | {{ .AnsiColor.BrightRed }} \::::_\/_\:\ \ \::: _ \ \\::::__\/__\:::_ \ \ 33 | {{ .AnsiColor.BrightRed }} \:\/___/\\:\ \ \::(_) \ \\:\ /____/\\:\ \ \ \ 34 | {{ .AnsiColor.BrightRed }} \:::._\/ \:\ \____\:: __ \ \\:\\_ _\/ \:\ \ \ \ 35 | {{ .AnsiColor.BrightRed }} \:\ \ \:\/___/\\:.\ \ \ \\:\_\ \ \ \:\/.:| | 36 | {{ .AnsiColor.BrightRed }} \_\/ \_____\/ \__\/\__\/ \_____\/ \____/_/ 37 | {{ .AnsiColor.BrightRed }} Kubernetes Proxy 38 | {{ .AnsiColor.Default }} 39 | `) 40 | } 41 | }, 42 | } 43 | 44 | // Execute adds all child commands to the root command and sets flags appropriately. 45 | // This is called by main.main(). It only needs to happen once to the rootCmd. 46 | func Execute(version string, commit string, date string) { 47 | Version = version 48 | Commit = commit 49 | Date = date 50 | err := rootCmd.Execute() 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | } 55 | 56 | func init() { 57 | cobra.OnInitialize(initConfig) 58 | 59 | // Here you will define your flags and configuration settings. 60 | // Cobra supports persistent flags, which, if defined here, 61 | // will be global for your application. 62 | rootCmd.PersistentFlags().BoolVarP(&Debug, "debug", "x", false, "verbose logging") 63 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.agent.yaml)") 64 | rootCmd.AddCommand(startCmd) 65 | } 66 | 67 | // initConfig reads in config file and ENV variables if set. 68 | func initConfig() { 69 | if cfgFile != "" { 70 | // Use config file from the flag. 71 | viper.SetConfigFile(cfgFile) 72 | } else { 73 | // Find home directory. 74 | home, err := os.UserHomeDir() 75 | cobra.CheckErr(err) 76 | 77 | // Search config in home directory with name ".agent" (without extension). 78 | viper.AddConfigPath(home) 79 | viper.SetConfigType("yaml") 80 | viper.SetConfigName(".agent") 81 | } 82 | 83 | viper.AutomaticEnv() // read in environment variables that match 84 | 85 | // If a config file is found, read it in. 86 | if err := viper.ReadInConfig(); err == nil { 87 | fmt.Fprintln(os.Stderr, "using config file:", viper.ConfigFileUsed()) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /flagd-proxy/cmd/start.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | 13 | "github.com/open-feature/flagd/core/pkg/logger" 14 | iService "github.com/open-feature/flagd/core/pkg/service" 15 | "github.com/open-feature/flagd/flagd-proxy/pkg/service" 16 | "github.com/open-feature/flagd/flagd-proxy/pkg/service/subscriptions" 17 | "github.com/spf13/cobra" 18 | "github.com/spf13/viper" 19 | "go.uber.org/zap/zapcore" 20 | ) 21 | 22 | // start 23 | 24 | const ( 25 | logFormatFlagName = "log-format" 26 | managementPortFlagName = "management-port" 27 | portFlagName = "port" 28 | ) 29 | 30 | func init() { 31 | flags := startCmd.Flags() 32 | 33 | // allows environment variables to use _ instead of - 34 | flags.Int32P(portFlagName, "p", 8015, "Port to listen on") 35 | flags.Int32P(managementPortFlagName, "m", 8016, "Management port") 36 | flags.StringP(logFormatFlagName, "z", "console", "Set the logging format, e.g. console or json") 37 | 38 | _ = viper.BindPFlag(logFormatFlagName, flags.Lookup(logFormatFlagName)) 39 | _ = viper.BindPFlag(managementPortFlagName, flags.Lookup(managementPortFlagName)) 40 | _ = viper.BindPFlag(portFlagName, flags.Lookup(portFlagName)) 41 | } 42 | 43 | // startCmd represents the start command 44 | var startCmd = &cobra.Command{ 45 | Use: "start", 46 | Short: "Start flagd-proxy", 47 | Long: ``, 48 | Run: func(cmd *cobra.Command, args []string) { 49 | // Configure loggers ------------------------------------------------------- 50 | var level zapcore.Level 51 | var err error 52 | if Debug { 53 | level = zapcore.DebugLevel 54 | } else { 55 | level = zapcore.InfoLevel 56 | } 57 | l, err := logger.NewZapLogger(level, viper.GetString(logFormatFlagName)) 58 | if err != nil { 59 | log.Fatalf("can't initialize zap logger: %v", err) 60 | } 61 | logger := logger.NewLogger(l, Debug) 62 | 63 | ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 64 | 65 | syncStore := subscriptions.NewManager(ctx, logger) 66 | s := service.NewServer(ctx, logger, syncStore) 67 | 68 | cfg := iService.Configuration{ 69 | ReadinessProbe: func() bool { return true }, 70 | Port: viper.GetUint16(portFlagName), 71 | ManagementPort: viper.GetUint16(managementPortFlagName), 72 | } 73 | 74 | errChan := make(chan error, 1) 75 | go func() { 76 | if err := s.Serve(ctx, cfg); err != nil && !errors.Is(err, http.ErrServerClosed) { 77 | errChan <- err 78 | } 79 | }() 80 | 81 | logger.Info(fmt.Sprintf("listening for connections on %d", cfg.Port)) 82 | 83 | defer func() { 84 | logger.Info("Shutting down server...") 85 | s.Shutdown() 86 | logger.Info("Server successfully shutdown.") 87 | }() 88 | 89 | select { 90 | case <-ctx.Done(): 91 | return 92 | case err := <-errChan: 93 | logger.Fatal(err.Error()) 94 | } 95 | }, 96 | } 97 | -------------------------------------------------------------------------------- /flagd-proxy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/open-feature/flagd/flagd-proxy/cmd" 4 | 5 | var ( 6 | version = "dev" 7 | commit = "HEAD" 8 | date = "unknown" 9 | ) 10 | 11 | func main() { 12 | cmd.Execute(version, commit, date) 13 | } 14 | -------------------------------------------------------------------------------- /flagd-proxy/pkg/service/subscriptions/multiplexer.go: -------------------------------------------------------------------------------- 1 | package subscriptions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/open-feature/flagd/core/pkg/logger" 9 | sourceSync "github.com/open-feature/flagd/core/pkg/sync" 10 | ) 11 | 12 | // multiplexer distributes updates for a target to all of its subscribers 13 | type multiplexer struct { 14 | subs map[interface{}]storedChannels 15 | dataSync chan sourceSync.DataSync 16 | cancelFunc context.CancelFunc 17 | syncRef sourceSync.ISync 18 | mu *sync.RWMutex 19 | } 20 | 21 | func (h *multiplexer) broadcastError(logger *logger.Logger, err error) { 22 | h.mu.RLock() 23 | defer h.mu.RUnlock() 24 | for k, ec := range h.subs { 25 | select { 26 | case ec.errChan <- err: 27 | continue 28 | default: 29 | logger.Error(fmt.Sprintf("unable to write error to channel for key %p", k)) 30 | } 31 | } 32 | } 33 | 34 | func (h *multiplexer) broadcastData(logger *logger.Logger, data sourceSync.DataSync) { 35 | h.mu.RLock() 36 | defer h.mu.RUnlock() 37 | for k, ds := range h.subs { 38 | select { 39 | case ds.dataSync <- data: 40 | continue 41 | default: 42 | logger.Error(fmt.Sprintf("unable to write data to channel for key %p", k)) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /flagd-proxy/pkg/service/sync_metrics.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go.opentelemetry.io/otel/exporters/prometheus" 8 | api "go.opentelemetry.io/otel/metric" 9 | "go.opentelemetry.io/otel/sdk/metric" 10 | ) 11 | 12 | const ( 13 | serviceName = "flagd-proxy" 14 | ) 15 | 16 | func (s *Server) captureMetrics() error { 17 | exporter, err := prometheus.New() 18 | if err != nil { 19 | return fmt.Errorf("unable to create prometheus exporter: %w", err) 20 | } 21 | provider := metric.NewMeterProvider(metric.WithReader(exporter)) 22 | meter := provider.Meter(serviceName) 23 | 24 | syncGauge, err := meter.Int64ObservableGauge( 25 | "sync_active_streams", 26 | api.WithDescription("number of open sync subscriptions"), 27 | ) 28 | if err != nil { 29 | return fmt.Errorf("unable to create active subscription metric gauge: %w", err) 30 | } 31 | 32 | _, err = meter.RegisterCallback(func(_ context.Context, o api.Observer) error { 33 | o.ObserveInt64(syncGauge, s.handler.syncStore.GetActiveSubscriptionsInt64()) 34 | return nil 35 | }, syncGauge) 36 | if err != nil { 37 | return fmt.Errorf("unable to register active subscription metric callback: %w", err) 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /flagd-proxy/tests/loadtest/.gitignore: -------------------------------------------------------------------------------- 1 | profiling-results.json -------------------------------------------------------------------------------- /flagd-proxy/tests/loadtest/README.md: -------------------------------------------------------------------------------- 1 | # flagd Proxy Profiling 2 | 3 | This go module contains a profiling tool for the `flagd-proxy`. Starting `n` watchers against a single flag configuration resource to monitor the effects of server load and flag configuration definition size on the response time between a configuration change and all watchers receiving the configuration change. 4 | 5 | ## Pseudo Code 6 | 7 | 1. Parse configuration file referenced as the only startup argument 8 | 1. Loop for each defined repeat 9 | 1. Write to the target file using the start configuration 10 | 1. Start `n` watchers for the resource using a grpc sync definining the selector as `file:TARGET-FILE` 11 | 1. Wait for all watchers to receive their first configuration change event (which will contain the full configuration object) 12 | 1. Flush the change event channel to ensure there are no previous events 13 | 1. Trigger a configuration change event by writing the end configuration to the target file 14 | 1. Time how long it takes for all watchers to receive the new configuration 15 | 16 | ## Example 17 | 18 | run the flagd-proxy locally (from the project root): 19 | 20 | ```sh 21 | go run flagd-proxy/main.go start --port 8080 22 | ``` 23 | 24 | run the flagd-proxy-profiler (from the project root): 25 | 26 | ```sh 27 | go run flagd-proxy/tests/loadtest/main.go ./flagd-proxy/tests/loadtest/config/config.json 28 | ``` 29 | 30 | Once the tests have been run the results can be found in ./flagd-proxy/tests/loadtest/profiling-results.json 31 | 32 | ## Sample Configuration 33 | 34 | ```json 35 | { 36 | "triggerType": "filepath", 37 | "fileTriggerConfig": { 38 | "startFile":"./start-spec.json", 39 | "endFile":"./config/end-spec.json", 40 | "targetFile":"./target.json" 41 | }, 42 | "handlerConfig": { 43 | "filePath": "./target.json", 44 | "outFile":"./profiling-results.json", 45 | "host": "localhost", 46 | "port": 8080, 47 | }, 48 | "tests": [ 49 | { 50 | "watchers": 10000, 51 | "repeats": 5, 52 | "delay": 2000000000 53 | } 54 | ] 55 | } 56 | ``` 57 | -------------------------------------------------------------------------------- /flagd-proxy/tests/loadtest/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "fileTriggerConfig": { 3 | "startFile":"./flagd-proxy/tests/loadtest/config/start-spec.json", 4 | "endFile":"./flagd-proxy/tests/loadtest/config/end-spec.json", 5 | "targetFile":"./flagd-proxy/tests/loadtest/target.json" 6 | }, 7 | "handlerConfig": { 8 | "filePath": "./flagd-proxy/tests/loadtest/target.json", 9 | "outFile":"./flagd-proxy/tests/loadtest/profiling-results.json" 10 | }, 11 | "tests": [ 12 | { 13 | "watchers": 10, 14 | "repeats": 5, 15 | "delay": 2000000000 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /flagd-proxy/tests/loadtest/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/open-feature/flagd/flagd-proxy/tests/loadtest 2 | 3 | go 1.22 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | buf.build/gen/go/open-feature/flagd/grpc/go v1.3.0-20240215170432-1e611e2999cc.3 9 | buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.4-20250127221518-be6d1143b690.1 10 | google.golang.org/grpc v1.63.2 11 | ) 12 | 13 | require ( 14 | golang.org/x/net v0.25.0 // indirect 15 | golang.org/x/sys v0.20.0 // indirect 16 | golang.org/x/text v0.15.0 // indirect 17 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240506185236-b8a5c65736ae // indirect 18 | google.golang.org/protobuf v1.36.4 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /flagd-proxy/tests/loadtest/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | 11 | "github.com/open-feature/flagd/flagd-proxy/tests/loadtest/pkg/config" 12 | "github.com/open-feature/flagd/flagd-proxy/tests/loadtest/pkg/handler" 13 | itrigger "github.com/open-feature/flagd/flagd-proxy/tests/loadtest/pkg/trigger" 14 | trigger "github.com/open-feature/flagd/flagd-proxy/tests/loadtest/pkg/trigger/file" 15 | ) 16 | 17 | func main() { 18 | configFilepath := "" 19 | if len(os.Args) == 2 { 20 | configFilepath = os.Args[1] 21 | } 22 | 23 | ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 24 | cfg, err := config.NewConfig(configFilepath) 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | var trg itrigger.Trigger 29 | switch cfg.TriggerType { 30 | case config.FilepathTrigger: 31 | trg = trigger.NewFilePathTrigger(cfg.FileTriggerConfig) 32 | default: 33 | log.Fatalf("unrecognized trigger type %s", cfg.TriggerType) 34 | } 35 | 36 | h := handler.NewHandler(cfg.HandlerConfig, trg) 37 | results, err := h.Profile(ctx, cfg.Tests) 38 | fmt.Println(err, results) 39 | } 40 | -------------------------------------------------------------------------------- /flagd-proxy/tests/loadtest/pkg/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | 6 | syncv1 "buf.build/gen/go/open-feature/flagd/grpc/go/sync/v1/syncv1grpc" 7 | "google.golang.org/grpc" 8 | "google.golang.org/grpc/credentials/insecure" 9 | ) 10 | 11 | type Config struct { 12 | Host string 13 | Port uint16 14 | } 15 | 16 | func NewClient(config Config) (syncv1.FlagSyncServiceClient, error) { 17 | conn, err := grpc.NewClient( 18 | fmt.Sprintf( 19 | "%s:%d", 20 | config.Host, 21 | config.Port, 22 | ), 23 | grpc.WithTransportCredentials(insecure.NewCredentials()), 24 | ) 25 | if err != nil { 26 | return nil, fmt.Errorf("unable to create client connection: %w", err) 27 | } 28 | return syncv1.NewFlagSyncServiceClient(conn), nil 29 | } 30 | -------------------------------------------------------------------------------- /flagd-proxy/tests/loadtest/pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/open-feature/flagd/flagd-proxy/tests/loadtest/pkg/handler" 10 | trigger "github.com/open-feature/flagd/flagd-proxy/tests/loadtest/pkg/trigger/file" 11 | ) 12 | 13 | type TriggerType string 14 | 15 | const ( 16 | FilepathTrigger TriggerType = "filepath" 17 | 18 | defaultHost = "localhost" 19 | defaultPort uint16 = 8080 20 | defaultStartFile = "./config/start-spec.json" 21 | defaultEndFile = "./config/end-spec.json" 22 | defaultTargetFile = "./target.json" 23 | defaultTargetFileSource = "./target.json" 24 | defaultOutTarget = "./profiling-results.json" 25 | ) 26 | 27 | var defaultTests = []handler.TestConfig{ 28 | { 29 | Watchers: 1, 30 | Repeats: 5, 31 | Delay: time.Second * 1, 32 | }, 33 | { 34 | Watchers: 10, 35 | Repeats: 5, 36 | Delay: time.Second * 1, 37 | }, 38 | { 39 | Watchers: 100, 40 | Repeats: 5, 41 | Delay: time.Second * 1, 42 | }, 43 | { 44 | Watchers: 1000, 45 | Repeats: 5, 46 | Delay: time.Second * 1, 47 | }, 48 | { 49 | Watchers: 10000, 50 | Repeats: 5, 51 | Delay: time.Second * 1, 52 | }, 53 | } 54 | 55 | type Config struct { 56 | TriggerType TriggerType `json:"triggerType"` 57 | FileTriggerConfig trigger.FilePathTriggerConfig `json:"fileTriggerConfig"` 58 | HandlerConfig handler.Config `json:"handlerConfig"` 59 | Tests []handler.TestConfig 60 | } 61 | 62 | func NewConfig(filepath string) (*Config, error) { 63 | config := &Config{ 64 | TriggerType: FilepathTrigger, 65 | FileTriggerConfig: trigger.FilePathTriggerConfig{ 66 | StartFile: defaultStartFile, 67 | EndFile: defaultEndFile, 68 | TargetFile: defaultTargetFile, 69 | }, 70 | HandlerConfig: handler.Config{ 71 | FilePath: defaultTargetFileSource, 72 | Host: defaultHost, 73 | Port: defaultPort, 74 | OutFile: defaultOutTarget, 75 | }, 76 | Tests: defaultTests, 77 | } 78 | if filepath != "" { 79 | b, err := os.ReadFile(filepath) 80 | if err != nil { 81 | return nil, fmt.Errorf("unable to read config file %s: %w", filepath, err) 82 | } 83 | if err := json.Unmarshal(b, config); err != nil { 84 | return nil, fmt.Errorf("unable to unmarshal config: %w", err) 85 | } 86 | } 87 | return config, nil 88 | } 89 | -------------------------------------------------------------------------------- /flagd-proxy/tests/loadtest/pkg/trigger/file/trigger.go: -------------------------------------------------------------------------------- 1 | package trigger 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | type FilePathTrigger struct { 9 | config FilePathTriggerConfig 10 | } 11 | 12 | type FilePathTriggerConfig struct { 13 | StartFile string `json:"startFile"` 14 | EndFile string `json:"endFile"` 15 | TargetFile string `json:"targetFile"` 16 | } 17 | 18 | func NewFilePathTrigger(config FilePathTriggerConfig) *FilePathTrigger { 19 | return &FilePathTrigger{ 20 | config: config, 21 | } 22 | } 23 | 24 | func (f *FilePathTrigger) Setup() error { 25 | dat, err := os.ReadFile(f.config.StartFile) 26 | if err != nil { 27 | return fmt.Errorf("unable to read start file at %s: %w", f.config.StartFile, err) 28 | } 29 | if err = os.WriteFile(f.config.TargetFile, dat, 0o600); err != nil { 30 | return fmt.Errorf("unable to write start file: %w", err) 31 | } 32 | return nil 33 | } 34 | 35 | func (f *FilePathTrigger) Update() error { 36 | dat, err := os.ReadFile(f.config.EndFile) 37 | if err != nil { 38 | return fmt.Errorf("unable to read end file at %s: %w", f.config.EndFile, err) 39 | } 40 | if err = os.WriteFile(f.config.TargetFile, dat, 0o600); err != nil { 41 | return fmt.Errorf("unable to write end file: %w", err) 42 | } 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /flagd-proxy/tests/loadtest/pkg/trigger/trigger.go: -------------------------------------------------------------------------------- 1 | package trigger 2 | 3 | type Trigger interface { 4 | Setup() error 5 | Update() error 6 | } 7 | -------------------------------------------------------------------------------- /flagd-proxy/tests/loadtest/pkg/watcher/watcher.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "time" 8 | 9 | "buf.build/gen/go/open-feature/flagd/grpc/go/sync/v1/syncv1grpc" 10 | syncv1Types "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/sync/v1" 11 | ) 12 | 13 | const ( 14 | timeoutSeconds = 10 15 | ) 16 | 17 | type Watcher struct { 18 | client syncv1grpc.FlagSyncServiceClient 19 | //nolint:staticcheck 20 | Stream chan syncv1Types.SyncState 21 | Ready chan struct{} 22 | targetFile string 23 | } 24 | 25 | func NewWatcher(client syncv1grpc.FlagSyncServiceClient, target string) *Watcher { 26 | return &Watcher{ 27 | //nolint:staticcheck 28 | Stream: make(chan syncv1Types.SyncState, 1), 29 | client: client, 30 | Ready: make(chan struct{}), 31 | targetFile: target, 32 | } 33 | } 34 | 35 | //nolint:staticcheck 36 | func (w *Watcher) StartWatcher(ctx context.Context) error { 37 | stream, err := w.client.SyncFlags(ctx, &syncv1Types.SyncFlagsRequest{ 38 | Selector: fmt.Sprintf("file:%s", w.targetFile), 39 | }) 40 | if err != nil { 41 | return fmt.Errorf("unable to create stream: %w", err) 42 | } 43 | 44 | ready := false 45 | for { 46 | msg, err := stream.Recv() 47 | if err != nil { 48 | if err == io.EOF { 49 | return nil 50 | } 51 | return fmt.Errorf("unable to read payload from stream: %w", err) 52 | } 53 | w.Stream <- msg.State 54 | if !ready { 55 | ready = true 56 | close(w.Ready) 57 | } 58 | } 59 | } 60 | 61 | func (w *Watcher) Wait() error { 62 | w.drainChan() 63 | select { 64 | case <-time.After(timeoutSeconds * time.Second): 65 | return fmt.Errorf("timeout out after %d", timeoutSeconds) 66 | case <-w.Stream: 67 | return nil 68 | } 69 | } 70 | 71 | func (w *Watcher) drainChan() { 72 | for { 73 | select { 74 | case <-w.Stream: 75 | default: 76 | return 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /flagd/build.Dockerfile: -------------------------------------------------------------------------------- 1 | # Main Dockerfile for flagd builds 2 | # Build the manager binary 3 | FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder 4 | 5 | WORKDIR /src 6 | 7 | ARG TARGETOS 8 | ARG TARGETARCH 9 | ARG VERSION 10 | ARG COMMIT 11 | ARG DATE 12 | 13 | # Download dependencies as a separate step to take advantage of Docker's caching. 14 | # Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. 15 | # Leverage bind mounts to go.sum and go.mod to avoid having to copy them into 16 | # the container. 17 | RUN --mount=type=cache,target=/go/pkg/mod/ \ 18 | --mount=type=bind,source=./core/go.mod,target=./core/go.mod \ 19 | --mount=type=bind,source=./core/go.sum,target=./core/go.sum \ 20 | --mount=type=bind,source=./flagd/go.mod,target=./flagd/go.mod \ 21 | --mount=type=bind,source=./flagd/go.sum,target=./flagd/go.sum \ 22 | go work init ./core ./flagd && go mod download 23 | 24 | # Build the application. 25 | # Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. 26 | # Leverage a bind mount to the current directory to avoid having to copy the 27 | # source code into the container. 28 | RUN --mount=type=cache,target=/go/pkg/mod/ \ 29 | --mount=type=cache,target=/root/.cache/go-build \ 30 | --mount=type=bind,source=./core,target=./core \ 31 | --mount=type=bind,source=./flagd,target=./flagd \ 32 | CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /bin/flagd-build flagd/main.go 33 | 34 | # # Use distroless as minimal base image to package the manager binary 35 | # # Refer to https://github.com/GoogleContainerTools/distroless for more details 36 | FROM gcr.io/distroless/static:nonroot 37 | WORKDIR / 38 | COPY --from=builder /bin/flagd-build . 39 | USER 65532:65532 40 | 41 | ENTRYPOINT ["/flagd-build"] 42 | -------------------------------------------------------------------------------- /flagd/cmd/doc.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra/doc" 7 | ) 8 | 9 | // GenerateDoc generates cobra docs of the cmd 10 | func GenerateDoc(path string) error { 11 | linkHandler := func(name string) string { 12 | return name 13 | } 14 | 15 | filePrepender := func(filename string) string { 16 | return "\n\n" 17 | } 18 | 19 | if err := doc.GenMarkdownTreeCustom(rootCmd, path, filePrepender, linkHandler); err != nil { 20 | return fmt.Errorf("error generating docs: %w", err) 21 | } 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /flagd/cmd/doc/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/open-feature/flagd/flagd/cmd" 7 | ) 8 | 9 | const docPath = "../docs/reference/flagd-cli" 10 | 11 | func main() { 12 | if err := cmd.GenerateDoc(docPath); err != nil { 13 | log.Fatal(err) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /flagd/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "runtime/debug" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // versionCmd represents the version command 11 | var versionCmd = &cobra.Command{ 12 | Use: "version", 13 | Short: "Print the version number of flagd", 14 | Long: ``, 15 | Run: func(cmd *cobra.Command, args []string) { 16 | if Version == "dev" { 17 | details, ok := debug.ReadBuildInfo() 18 | if ok && details.Main.Version != "" && details.Main.Version != "(devel)" { 19 | Version = details.Main.Version 20 | for _, i := range details.Settings { 21 | if i.Key == "vcs.time" { 22 | Date = i.Value 23 | } 24 | if i.Key == "vcs.revision" { 25 | Commit = i.Value 26 | } 27 | } 28 | } 29 | } 30 | fmt.Printf("flagd: %s (%s), built at: %s\n", Version, Commit, Date) 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /flagd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/open-feature/flagd/flagd/cmd" 4 | 5 | var ( 6 | version = "dev" 7 | commit = "HEAD" 8 | date = "unknown" 9 | ) 10 | 11 | func main() { 12 | cmd.Execute(version, commit, date) 13 | } 14 | -------------------------------------------------------------------------------- /flagd/pkg/service/flag-evaluation/eventing.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "sync" 5 | 6 | iservice "github.com/open-feature/flagd/core/pkg/service" 7 | ) 8 | 9 | // IEvents is an interface for event subscriptions 10 | type IEvents interface { 11 | Subscribe(id any, notifyChan chan iservice.Notification) 12 | Unsubscribe(id any) 13 | EmitToAll(n iservice.Notification) 14 | } 15 | 16 | // eventingConfiguration is a wrapper for notification subscriptions 17 | type eventingConfiguration struct { 18 | mu *sync.RWMutex 19 | subs map[any]chan iservice.Notification 20 | } 21 | 22 | func (eventing *eventingConfiguration) Subscribe(id any, notifyChan chan iservice.Notification) { 23 | eventing.mu.Lock() 24 | defer eventing.mu.Unlock() 25 | 26 | eventing.subs[id] = notifyChan 27 | } 28 | 29 | func (eventing *eventingConfiguration) EmitToAll(n iservice.Notification) { 30 | eventing.mu.RLock() 31 | defer eventing.mu.RUnlock() 32 | 33 | for _, send := range eventing.subs { 34 | send <- n 35 | } 36 | } 37 | 38 | func (eventing *eventingConfiguration) Unsubscribe(id any) { 39 | eventing.mu.Lock() 40 | defer eventing.mu.Unlock() 41 | 42 | delete(eventing.subs, id) 43 | } 44 | -------------------------------------------------------------------------------- /flagd/pkg/service/flag-evaluation/eventing_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | iservice "github.com/open-feature/flagd/core/pkg/service" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestSubscribe(t *testing.T) { 12 | // given 13 | eventing := &eventingConfiguration{ 14 | subs: make(map[interface{}]chan iservice.Notification), 15 | mu: &sync.RWMutex{}, 16 | } 17 | 18 | idA := "a" 19 | chanA := make(chan iservice.Notification, 1) 20 | 21 | idB := "b" 22 | chanB := make(chan iservice.Notification, 1) 23 | 24 | // when 25 | eventing.Subscribe(idA, chanA) 26 | eventing.Subscribe(idB, chanB) 27 | 28 | // then 29 | require.Equal(t, chanA, eventing.subs[idA], "incorrect subscription association") 30 | require.Equal(t, chanB, eventing.subs[idB], "incorrect subscription association") 31 | } 32 | 33 | func TestUnsubscribe(t *testing.T) { 34 | // given 35 | eventing := &eventingConfiguration{ 36 | subs: make(map[interface{}]chan iservice.Notification), 37 | mu: &sync.RWMutex{}, 38 | } 39 | 40 | idA := "a" 41 | chanA := make(chan iservice.Notification, 1) 42 | idB := "b" 43 | chanB := make(chan iservice.Notification, 1) 44 | 45 | // when 46 | eventing.Subscribe(idA, chanA) 47 | eventing.Subscribe(idB, chanB) 48 | 49 | eventing.Unsubscribe(idA) 50 | 51 | // then 52 | require.Empty(t, eventing.subs[idA], 53 | "expected subscription cleared, but value present: %v", eventing.subs[idA]) 54 | require.Equal(t, chanB, eventing.subs[idB], "incorrect subscription association") 55 | } 56 | -------------------------------------------------------------------------------- /flagd/pkg/service/flag-evaluation/ofrep/ofrep_service.go: -------------------------------------------------------------------------------- 1 | package ofrep 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/open-feature/flagd/core/pkg/evaluator" 11 | "github.com/open-feature/flagd/core/pkg/logger" 12 | "github.com/rs/cors" 13 | "golang.org/x/sync/errgroup" 14 | ) 15 | 16 | type IOfrepService interface { 17 | // Start the OFREP service with context for shutdown 18 | Start(context.Context) error 19 | } 20 | 21 | type SvcConfiguration struct { 22 | Logger *logger.Logger 23 | Port uint16 24 | } 25 | 26 | type Service struct { 27 | logger *logger.Logger 28 | port uint16 29 | server *http.Server 30 | } 31 | 32 | func NewOfrepService( 33 | evaluator evaluator.IEvaluator, origins []string, cfg SvcConfiguration, contextValues map[string]any, 34 | ) (*Service, error) { 35 | corsMW := cors.New(cors.Options{ 36 | AllowedOrigins: origins, 37 | AllowedMethods: []string{http.MethodPost}, 38 | }) 39 | h := corsMW.Handler(NewOfrepHandler(cfg.Logger, evaluator, contextValues)) 40 | 41 | server := http.Server{ 42 | Addr: fmt.Sprintf(":%d", cfg.Port), 43 | Handler: h, 44 | ReadHeaderTimeout: 3 * time.Second, 45 | } 46 | 47 | return &Service{ 48 | logger: cfg.Logger, 49 | port: cfg.Port, 50 | server: &server, 51 | }, nil 52 | } 53 | 54 | func (s Service) Start(ctx context.Context) error { 55 | group, gCtx := errgroup.WithContext(ctx) 56 | 57 | group.Go(func() error { 58 | s.logger.Info(fmt.Sprintf("ofrep service listening at %d", s.port)) 59 | err := s.server.ListenAndServe() 60 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 61 | return fmt.Errorf("error from ofrep service: %w", err) 62 | } 63 | 64 | return nil 65 | }) 66 | 67 | group.Go(func() error { 68 | <-gCtx.Done() 69 | s.logger.Info("shutting down ofrep service") 70 | err := s.server.Close() 71 | if err != nil { 72 | return fmt.Errorf("error from ofrep server shutdown: %w", err) 73 | } 74 | 75 | return nil 76 | }) 77 | 78 | err := group.Wait() 79 | if err != nil { 80 | return fmt.Errorf("error from ofrep service: %w", err) 81 | } 82 | 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /flagd/pkg/service/flag-evaluation/ofrep/ofrep_service_test.go: -------------------------------------------------------------------------------- 1 | package ofrep 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "net/http" 8 | "testing" 9 | "time" 10 | 11 | "github.com/open-feature/flagd/core/pkg/evaluator" 12 | mock "github.com/open-feature/flagd/core/pkg/evaluator/mock" 13 | "github.com/open-feature/flagd/core/pkg/logger" 14 | "github.com/open-feature/flagd/core/pkg/model" 15 | "go.uber.org/mock/gomock" 16 | "golang.org/x/sync/errgroup" 17 | ) 18 | 19 | func Test_OfrepServiceStartStop(t *testing.T) { 20 | port := 18282 21 | eval := mock.NewMockIEvaluator(gomock.NewController(t)) 22 | 23 | eval.EXPECT().ResolveAllValues(gomock.Any(), gomock.Any(), gomock.Any()). 24 | Return([]evaluator.AnyValue{}, model.Metadata{}, nil) 25 | 26 | cfg := SvcConfiguration{ 27 | Logger: logger.NewLogger(nil, false), 28 | Port: uint16(port), 29 | } 30 | 31 | service, err := NewOfrepService(eval, []string{"*"}, cfg, nil) 32 | if err != nil { 33 | t.Fatalf("error creating the ofrep service: %v", err) 34 | } 35 | 36 | ctx, cancelFunc := context.WithCancel(context.Background()) 37 | defer cancelFunc() 38 | 39 | group, gCtx := errgroup.WithContext(ctx) 40 | group.Go(func() error { 41 | return service.Start(gCtx) 42 | }) 43 | 44 | // allow time for server startup 45 | <-time.After(2 * time.Second) 46 | 47 | path := fmt.Sprintf("http://localhost:%d/ofrep/v1/evaluate/flags", port) 48 | 49 | // validate response 50 | response, err := tryResponse(http.MethodPost, path, []byte{}) 51 | if err != nil { 52 | t.Fatalf("error from server: %v", err) 53 | } 54 | 55 | if response == 0 { 56 | t.Fatal("expected non zero status") 57 | } 58 | 59 | // cancel the context 60 | cancelFunc() 61 | 62 | err = group.Wait() 63 | if err != nil { 64 | t.Errorf("error from service group: %v", err) 65 | } 66 | } 67 | 68 | func tryResponse(method string, uri string, payload []byte) (int, error) { 69 | client := http.Client{ 70 | Timeout: 3 * time.Second, 71 | } 72 | 73 | request, err := http.NewRequest(method, uri, bytes.NewReader(payload)) 74 | if err != nil { 75 | return 0, fmt.Errorf("error forming the request: %w", err) 76 | } 77 | 78 | rsp, err := client.Do(request) 79 | if err != nil { 80 | return 0, fmt.Errorf("error from the request: %w", err) 81 | } 82 | return rsp.StatusCode, nil 83 | } 84 | -------------------------------------------------------------------------------- /flagd/pkg/service/flag-sync/handler.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "buf.build/gen/go/open-feature/flagd/grpc/go/flagd/sync/v1/syncv1grpc" 8 | syncv1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/sync/v1" 9 | "github.com/open-feature/flagd/core/pkg/logger" 10 | "google.golang.org/protobuf/types/known/structpb" 11 | ) 12 | 13 | // syncHandler implements the sync contract 14 | type syncHandler struct { 15 | mux *Multiplexer 16 | log *logger.Logger 17 | contextValues map[string]any 18 | } 19 | 20 | func (s syncHandler) SyncFlags(req *syncv1.SyncFlagsRequest, server syncv1grpc.FlagSyncService_SyncFlagsServer) error { 21 | muxPayload := make(chan payload, 1) 22 | selector := req.GetSelector() 23 | 24 | ctx := server.Context() 25 | 26 | err := s.mux.Register(ctx, selector, muxPayload) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | for { 32 | select { 33 | case payload := <-muxPayload: 34 | err := server.Send(&syncv1.SyncFlagsResponse{FlagConfiguration: payload.flags}) 35 | if err != nil { 36 | s.log.Debug(fmt.Sprintf("error sending stream response: %v", err)) 37 | return fmt.Errorf("error sending stream response: %w", err) 38 | } 39 | case <-ctx.Done(): 40 | s.mux.Unregister(ctx, selector) 41 | s.log.Debug("context complete and exiting stream request") 42 | return nil 43 | } 44 | } 45 | } 46 | 47 | func (s syncHandler) FetchAllFlags(_ context.Context, req *syncv1.FetchAllFlagsRequest) ( 48 | *syncv1.FetchAllFlagsResponse, error, 49 | ) { 50 | flags, err := s.mux.GetAllFlags(req.GetSelector()) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return &syncv1.FetchAllFlagsResponse{ 56 | FlagConfiguration: flags, 57 | }, nil 58 | } 59 | 60 | func (s syncHandler) GetMetadata(_ context.Context, _ *syncv1.GetMetadataRequest) ( 61 | *syncv1.GetMetadataResponse, error, 62 | ) { 63 | metadataSrc := make(map[string]any) 64 | for k, v := range s.contextValues { 65 | metadataSrc[k] = v 66 | } 67 | if sources := s.mux.SourcesAsMetadata(); sources != "" { 68 | metadataSrc["sources"] = sources 69 | } 70 | 71 | metadata, err := structpb.NewStruct(metadataSrc) 72 | if err != nil { 73 | s.log.Warn(fmt.Sprintf("error from struct creation: %v", err)) 74 | return nil, fmt.Errorf("error constructing metadata response") 75 | } 76 | 77 | return &syncv1.GetMetadataResponse{ 78 | Metadata: metadata, 79 | }, 80 | nil 81 | } 82 | -------------------------------------------------------------------------------- /flagd/pkg/service/flag-sync/test-cert/ca-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFJTCCAw2gAwIBAgIUKd5pSsJ6Fxr2f4vF2a+MOlQAvcowDQYJKoZIhvcNAQEL 3 | BQAwITEfMB0GA1UEAwwWZmxhZ0QgdGVzdCBjZXJ0aWZpY2F0ZTAgFw0yNTAzMTIx 4 | MjEzNDdaGA8yMDUyMDcyNzEyMTM0N1owITEfMB0GA1UEAwwWZmxhZ0QgdGVzdCBj 5 | ZXJ0aWZpY2F0ZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALqvg928 6 | yzEpRGpbK/gQiVC/tH3CzrieP+QHDI9D0Hlzi8F2V9YxYbmGutewd3mkNiiaVfuo 7 | Ue5HRYwhMBKiMhUByZc2a5wF/eftPE2Hj5rDFiOnW5duDERLMLjFE4lOwnuCZtNk 8 | Ljt5FEd7Q6TbDfOW4ETxwdbQObS+neSXmqYrWQvdIvN4jKyHkiMqdMwGZp6AYYMz 9 | FVEKEQ76xbNkTiMjhOfaCZklZp4D99DNIANtYcpf3+VRZcN4xkVe7+wP8ofioZ/M 10 | CwAQAW/2gq2eienTQ+XGHUZfig0JslinuTZy15wr+1lnYzNvJtK/30Z+pmhjkQBN 11 | f2d0Be6Gf3RUTEFlXqysY1aDEbvW+8lSKyuLr/H73O1vGkhMfJ1A5dak+vwhk40g 12 | 8twYSJesDGjNNW/rxglSZcCA1sPu39gLC+FHZ3eTnur5JOwLvM7n0sn1Ztmkq//7 13 | eCY+ZDT/X39UwluMa9SFugdqfqOLpCCZtkMCLxjNoDLPmEqCHS5E2q3yrl6RzNlg 14 | yc78dRaChgTQbbS2EX0TXqCDnNpyuQAl9hwcMbh1Law0iNGuRircZW6v4Mkc6se0 15 | rpDmtGy9E/wrr2XGsD5KtjpVF2rUHXvpzoY+ioOtiOrDdnLBAwRyw8bIsynLlbpb 16 | F8K7H5b7x1RI8d9AAZohEl9c9iqMyvJnaZTrAgMBAAGjUzBRMB0GA1UdDgQWBBTj 17 | lAwMUauC+x+73IzYJOxNnqJnMDAfBgNVHSMEGDAWgBTjlAwMUauC+x+73IzYJOxN 18 | nqJnMDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBWGKra39+p 19 | WGpobGClcmlP+n5umtzOolFNTM5OyVmNaqWLtcigldFwpPFW7lhYIbbmxWSDpFto 20 | DDkv1FuiEulo7C4TVEa6x/RsgCfOMR5WCsXOh65moSN86SFMPbnSuajpGuY5RfqH 21 | wxfAyd9/IO4aqwINQE0P4VNgqhOMJ66LmodPsGjXC2mkLDePV3sswka+dv1CtFWU 22 | /9Zp7n0UwEga3JLw5RTnXswpA5lyIkRnda9m9ydL5A5uKe3tCwSEUOUGphjdi6Y0 23 | ycAwsD1G7XwK60seZyX3MUMEo4CiH21WC3P2c8xRVl7TxQEj2m+NlQ3UArgIdl60 24 | 4FE7p+yorrXEi43fwLHFi17P7LGvnurp9H6Xs5YZPGkYwEOD6HxDpGHaYlvHArDe 25 | r2+pw/o8YJqP3E9YjVK5wby4v32DdBSGykyJTtXWj5JkwmILUzTE0skDk7fviiyO 26 | F5nwMt37bSliU5p8mk9YzJNv+tTqRGEsOj3NjnjVX1BSDvuKeZfiLG08LRaQrhWa 27 | 6ZU+BPtTi0JkeczEpreSNMPnKytGRkXQ3AXhkeQYLBoAbypzp0zzw6yJ6ADBbUZu 28 | Kn3iuQR7nqpdnE7hNh21mO7tKGV0il4ePQUIeM+E3MJNW4KiSN5UV+Z9GxeQQudD 29 | zJCP+qbCnwOTm+ZDZOymqCoMILaWVL0/Qw== 30 | -----END CERTIFICATE----- 31 | -------------------------------------------------------------------------------- /flagd/pkg/service/flag-sync/test-cert/gen.sh: -------------------------------------------------------------------------------- 1 | :' 2 | This script can be used to recreate the SSL certificates that are used in the sync service test 3 | 4 | Warning: there might be issues running the script on Windows with the -subj argument 5 | -> workaround: run the commands manually without the -subj argument and provide info when asked by the console output 6 | ' 7 | rm *.pem 8 | 9 | # 1. Generate CA's private key and self-signed certificate 10 | openssl req -x509 -newkey rsa:4096 -days 9999 -nodes -keyout ca-key.pem -out ca-cert.pem -subj "/CN=flagD test certificate" 11 | 12 | # 2. Generate web server's private key and certificate signing request (CSR) 13 | openssl req -newkey rsa:4096 -nodes -keyout server-key.pem -out server-req.pem -subj "/CN=flagD test server PR and CSR" 14 | 15 | # 3. Use CA's private key to sign web server's CSR and get back the signed certificate 16 | openssl x509 -req -in server-req.pem -days 9999 -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -out server-cert.pem -extfile server-ext.cnf 17 | 18 | -------------------------------------------------------------------------------- /flagd/pkg/service/flag-sync/test-cert/server-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFKzCCAxOgAwIBAgIUQlEn6qb8OCeRl1QsEFJqnLnk+DcwDQYJKoZIhvcNAQEL 3 | BQAwITEfMB0GA1UEAwwWZmxhZ0QgdGVzdCBjZXJ0aWZpY2F0ZTAgFw0yNTAzMTIx 4 | MjEzNDhaGA8yMDUyMDcyNzEyMTM0OFowJzElMCMGA1UEAwwcZmxhZ0QgdGVzdCBz 5 | ZXJ2ZXIgUFIgYW5kIENTUjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB 6 | AOgX5FyznO1ayOzN8I2n7XBicyzMz6qFxHOb/MdY+JMiMTkhalyEFRMjwK6cW24a 7 | twk8MorUPtbZyE8MEL18OqroEeuiJTRX8BQbXXfK1yC39HkBv7Rjnh3pacenl2TY 8 | E8U47PEkrNgEqlIn0lK/5j8KZ2IZY/h4LIWcajFfTnURPdYBn8V/nkrA/l4dVrfb 9 | dYzxy3BGIE5AVvtv6kh267F5YfRklRtod1IN9BURjILi8YPu1MKqclr1xhu74eMW 10 | oJfp/JSw4E6saOyAfX3/agkeotCITeMVAvuS23DdTMiCG4BWxriCE8O32/7JwhaU 11 | FJpWoZfcnUtTZOpz6fCNTswDGMNN4hbeoHNrYCkp207l/AsIpENQHN8qxlp/fu9p 12 | WAzm7hXDiYfbhKV8uePb9S7YIPZGhG5OPGVQvAzHdCEfkBji4fwYK63coITriu+S 13 | BPWsWfN8bq9kxFtOufKJX4SNtMzFHQdLo+Zb++65C42NmdcOEMXzu9ppbGr8TQh/ 14 | EaZHMkaAEJf0rWjU7W0hmVUnOtV3XYJVJ6juTCm4vEDNnLQlmdz0jsfYYibGIzwZ 15 | yMfmNUPLRGjqKedRB+4/7HQ07swnmRuu74KvH3WI20RfdJU5VI87JL88+oFuUB4Z 16 | P6Vv11dIOEdAoAj8iYbfvOQYHydm8f+//0cf5WSB7QsLAgMBAAGjUzBRMA8GA1Ud 17 | EQQIMAaHBAAAAAAwHQYDVR0OBBYEFFdKZlUJY5zYX5cXpkWVETEzOGMCMB8GA1Ud 18 | IwQYMBaAFOOUDAxRq4L7H7vcjNgk7E2eomcwMA0GCSqGSIb3DQEBCwUAA4ICAQBf 19 | dOL20Sq/utYKPESS7FDy6C3P7pRxB1l+CFNQNXFqhLZjqpo/vxXwY+e26mlSU/O7 20 | rV48eAgscuWbajdkNFQtRq59Dr1uJYnTLn8s4vMxn+5qaPSLE/TyA4rt+cjdvi8i 21 | SCegVSsF4YuSt90+b7ZUkdwu6+HeEUauCwSkV7tUh1IZ4UO75iJtRMZxiBfJ3R8y 22 | encOH/dkS3Io98IqEUgqIc3R32jSadwovrgySwMV2frvUT+JfxvodtIDfCwAFQvS 23 | GtbOxfV7lyyPE12HXgv19hgL3IAarZPdJvEusx1uOKBVDuUJooWU2ByJseHB6TIb 24 | sZdrLbzfydl+e2aJzEub4x6BNgBW+7pFkoF31UHq81KZyvAByFWqMPjhpcZHFbjG 25 | izVB0Wz02QxPMX094UUl0EBUwDhFueDASnWfItrPaIbboXQKlg3Va/IJDolAACTp 26 | bTz4UmtJ7G8HEMf+VpYBAVcL0TYbpn+oIqR7Myo1lbeVkIatn8U7+x8LpJ2ZZg6h 27 | sMsP8fDf0aFdYiWUMo3dj54XbFkkxIXEfUptr0lY3NqxqloQOHZwKNLVPAx7PO+Z 28 | Gt8NwiEiFdgghTOOXLvhbXBaaj3SZHxRX39/ZYBB+CjH/AHihqsQ7jIWPV9vk/oU 29 | Tli94kf5sC2bQveDRP6+Zybjb5uYDbDQmurD09c+Mw== 30 | -----END CERTIFICATE----- 31 | -------------------------------------------------------------------------------- /flagd/pkg/service/flag-sync/test-cert/server-ext.cnf: -------------------------------------------------------------------------------- 1 | subjectAltName=IP:0.0.0.0 2 | -------------------------------------------------------------------------------- /flagd/pkg/service/flag-sync/util_test.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/open-feature/flagd/core/pkg/model" 10 | "github.com/open-feature/flagd/core/pkg/store" 11 | "google.golang.org/grpc/credentials" 12 | ) 13 | 14 | // getSimpleFlagStore returns a flag store pre-filled with flags from sources A & B & C, which C empty 15 | func getSimpleFlagStore() (*store.State, []string) { 16 | variants := map[string]any{ 17 | "true": true, 18 | "false": false, 19 | } 20 | 21 | flagStore := store.NewFlags() 22 | 23 | flagStore.Set("flagA", model.Flag{ 24 | State: "ENABLED", 25 | DefaultVariant: "false", 26 | Variants: variants, 27 | Source: "A", 28 | }) 29 | 30 | flagStore.Set("flagB", model.Flag{ 31 | State: "ENABLED", 32 | DefaultVariant: "true", 33 | Variants: variants, 34 | Source: "B", 35 | }) 36 | 37 | flagStore.MetadataPerSource["A"] = model.Metadata{ 38 | "keyDuped": "value", 39 | "keyA": "valueA", 40 | } 41 | 42 | flagStore.MetadataPerSource["B"] = model.Metadata{ 43 | "keyDuped": "value", 44 | "keyB": "valueB", 45 | } 46 | 47 | return flagStore, []string{"A", "B", "C"} 48 | } 49 | 50 | func loadTLSClientCredentials(certPath string) (credentials.TransportCredentials, error) { 51 | // Load certificate of the CA who signed server's certificate 52 | pemServerCA, err := os.ReadFile(certPath) 53 | if err != nil { 54 | return nil, fmt.Errorf("failed to read file from path '%s'", certPath) 55 | } 56 | 57 | certPool := x509.NewCertPool() 58 | if !certPool.AppendCertsFromPEM(pemServerCA) { 59 | return nil, fmt.Errorf("failed to add server CA's certificate") 60 | } 61 | 62 | // Create the credentials and return it 63 | config := &tls.Config{ 64 | RootCAs: certPool, 65 | MinVersion: tls.VersionTLS12, 66 | } 67 | 68 | return credentials.NewTLS(config), nil 69 | } 70 | -------------------------------------------------------------------------------- /flagd/pkg/service/middleware/cors/cors.go: -------------------------------------------------------------------------------- 1 | package cors 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/rs/cors" 7 | ) 8 | 9 | type Middleware struct { 10 | cors *cors.Cors 11 | } 12 | 13 | func New(allowedOrigins []string) *Middleware { 14 | return &Middleware{ 15 | cors: cors.New(cors.Options{ 16 | AllowedMethods: []string{ 17 | http.MethodHead, 18 | http.MethodGet, 19 | http.MethodPost, 20 | http.MethodPut, 21 | http.MethodPatch, 22 | http.MethodDelete, 23 | }, 24 | AllowedOrigins: allowedOrigins, 25 | AllowedHeaders: []string{"*"}, 26 | ExposedHeaders: []string{ 27 | // Content-Type is in the default safelist. 28 | "Accept", 29 | "Accept-Encoding", 30 | "Accept-Post", 31 | "Connect-Accept-Encoding", 32 | "Connect-Content-Encoding", 33 | "Content-Encoding", 34 | "Grpc-Accept-Encoding", 35 | "Grpc-Encoding", 36 | "Grpc-Message", 37 | "Grpc-Status", 38 | "Grpc-Status-Details-Bin", 39 | }, 40 | }), 41 | } 42 | } 43 | 44 | func (c Middleware) Handler(handler http.Handler) http.Handler { 45 | return c.cors.Handler(handler) 46 | } 47 | -------------------------------------------------------------------------------- /flagd/pkg/service/middleware/cors/cors_test.go: -------------------------------------------------------------------------------- 1 | package cors 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/open-feature/flagd/flagd/pkg/service/middleware/mock" 9 | "github.com/stretchr/testify/require" 10 | "go.uber.org/mock/gomock" 11 | ) 12 | 13 | func TestMiddleware(t *testing.T) { 14 | ctrl := gomock.NewController(t) 15 | mockMw := middlewaremock.NewMockIMiddleware(ctrl) 16 | 17 | handlerFunc := http.HandlerFunc( 18 | func(writer http.ResponseWriter, request *http.Request) { 19 | writer.WriteHeader(http.StatusOK) 20 | }, 21 | ) 22 | 23 | mockMw.EXPECT().Handler(gomock.Any()).Return(handlerFunc) 24 | 25 | ts := httptest.NewServer(handlerFunc) 26 | 27 | defer ts.Close() 28 | 29 | mw := New([]string{"*"}) 30 | require.NotNil(t, mw) 31 | 32 | // wrap the cors middleware around the mock to make sure the wrapped handler is called by the cors middleware 33 | ts.Config.Handler = mw.Handler(mockMw.Handler(handlerFunc)) 34 | 35 | req, err := http.NewRequest(http.MethodGet, ts.URL, nil) 36 | 37 | require.Nil(t, err) 38 | 39 | client := http.DefaultClient 40 | resp, err := client.Do(req) 41 | 42 | require.Nil(t, err) 43 | require.Equal(t, http.StatusOK, resp.StatusCode) 44 | } 45 | -------------------------------------------------------------------------------- /flagd/pkg/service/middleware/h2c/h2c.go: -------------------------------------------------------------------------------- 1 | package h2c 2 | 3 | import ( 4 | "net/http" 5 | 6 | "golang.org/x/net/http2" 7 | "golang.org/x/net/http2/h2c" 8 | ) 9 | 10 | type Middleware struct{} 11 | 12 | func New() *Middleware { 13 | return &Middleware{} 14 | } 15 | 16 | func (m Middleware) Handler(handler http.Handler) http.Handler { 17 | return h2c.NewHandler(handler, &http2.Server{}) 18 | } 19 | -------------------------------------------------------------------------------- /flagd/pkg/service/middleware/h2c/h2c_test.go: -------------------------------------------------------------------------------- 1 | package h2c 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/open-feature/flagd/flagd/pkg/service/middleware/mock" 9 | "github.com/stretchr/testify/require" 10 | "go.uber.org/mock/gomock" 11 | ) 12 | 13 | func TestMiddleware(t *testing.T) { 14 | ctrl := gomock.NewController(t) 15 | mockMw := middlewaremock.NewMockIMiddleware(ctrl) 16 | 17 | handlerFunc := http.HandlerFunc( 18 | func(writer http.ResponseWriter, request *http.Request) { 19 | writer.WriteHeader(http.StatusOK) 20 | }, 21 | ) 22 | 23 | mockMw.EXPECT().Handler(gomock.Any()).Return(handlerFunc) 24 | 25 | ts := httptest.NewServer(handlerFunc) 26 | 27 | defer ts.Close() 28 | 29 | mw := New() 30 | require.NotNil(t, mw) 31 | 32 | // wrap the h2c middleware around the mock to make sure the wrapped handler is called by the h2c middleware 33 | ts.Config.Handler = mw.Handler(mockMw.Handler(handlerFunc)) 34 | 35 | resp, err := http.Get(ts.URL) 36 | 37 | require.Nil(t, err) 38 | require.Equal(t, http.StatusOK, resp.StatusCode) 39 | } 40 | -------------------------------------------------------------------------------- /flagd/pkg/service/middleware/interface.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type IMiddleware interface { 8 | Handler(handler http.Handler) http.Handler 9 | } 10 | -------------------------------------------------------------------------------- /flagd/pkg/service/middleware/mock/interface.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: pkg/service/middleware/interface.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -source=pkg/service/middleware/interface.go -destination=pkg/service/middleware/mock/interface.go -package=middlewaremock 7 | // 8 | 9 | // Package middlewaremock is a generated GoMock package. 10 | package middlewaremock 11 | 12 | import ( 13 | http "net/http" 14 | reflect "reflect" 15 | 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockIMiddleware is a mock of IMiddleware interface. 20 | type MockIMiddleware struct { 21 | ctrl *gomock.Controller 22 | recorder *MockIMiddlewareMockRecorder 23 | } 24 | 25 | // MockIMiddlewareMockRecorder is the mock recorder for MockIMiddleware. 26 | type MockIMiddlewareMockRecorder struct { 27 | mock *MockIMiddleware 28 | } 29 | 30 | // NewMockIMiddleware creates a new mock instance. 31 | func NewMockIMiddleware(ctrl *gomock.Controller) *MockIMiddleware { 32 | mock := &MockIMiddleware{ctrl: ctrl} 33 | mock.recorder = &MockIMiddlewareMockRecorder{mock} 34 | return mock 35 | } 36 | 37 | // EXPECT returns an object that allows the caller to indicate expected use. 38 | func (m *MockIMiddleware) EXPECT() *MockIMiddlewareMockRecorder { 39 | return m.recorder 40 | } 41 | 42 | // Handler mocks base method. 43 | func (m *MockIMiddleware) Handler(handler http.Handler) http.Handler { 44 | m.ctrl.T.Helper() 45 | ret := m.ctrl.Call(m, "Handler", handler) 46 | ret0, _ := ret[0].(http.Handler) 47 | return ret0 48 | } 49 | 50 | // Handler indicates an expected call of Handler. 51 | func (mr *MockIMiddlewareMockRecorder) Handler(handler any) *gomock.Call { 52 | mr.mock.ctrl.T.Helper() 53 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handler", reflect.TypeOf((*MockIMiddleware)(nil).Handler), handler) 54 | } 55 | -------------------------------------------------------------------------------- /flagd/profile.Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile with pprof profiler 2 | # Build the manager binary 3 | FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder 4 | 5 | WORKDIR /src 6 | 7 | ARG TARGETOS 8 | ARG TARGETARCH 9 | ARG VERSION 10 | ARG COMMIT 11 | ARG DATE 12 | 13 | # Download dependencies as a separate step to take advantage of Docker's caching. 14 | # Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. 15 | # Leverage bind mounts to go.sum and go.mod to avoid having to copy them into 16 | # the container. 17 | RUN --mount=type=cache,target=/go/pkg/mod/ \ 18 | --mount=type=bind,source=./core/go.mod,target=./core/go.mod \ 19 | --mount=type=bind,source=./core/go.sum,target=./core/go.sum \ 20 | --mount=type=bind,source=./flagd/go.mod,target=./flagd/go.mod \ 21 | --mount=type=bind,source=./flagd/go.sum,target=./flagd/go.sum \ 22 | go work init ./core ./flagd && go mod download 23 | 24 | # Build the application. 25 | # Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. 26 | # Leverage a bind mount to the current directory to avoid having to copy the 27 | # source code into the container. 28 | RUN --mount=type=cache,target=/go/pkg/mod/ \ 29 | --mount=type=cache,target=/root/.cache/go-build \ 30 | --mount=type=bind,source=./core,target=./core \ 31 | --mount=type=bind,source=./flagd,target=./flagd \ 32 | CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /bin/flagd-build ./flagd/main.go ./flagd/profiler.go 33 | 34 | # Use distroless as minimal base image to package the manager binary 35 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 36 | FROM gcr.io/distroless/static:nonroot 37 | WORKDIR / 38 | COPY --from=builder /bin/flagd-build . 39 | USER 65532:65532 40 | 41 | ENTRYPOINT ["/flagd-build"] 42 | -------------------------------------------------------------------------------- /flagd/profiler.go: -------------------------------------------------------------------------------- 1 | //go:build profile 2 | 3 | package main 4 | 5 | import ( 6 | "net/http" 7 | _ "net/http/pprof" 8 | ) 9 | 10 | /* 11 | Enable pprof profiler for flagd. Build controlled by the build tag "profile". 12 | */ 13 | func init() { 14 | // Go routine to server PProf 15 | go func() { 16 | server := http.Server{Addr: ":6060", Handler: nil} 17 | server.ListenAndServe() 18 | }() 19 | } 20 | -------------------------------------------------------------------------------- /flagd/snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: flagd 2 | base: core20 3 | version: 0.8.1 4 | summary: A feature flag daemon with a Unix philosophy 5 | description: > 6 | Flagd is a simple command line tool for fetching and evaluating feature flags 7 | for services. It is designed to conform with the OpenFeature specification. 8 | grade: stable 9 | confinement: strict 10 | architectures: 11 | - build-on: amd64 12 | - build-on: arm64 13 | apps: 14 | flagd: 15 | command: bin/flagd 16 | plugs: 17 | - home 18 | - network 19 | - network-bind 20 | parts: 21 | home: 22 | plugin: go 23 | source-type: git 24 | source: https://github.com/open-feature/flagd.git 25 | -------------------------------------------------------------------------------- /images/flagD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/flagd/761d870a3c563b8eb1b83ee543b41316c98a1d48/images/flagD.png -------------------------------------------------------------------------------- /images/flagd-proxy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/flagd/761d870a3c563b8eb1b83ee543b41316c98a1d48/images/flagd-proxy.png -------------------------------------------------------------------------------- /images/loadTestResults.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/flagd/761d870a3c563b8eb1b83ee543b41316c98a1d48/images/loadTestResults.png -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "site" 3 | # https://docs.netlify.com/configure-builds/ignore-builds/ 4 | ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF netlify.toml mkdocs.yml requirements.txt ./docs/" 5 | command = "mkdocs build" 6 | 7 | [[headers]] 8 | # Relax cross origin restrictions for schemas, so they can be requested by front-end apps. 9 | for = "/schema/*" 10 | [headers.values] 11 | Access-Control-Allow-Origin = "*" -------------------------------------------------------------------------------- /playground-app/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /playground-app/README.md: -------------------------------------------------------------------------------- 1 | # flagd playground 2 | 3 | The flagd playground is an application designed to test the behavior of flagd. 4 | It allows users to define flags and experiment with various inputs to understand how flagd responds. 5 | This tool is particularly useful for developers and testers who are working with flagd in their projects and need a simple and effective way to validate their flag definitions. 6 | 7 | ## Development 8 | 9 | ### Getting Started 10 | 11 | To get started with the development of the flagd playground, you'll need to have Node.js installed on your machine. 12 | 13 | 1. Install [Node.js](https://nodejs.org/en/download/) version 18 or newer. 14 | 1. From the root of the project, run `make playground-dev`. 15 | 1. Open your browser and navigate to [http://localhost:5173/](http://localhost:5173/); 16 | 17 | > [!NOTE] 18 | > This page is mostly unstyled because it inherits the styles from Mkdocs Material. 19 | 20 | ### Add a new scenario 21 | 22 | A new scenario can be added to the playground by following these steps: 23 | 24 | 1. Add a new scenario file during the ``./src/scenarios`` directory. 25 | 1. Export a constant that conforms to the `Scenario` type. 26 | 1. Include the scenario in the scenarios objects at `./src/scenarios/index.ts`. 27 | 28 | > [!NOTE] 29 | > Make sure to update the docs once you're ready. This does not happen automatically! Please see below for more information. 30 | 31 | ### Adding Playground to the Docs 32 | 33 | Adding the playground app to the docs can be done by running the following command from the root of the project: 34 | 35 | ```bash 36 | make make playground-publish 37 | ``` 38 | 39 | > [!NOTE] 40 | > This will build the app and copy the output to the docs. 41 | -------------------------------------------------------------------------------- /playground-app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /playground-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flagd-playground", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@monaco-editor/react": "^4.7.0-rc.0", 14 | "@openfeature/flagd-core": "^1.0.0", 15 | "react": "^19.0.0", 16 | "react-dom": "^19.0.0", 17 | "react-use": "^17.6.0" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "^19.0.3", 21 | "@types/react-dom": "^19.0.2", 22 | "@typescript-eslint/eslint-plugin": "^8.19.1", 23 | "@typescript-eslint/parser": "^8.19.1", 24 | "@vitejs/plugin-react": "^4.3.4", 25 | "eslint": "^9.17.0", 26 | "eslint-plugin-react-hooks": "^5.1.0", 27 | "eslint-plugin-react-refresh": "^0.4.16", 28 | "typescript": "^5.7.2", 29 | "vite": "^6.0.7" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /playground-app/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | 5 | ReactDOM.createRoot(document.getElementById("playground")!).render( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/basic-boolean.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const basicBoolean: Scenario = { 5 | description: [ 6 | "In this scenario, we have a feature flag with the key 'basic-boolean' that is enabled and has two variants: true and false.", 7 | "The default variant is false. Try changing the 'defaultVariant' to 'true' or add a targeting rule.", 8 | ].join(" "), 9 | flagDefinition: featureDefinitionToPrettyJson({ 10 | flags: { 11 | "basic-boolean": { 12 | state: "ENABLED", 13 | defaultVariant: "false", 14 | variants: { 15 | true: true, 16 | false: false, 17 | }, 18 | targeting: {}, 19 | }, 20 | }, 21 | }), 22 | flagKey: "basic-boolean", 23 | returnType: "boolean", 24 | context: contextToPrettyJson({}), 25 | }; 26 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/basic-number.ts: -------------------------------------------------------------------------------- 1 | import { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const basicNumber: Scenario = { 5 | description: [ 6 | 'In this scenario, we have a feature flag with the key "basic-number" that is enabled and has two variants: 1 and 2.', 7 | 'The default variant is 1. Try changing the "defaultVariant" to "2" or add a targeting rule.', 8 | ].join(" "), 9 | flagDefinition: featureDefinitionToPrettyJson({ 10 | flags: { 11 | "basic-number": { 12 | state: "ENABLED", 13 | defaultVariant: "1", 14 | variants: { 15 | "1": 1, 16 | "2": 2, 17 | }, 18 | targeting: {}, 19 | }, 20 | }, 21 | }), 22 | flagKey: "basic-number", 23 | returnType: "number", 24 | context: contextToPrettyJson({}), 25 | }; 26 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/basic-object.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const basicObject: Scenario = { 5 | description: [ 6 | 'In this scenario, we have a feature flag with the key "basic-object" that is enabled and has two variants: foo and bar.', 7 | 'The default variant is foo. Try changing the "defaultVariant" to "bar" or add a targeting rule.', 8 | ].join(" "), 9 | flagDefinition: featureDefinitionToPrettyJson({ 10 | flags: { 11 | "basic-object": { 12 | state: "ENABLED", 13 | defaultVariant: "foo", 14 | variants: { 15 | foo: { 16 | foo: "foo", 17 | }, 18 | bar: { 19 | bar: "bar", 20 | }, 21 | }, 22 | targeting: {}, 23 | }, 24 | }, 25 | }), 26 | flagKey: "basic-object", 27 | returnType: "object", 28 | context: contextToPrettyJson({}), 29 | }; 30 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/basic-string.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const basicString: Scenario = { 5 | description: [ 6 | 'In this scenario, we have a feature flag with the key "basic-string" that is enabled and has two variants: foo and bar.', 7 | 'The default variant is foo. Try changing the "defaultVariant" to "bar" or add a targeting rule.', 8 | ].join(" "), 9 | flagDefinition: featureDefinitionToPrettyJson({ 10 | flags: { 11 | "basic-string": { 12 | state: "ENABLED", 13 | defaultVariant: "foo", 14 | variants: { 15 | foo: "foo", 16 | bar: "bar", 17 | }, 18 | targeting: {}, 19 | }, 20 | }, 21 | }), 22 | flagKey: "basic-string", 23 | returnType: "string", 24 | context: contextToPrettyJson({}), 25 | }; 26 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/boolean-shorthand.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const booleanShorthand: Scenario = { 5 | description: [ 6 | "In this scenario, we have a feature flag with a targeting rule that returns true when the age is 18 or greater.", 7 | "This targeting rule leverages the boolean shorthand syntax, which converts a boolean to its string equivalent.", 8 | "The converted value is then used as the variant key.", 9 | "Try changing the value of the context attribute 'age'.", 10 | ].join(" "), 11 | flagDefinition: featureDefinitionToPrettyJson({ 12 | flags: { 13 | "feature-1": { 14 | state: "ENABLED", 15 | defaultVariant: "false", 16 | variants: { 17 | true: true, 18 | false: false, 19 | }, 20 | targeting: { 21 | ">=": [{ var: "age" }, 18], 22 | }, 23 | }, 24 | }, 25 | }), 26 | flagKey: "feature-1", 27 | returnType: "boolean", 28 | context: contextToPrettyJson({ 29 | age: 20, 30 | }), 31 | }; 32 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/chainable-conditions.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const chainableConditions: Scenario = { 5 | description: [ 6 | "In this scenario, we have a feature flag with the key 'acceptable-feature-stability' with three variants: alpha, beta, and ga.", 7 | "The flag has a targeting rule that enables the flag based on the customer ID.", 8 | "The flag is enabled for customer-A in the alpha variant, for customer-B1 and customer-B2 in the beta variant, and for all other customers in the ga variant.", 9 | "Experiment by changing the 'customerId' in the context.", 10 | ].join(" "), 11 | flagDefinition: featureDefinitionToPrettyJson({ 12 | flags: { 13 | "acceptable-feature-stability": { 14 | state: "ENABLED", 15 | defaultVariant: "ga", 16 | variants: { 17 | alpha: "alpha", 18 | beta: "beta", 19 | ga: "ga", 20 | }, 21 | targeting: { 22 | if: [ 23 | { "===": [{ var: "customerId" }, "customer-A"] }, 24 | "alpha", 25 | { in: [{ var: "customerId" }, ["customer-B1", "customer-B2"]] }, 26 | "beta", 27 | "ga", 28 | ], 29 | }, 30 | }, 31 | }, 32 | }), 33 | flagKey: "acceptable-feature-stability", 34 | returnType: "string", 35 | context: contextToPrettyJson({ 36 | targetingKey: "sessionId-123", 37 | customerId: "customer-A", 38 | }), 39 | }; 40 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/enable-by-domain.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const enableByDomain: Scenario = { 5 | description: [ 6 | 'In this scenario, we have a feature flag with the key "enable-mainframe-access" that is enabled and has two variants: true and false.', 7 | 'This flag has a targeting rule defined that enables the flag for users with an email address that ends with "@ingen.com".', 8 | 'Experiment with changing the email address in the context or in the targeting rule.', 9 | ].join(" "), 10 | flagDefinition: featureDefinitionToPrettyJson({ 11 | flags: { 12 | "enable-mainframe-access": { 13 | state: "ENABLED", 14 | defaultVariant: "false", 15 | variants: { 16 | true: true, 17 | false: false, 18 | }, 19 | targeting: { 20 | if: [{ ends_with: [{ var: "email" }, "@ingen.com"] }, "true"], 21 | }, 22 | }, 23 | }, 24 | }), 25 | flagKey: "enable-mainframe-access", 26 | returnType: "boolean", 27 | context: contextToPrettyJson({ 28 | email: "john.arnold@ingen.com", 29 | }), 30 | }; 31 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/enable-by-locale.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const enableByLocale: Scenario = { 5 | description: [ 6 | 'In this scenario, we have a feature flag with the key "supports-one-hour-delivery" that is enabled and has two variants: true and false.', 7 | 'This flag has a targeting rule defined that enables the flag for users with a locale of "us" or "ca".', 8 | 'Experiment with changing the locale in the context or in the locale list in the targeting rule.', 9 | ].join(" "), 10 | flagDefinition: featureDefinitionToPrettyJson({ 11 | flags: { 12 | "supports-one-hour-delivery": { 13 | state: "ENABLED", 14 | defaultVariant: "false", 15 | variants: { 16 | true: true, 17 | false: false, 18 | }, 19 | targeting: { 20 | if: [{ in: [{ var: "locale" }, ["us", "ca"]] }, "true"], 21 | }, 22 | }, 23 | }, 24 | }), 25 | context: contextToPrettyJson({ 26 | locale: "us", 27 | }), 28 | flagKey: "supports-one-hour-delivery", 29 | returnType: "boolean", 30 | }; 31 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/enable-by-time.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const enableByTime: Scenario = { 5 | description: [ 6 | 'In this scenario, we have a feature flag with the key "enable-announcement-banner" that is enabled and has two variants: true and false.', 7 | "This flag has a targeting rule defined that enables the flag after a specified time.", 8 | 'The current time (epoch) can be accessed using "$flagd.timestamp" which is automatically provided by flagd.', 9 | 'Five seconds after loading this scenario, the response will change to "true".', 10 | ].join(" "), 11 | flagDefinition: () => 12 | featureDefinitionToPrettyJson({ 13 | flags: { 14 | "enable-announcement-banner": { 15 | state: "ENABLED", 16 | defaultVariant: "false", 17 | variants: { 18 | true: true, 19 | false: false, 20 | }, 21 | targeting: { 22 | if: [ 23 | { 24 | ">": [ 25 | { var: "$flagd.timestamp" }, 26 | Math.floor(Date.now() / 1000) + 5, 27 | ], 28 | }, 29 | "true", 30 | ], 31 | }, 32 | }, 33 | }, 34 | }), 35 | flagKey: "enable-announcement-banner", 36 | returnType: "boolean", 37 | context: () => contextToPrettyJson({}), 38 | }; 39 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/enable-by-version.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const enableByVersion: Scenario = { 5 | description: [ 6 | 'In this scenario, we have a feature flag with the key "enable-performance-mode" that is enabled and has two variants: true and false.', 7 | 'This rule looks for the evaluation context "version". If the version is greater or equal to "1.7.0" the feature is enabled.', 8 | 'Otherwise, the "defaultVariant" is return. Experiment by changing the version in the context.', 9 | ].join(" "), 10 | flagDefinition: featureDefinitionToPrettyJson({ 11 | flags: { 12 | "enable-performance-mode": { 13 | state: "ENABLED", 14 | defaultVariant: "false", 15 | variants: { 16 | true: true, 17 | false: false, 18 | }, 19 | targeting: { 20 | if: [{ sem_ver: [{ var: "version" }, ">=", "1.7.0"] }, "true"], 21 | }, 22 | }, 23 | }, 24 | }), 25 | flagKey: "enable-performance-mode", 26 | returnType: "boolean", 27 | context: contextToPrettyJson({ 28 | version: "1.6.0", 29 | }), 30 | }; 31 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/flag-metadata.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const flagMetadata: Scenario = { 5 | description: [ 6 | "In this scenario, we have a feature flag with metadata about the flag.", 7 | "There is top-level metadata for the flag set and metadata specific to the flag.", 8 | "These values are merged together, with the flag metadata taking precedence.", 9 | ].join(" "), 10 | flagDefinition: featureDefinitionToPrettyJson({ 11 | flags: { 12 | "flag-with-metadata": { 13 | state: "ENABLED", 14 | variants: { 15 | on: true, 16 | off: false, 17 | }, 18 | defaultVariant: "on", 19 | metadata: { 20 | version: "1", 21 | }, 22 | }, 23 | }, 24 | metadata: { 25 | flagSetId: "playground/dev", 26 | }, 27 | }), 28 | flagKey: "flag-with-metadata", 29 | returnType: "boolean", 30 | context: contextToPrettyJson({}), 31 | }; 32 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/fraction-string.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const pseudoRandomSplit: Scenario = { 5 | description: [ 6 | 'In this scenario, we have a feature flag with the key "color-palette-experiment" that is enabled and has four variants: red, blue, green, and grey.', 7 | 'The targeting rule uses the "fractional" operator, which deterministically splits the traffic based on the configuration.', 8 | 'This configuration splits the traffic evenly between the four variants by bucketing evaluations pseudorandomly using the "targetingKey" and feature flag key.', 9 | 'Experiment by changing the "targetingKey" to another value.', 10 | ].join(" "), 11 | flagDefinition: featureDefinitionToPrettyJson({ 12 | flags: { 13 | "color-palette-experiment": { 14 | state: "ENABLED", 15 | defaultVariant: "grey", 16 | variants: { 17 | red: "#b91c1c", 18 | blue: "#0284c7", 19 | green: "#16a34a", 20 | grey: "#4b5563", 21 | }, 22 | targeting: { 23 | fractional: [ 24 | ["red", 25], 25 | ["blue", 25], 26 | ["green", 25], 27 | ["grey", 25], 28 | ], 29 | }, 30 | }, 31 | }, 32 | }), 33 | flagKey: "color-palette-experiment", 34 | returnType: "string", 35 | context: contextToPrettyJson({ 36 | targetingKey: "sessionId-123", 37 | }), 38 | }; 39 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/index.ts: -------------------------------------------------------------------------------- 1 | import { Scenario } from "../types"; 2 | import { basicBoolean } from "./basic-boolean"; 3 | import { basicNumber } from "./basic-number"; 4 | import { basicObject } from "./basic-object"; 5 | import { basicString } from "./basic-string"; 6 | import { booleanShorthand } from "./boolean-shorthand"; 7 | import { chainableConditions } from "./chainable-conditions"; 8 | import { enableByDomain } from "./enable-by-domain"; 9 | import { enableByLocale } from "./enable-by-locale"; 10 | import { enableByTime } from "./enable-by-time"; 11 | import { enableByVersion } from "./enable-by-version"; 12 | import { pseudoRandomSplit } from "./fraction-string"; 13 | import { progressRollout } from "./progressive-rollout"; 14 | import { sharedEvaluators } from "./share-evaluators"; 15 | import { targetingKey } from "./targeting-key"; 16 | import { flagMetadata } from "./flag-metadata"; 17 | 18 | export const scenarios = { 19 | "Basic boolean flag": basicBoolean, 20 | "Basic numeric flag": basicNumber, 21 | "Basic string flag": basicString, 22 | "Basic object flag": basicObject, 23 | "Enable for a specific email domain": enableByDomain, 24 | "Enable based on users locale": enableByLocale, 25 | "Enable based on release version": enableByVersion, 26 | "Enable based on the current time": enableByTime, 27 | "Chainable if/else/then": chainableConditions, 28 | "Multi-variant experiment": pseudoRandomSplit, 29 | "Progressive rollout": progressRollout, 30 | "Shared evaluators": sharedEvaluators, 31 | "Boolean variant shorthand": booleanShorthand, 32 | "Targeting key": targetingKey, 33 | "Flag metadata": flagMetadata, 34 | } satisfies { [name: string]: Scenario }; 35 | 36 | export type ScenarioName = keyof typeof scenarios; 37 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/progressive-rollout.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const progressRollout: Scenario = { 5 | description: [ 6 | 'In this scenario, we have a feature flag with the key "enable-new-llm-model" with multiple variant for illustrative purposes.', 7 | "This flag has a targeting rule defined that enables the flag for a percentage of users based on the release phase.", 8 | 'The "targetingKey" ensures that the user always sees the same results during a each phase of the rollout process.', 9 | ].join(" "), 10 | flagDefinition: () => { 11 | const phase1 = Math.floor(Date.now() / 1000) + 5; 12 | const phase2 = Math.floor(Date.now() / 1000) + 10; 13 | const phase3 = Math.floor(Date.now() / 1000) + 15; 14 | const enabled = Math.floor(Date.now() / 1000) + 20; 15 | return featureDefinitionToPrettyJson({ 16 | flags: { 17 | "enable-new-llm-model": { 18 | state: "ENABLED", 19 | defaultVariant: "disabled", 20 | variants: { 21 | disabled: false, 22 | phase1Enabled: true, 23 | phase1Disabled: false, 24 | phase2Enabled: true, 25 | phase2Disabled: false, 26 | phase3Enabled: true, 27 | phase3Disabled: false, 28 | enabled: true, 29 | }, 30 | targeting: { 31 | if: [ 32 | { ">=": [phase1, { var: "$flagd.timestamp" }] }, 33 | "disabled", 34 | { "<=": [phase1, { var: "$flagd.timestamp" }, phase2] }, 35 | { 36 | fractional: [ 37 | ["phase1Enabled", 10], 38 | ["phase1Disabled", 90], 39 | ], 40 | }, 41 | { "<=": [phase2, { var: "$flagd.timestamp" }, phase3] }, 42 | { 43 | fractional: [ 44 | ["phase2Enabled", 25], 45 | ["phase2Disabled", 75], 46 | ], 47 | }, 48 | { "<=": [phase3, { var: "$flagd.timestamp" }, enabled] }, 49 | { 50 | fractional: [ 51 | ["phase3Enabled", 50], 52 | ["phase3Disabled", 50], 53 | ], 54 | }, 55 | "enabled", 56 | ], 57 | }, 58 | }, 59 | }, 60 | }); 61 | }, 62 | flagKey: "enable-new-llm-model", 63 | returnType: "boolean", 64 | context: () => 65 | contextToPrettyJson({ 66 | targetingKey: "sessionId-12345", 67 | }), 68 | }; 69 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/share-evaluators.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const sharedEvaluators: Scenario = { 5 | description: [ 6 | "In this scenario, we have two feature flags that share targeting rule logic.", 7 | "This is accomplished by defining a $evaluators object in the feature flag definition and referencing it by name in a targeting rule.", 8 | "Experiment with changing the email domain in the shared evaluator.", 9 | ].join(" "), 10 | flagDefinition: featureDefinitionToPrettyJson({ 11 | flags: { 12 | "feature-1": { 13 | state: "ENABLED", 14 | defaultVariant: "false", 15 | variants: { 16 | true: true, 17 | false: false, 18 | }, 19 | targeting: { 20 | if: [{ $ref: "emailWithFaas" }, "true"], 21 | }, 22 | }, 23 | "feature-2": { 24 | state: "ENABLED", 25 | defaultVariant: "false", 26 | variants: { 27 | true: true, 28 | false: false, 29 | }, 30 | targeting: { 31 | if: [{ $ref: "emailWithFaas" }, "true"], 32 | }, 33 | }, 34 | }, 35 | $evaluators: { 36 | emailWithFaas: { 37 | ends_with: [{ var: "email" }, "@faas.com"], 38 | }, 39 | }, 40 | }), 41 | flagKey: "feature-1", 42 | returnType: "boolean", 43 | context: contextToPrettyJson({ 44 | email: "example@faas.com", 45 | }), 46 | }; 47 | -------------------------------------------------------------------------------- /playground-app/src/scenarios/targeting-key.ts: -------------------------------------------------------------------------------- 1 | import type { Scenario } from "../types"; 2 | import { contextToPrettyJson, featureDefinitionToPrettyJson } from "../utils"; 3 | 4 | export const targetingKey: Scenario = { 5 | description: [ 6 | "In this scenario, we have a feature flag that is evaluated based on its targeting key.", 7 | "The targeting key is contain a string uniquely identifying the subject of the flag evaluation, such as a user's email, or a session identifier.", 8 | "In this case, null is returned from targeting if the targeting key doesn't match; this results in a reason of \"DEFAULT\", since no variant was matched by the targeting rule.", 9 | ].join(" "), 10 | flagDefinition: featureDefinitionToPrettyJson({ 11 | flags: { 12 | "targeting-key-flag": { 13 | state: "ENABLED", 14 | variants: { 15 | miss: "miss", 16 | hit: "hit" 17 | }, 18 | defaultVariant: "miss", 19 | targeting: { 20 | if: [ 21 | { 22 | "==": [ { var: "targetingKey" }, "5c3d8535-f81a-4478-a6d3-afaa4d51199e" ] 23 | }, 24 | "hit", 25 | null 26 | ] 27 | } 28 | } 29 | }, 30 | }), 31 | flagKey: "targeting-key-flag", 32 | returnType: "string", 33 | context: contextToPrettyJson({ 34 | targetingKey: "5c3d8535-f81a-4478-a6d3-afaa4d51199e", 35 | }), 36 | }; 37 | -------------------------------------------------------------------------------- /playground-app/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FlagMetadata, 3 | FlagValueType, 4 | JsonObject, 5 | } from "@openfeature/core"; 6 | 7 | type StringVariants = { 8 | [key: string]: string; 9 | }; 10 | 11 | type NumberVariants = { 12 | [key: string]: number; 13 | }; 14 | 15 | type BooleanVariants = { 16 | [key: string]: boolean; 17 | }; 18 | 19 | type ObjectVariants = { 20 | [key: string]: JsonObject; 21 | }; 22 | 23 | export type FeatureDefinition = { 24 | flags: { 25 | [key: string]: { 26 | state: "ENABLED" | "DISABLED"; 27 | defaultVariant: string; 28 | variants: 29 | | StringVariants 30 | | NumberVariants 31 | | BooleanVariants 32 | | ObjectVariants; 33 | targeting?: JsonObject; 34 | metadata?: FlagMetadata; 35 | }; 36 | }; 37 | $evaluators?: JsonObject; 38 | metadata?: FlagMetadata; 39 | }; 40 | 41 | export type Scenario = { 42 | /** 43 | * A description of the scenario. 44 | */ 45 | description: string; 46 | /** 47 | * A stringify version of the flag definition. 48 | */ 49 | flagDefinition: string | (() => string); 50 | /** 51 | * The flag key that should be used as the default value in the playground. 52 | */ 53 | flagKey: string; 54 | /** 55 | * The expected return type of the flag. 56 | */ 57 | returnType: FlagValueType; 58 | /** 59 | * A string or function that returns a string that represents evaluation context. 60 | */ 61 | context: string | (() => string); 62 | }; 63 | -------------------------------------------------------------------------------- /playground-app/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { FeatureDefinition } from "./types"; 2 | import type { EvaluationContext } from "@openfeature/core"; 3 | 4 | const schemaMixin = { 5 | $schema: "https://flagd.dev/schema/v0/flags.json", 6 | }; 7 | 8 | export function prettyPrintJson(json: string): string { 9 | return JSON.stringify(JSON.parse(json), null, 2); 10 | } 11 | 12 | export function featureDefinitionToPrettyJson( 13 | definition: FeatureDefinition 14 | ): string { 15 | return prettyPrintJson(JSON.stringify({ ...schemaMixin, ...definition })); 16 | } 17 | 18 | export function contextToPrettyJson(context: EvaluationContext) { 19 | return prettyPrintJson(JSON.stringify(context)); 20 | } 21 | 22 | /** 23 | * Returns a string from a string or a function that returns a string. 24 | */ 25 | export function getString(input: string | (() => string)): string { 26 | if (typeof input === "function") { 27 | return input(); 28 | } 29 | return input; 30 | } 31 | 32 | export function isValidJson(input: string) { 33 | try { 34 | JSON.parse(input); 35 | return true; 36 | } catch { 37 | return false; 38 | } 39 | } -------------------------------------------------------------------------------- /playground-app/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /playground-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /playground-app/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /playground-app/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "include-component-in-tag": true, 3 | "tag-separator": "/", 4 | "last-release-sha": "4f7b3cf32d6abbf91bd002abbfe851e84fc3dac5", 5 | "packages": { 6 | "flagd": { 7 | "release-type": "go", 8 | "package-name": "flagd", 9 | "bump-minor-pre-major": true, 10 | "bump-patch-for-minor-pre-major": true, 11 | "versioning": "default", 12 | "extra-files": [ 13 | "snap/snapcraft.yaml" 14 | ] 15 | }, 16 | "flagd-proxy": { 17 | "release-type": "go", 18 | "package-name": "flagd-proxy", 19 | "versioning": "default", 20 | "bump-minor-pre-major": true, 21 | "bump-patch-for-minor-pre-major": true 22 | }, 23 | "core": { 24 | "release-type": "go", 25 | "package-name": "core", 26 | "versioning": "default", 27 | "bump-minor-pre-major": true, 28 | "bump-patch-for-minor-pre-major": true 29 | } 30 | }, 31 | "changelog-sections": [ 32 | { 33 | "type": "fix", 34 | "section": "🐛 Bug Fixes" 35 | }, 36 | { 37 | "type": "feat", 38 | "section": "✨ New Features" 39 | }, 40 | { 41 | "type": "chore", 42 | "section": "🧹 Chore" 43 | }, 44 | { 45 | "type": "docs", 46 | "section": "📚 Documentation" 47 | }, 48 | { 49 | "type": "perf", 50 | "section": "🚀 Performance" 51 | }, 52 | { 53 | "type": "build", 54 | "hidden": true, 55 | "section": "🛠️ Build" 56 | }, 57 | { 58 | "type": "deps", 59 | "section": "📦 Dependencies" 60 | }, 61 | { 62 | "type": "ci", 63 | "hidden": true, 64 | "section": "🚦 CI" 65 | }, 66 | { 67 | "type": "refactor", 68 | "section": "🔄 Refactoring" 69 | }, 70 | { 71 | "type": "revert", 72 | "section": "🔙 Reverts" 73 | }, 74 | { 75 | "type": "style", 76 | "hidden": true, 77 | "section": "🎨 Styling" 78 | }, 79 | { 80 | "type": "test", 81 | "hidden": true, 82 | "section": "🧪 Tests" 83 | } 84 | ], 85 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 86 | } -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "constraints": { 4 | "go": "1.22" 5 | }, 6 | "extends": ["github>open-feature/community-tooling"], 7 | "includePaths": [ 8 | "flagd/**", 9 | "flagd-proxy/**", 10 | "core/**", 11 | "test/**" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.6.1 2 | mkdocs-material==9.5.42 3 | pymdown-extensions 4 | mkdocs-material-extensions 5 | fontawesome-markdown 6 | pillow 7 | cairosvg 8 | mkdocs-include-markdown-plugin 9 | mkdocs-redirects -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | 3.8 2 | -------------------------------------------------------------------------------- /samples/example_flags.flagd.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://flagd.dev/schema/v0/flags.json 2 | flags: 3 | myBoolFlag: 4 | state: ENABLED 5 | variants: 6 | 'on': true 7 | 'off': false 8 | defaultVariant: 'on' 9 | myStringFlag: 10 | state: ENABLED 11 | variants: 12 | key1: val1 13 | key2: val2 14 | defaultVariant: key1 15 | myFloatFlag: 16 | state: ENABLED 17 | variants: 18 | one: 1.23 19 | two: 2.34 20 | defaultVariant: one 21 | myIntFlag: 22 | state: ENABLED 23 | variants: 24 | one: 1 25 | two: 2 26 | defaultVariant: one 27 | myObjectFlag: 28 | state: ENABLED 29 | variants: 30 | object1: 31 | key: val 32 | object2: 33 | key: true 34 | defaultVariant: object1 35 | isColorYellow: 36 | state: ENABLED 37 | variants: 38 | 'on': true 39 | 'off': false 40 | defaultVariant: 'off' 41 | targeting: 42 | if: 43 | - "==": 44 | - var: 45 | - color 46 | - yellow 47 | - 'on' 48 | - 'off' 49 | fibAlgo: 50 | variants: 51 | recursive: recursive 52 | memo: memo 53 | loop: loop 54 | binet: binet 55 | defaultVariant: recursive 56 | state: ENABLED 57 | targeting: 58 | if: 59 | - "$ref": emailWithFaas 60 | - binet 61 | - null 62 | headerColor: 63 | variants: 64 | red: "#FF0000" 65 | blue: "#0000FF" 66 | green: "#00FF00" 67 | yellow: "#FFFF00" 68 | defaultVariant: red 69 | state: ENABLED 70 | targeting: 71 | if: 72 | - "$ref": emailWithFaas 73 | - fractional: 74 | - cat: 75 | - var: $flagd.flagKey 76 | - var: email 77 | - - red 78 | - 25 79 | - - blue 80 | - 25 81 | - - green 82 | - 25 83 | - - yellow 84 | - 25 85 | - null 86 | "$evaluators": 87 | emailWithFaas: 88 | in: 89 | - "@faas.com" 90 | - var: 91 | - email -------------------------------------------------------------------------------- /samples/example_flags.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://flagd.dev/schema/v0/flags.json 2 | flags: 3 | myBoolFlag: 4 | state: ENABLED 5 | variants: 6 | 'on': true 7 | 'off': false 8 | defaultVariant: 'on' 9 | myStringFlag: 10 | state: ENABLED 11 | variants: 12 | key1: val1 13 | key2: val2 14 | defaultVariant: key1 15 | myFloatFlag: 16 | state: ENABLED 17 | variants: 18 | one: 1.23 19 | two: 2.34 20 | defaultVariant: one 21 | myIntFlag: 22 | state: ENABLED 23 | variants: 24 | one: 1 25 | two: 2 26 | defaultVariant: one 27 | myObjectFlag: 28 | state: ENABLED 29 | variants: 30 | object1: 31 | key: val 32 | object2: 33 | key: true 34 | defaultVariant: object1 35 | isColorYellow: 36 | state: ENABLED 37 | variants: 38 | 'on': true 39 | 'off': false 40 | defaultVariant: 'off' 41 | targeting: 42 | if: 43 | - "==": 44 | - var: 45 | - color 46 | - yellow 47 | - 'on' 48 | - 'off' 49 | fibAlgo: 50 | variants: 51 | recursive: recursive 52 | memo: memo 53 | loop: loop 54 | binet: binet 55 | defaultVariant: recursive 56 | state: ENABLED 57 | targeting: 58 | if: 59 | - "$ref": emailWithFaas 60 | - binet 61 | - null 62 | headerColor: 63 | variants: 64 | red: "#FF0000" 65 | blue: "#0000FF" 66 | green: "#00FF00" 67 | yellow: "#FFFF00" 68 | defaultVariant: red 69 | state: ENABLED 70 | targeting: 71 | if: 72 | - "$ref": emailWithFaas 73 | - fractional: 74 | - cat: 75 | - var: $flagd.flagKey 76 | - var: email 77 | - - red 78 | - 25 79 | - - blue 80 | - 25 81 | - - green 82 | - 25 83 | - - yellow 84 | - 25 85 | - null 86 | "$evaluators": 87 | emailWithFaas: 88 | in: 89 | - "@faas.com" 90 | - var: 91 | - email -------------------------------------------------------------------------------- /samples/example_flags_secondary.flagd.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://flagd.dev/schema/v0/flags.json", 3 | "flags": { 4 | "myBoolFlag": { 5 | "state": "ENABLED", 6 | "variants": { 7 | "on": true, 8 | "off": false 9 | }, 10 | "defaultVariant": "off" 11 | }, 12 | "isColorGreen": { 13 | "state": "ENABLED", 14 | "variants": { 15 | "on": true, 16 | "off": false 17 | }, 18 | "defaultVariant": "off", 19 | "targeting": { 20 | "if": [ 21 | { 22 | "==": [ 23 | { 24 | "var": [ 25 | "color" 26 | ] 27 | }, 28 | "yellow" 29 | ] 30 | }, 31 | "on", 32 | "off" 33 | ] 34 | } 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /samples/example_flags_secondary.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://flagd.dev/schema/v0/flags.json", 3 | "flags": { 4 | "myBoolFlag": { 5 | "state": "ENABLED", 6 | "variants": { 7 | "on": true, 8 | "off": false 9 | }, 10 | "defaultVariant": "off" 11 | }, 12 | "isColorGreen": { 13 | "state": "ENABLED", 14 | "variants": { 15 | "on": true, 16 | "off": false 17 | }, 18 | "defaultVariant": "off", 19 | "targeting": { 20 | "if": [ 21 | { 22 | "==": [ 23 | { 24 | "var": [ 25 | "color" 26 | ] 27 | }, 28 | "yellow" 29 | ] 30 | }, 31 | "on", 32 | "off" 33 | ] 34 | } 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: flagd 2 | base: core20 3 | version: "v0.4.2" # x-release-please-version 4 | summary: A feature flag daemon with a Unix philosophy 5 | description: | 6 | Flagd is a simple command line tool for fetching and evaluating feature flags for services. It is designed to conform with the OpenFeature specification. 7 | grade: stable # must be 'stable' to release into candidate/stable channels 8 | confinement: strict 9 | architectures: 10 | - build-on: amd64 11 | - build-on: arm64 12 | apps: 13 | flagd: 14 | command: bin/flagd 15 | plugs: 16 | - home 17 | - network 18 | - network-bind 19 | parts: 20 | home: 21 | # See 'snapcraft plugins' 22 | plugin: go 23 | source-type: git 24 | source: https://github.com/open-feature/flagd.git 25 | -------------------------------------------------------------------------------- /systemd/flagd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description="A feature flag daemon with a Unix philosophy" 3 | 4 | [Service] 5 | User=root 6 | WorkingDirectory=/etc/flagd 7 | ExecStart=flagd start --uri file:flags.json 8 | Restart=always 9 | 10 | [Install] 11 | WantedBy=multi-user.target 12 | -------------------------------------------------------------------------------- /systemd/flags.json: -------------------------------------------------------------------------------- 1 | { 2 | "flags": { 3 | "myFlag": { 4 | "state": "ENABLED", 5 | "variants": { 6 | "on": true, 7 | "off": false 8 | }, 9 | "defaultVariant": "on" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/README.MD: -------------------------------------------------------------------------------- 1 | ## Tests 2 | 3 | This folder contains testing resources for flagd & flagd-proxy. 4 | 5 | - [Load Tests](loadtest) 6 | - [Integration Tests](integration) 7 | - [Zero Downtime Tests for flagd](zero-downtime) 8 | - [Zero Downtime Tests for flagd-proxy](zero-downtime) -------------------------------------------------------------------------------- /test/integration/README.md: -------------------------------------------------------------------------------- 1 | # Integration tests 2 | 3 | The continuous integration runs a set of [gherkin integration tests](https://github.com/open-feature/test-harness/blob/main/features). 4 | If you'd like to run them locally, first pull the `test-harness` git submodule 5 | 6 | ```shell 7 | git submodule update --init --recursive 8 | ``` 9 | 10 | then build the `flagd` binary 11 | 12 | ```shell 13 | make build 14 | ``` 15 | 16 | then run the `flagd` binary 17 | 18 | ```shell 19 | ./bin/flagd start -f file:test-harness/symlink_testing-flags.json 20 | ``` 21 | 22 | and finally run 23 | 24 | ```shell 25 | make integration-test 26 | ``` 27 | 28 | ## TLS 29 | 30 | To run the integration tests against a `flagd` instance configured to use TLS, do the following: 31 | 32 | Generate a cert and key in the repository root 33 | 34 | ```shell 35 | openssl req -x509 -out localhost.crt -keyout localhost.key \ 36 | -newkey rsa:2048 -nodes -sha256 \ 37 | -subj '/CN=localhost' -extensions EXT -config <( \ 38 | printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth") 39 | ``` 40 | 41 | build the `flagd` binary 42 | 43 | ```shell 44 | make build 45 | ``` 46 | 47 | then run the `flagd` binary with tls configuration 48 | 49 | ```shell 50 | ./bin/flagd start -f file:test-harness/symlink_testing-flags.json -c ./localhost.crt -k ./localhost.key 51 | ``` 52 | 53 | finally, either run the tests with an explicit path to the certificate: 54 | 55 | ```shell 56 | make ARGS="-tls true -cert-path ./../../localhost.crt" integration-test 57 | ``` 58 | 59 | or, run without the path, defaulting to the host's root certificate authorities set (for this to work, the certificate must be registered and trusted in the host's system certificates) 60 | 61 | ```shell 62 | make ARGS="-tls true" integration-test 63 | ``` 64 | -------------------------------------------------------------------------------- /test/integration/config/envoy.yaml: -------------------------------------------------------------------------------- 1 | static_resources: 2 | listeners: 3 | - name: local-envoy 4 | address: 5 | socket_address: 6 | address: 0.0.0.0 7 | port_value: 9211 8 | filter_chains: 9 | - filters: 10 | - name: envoy.filters.network.http_connection_manager 11 | typed_config: 12 | "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager 13 | stat_prefix: ingress_http 14 | access_log: 15 | - name: envoy.access_loggers.stdout 16 | typed_config: 17 | "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog 18 | http_filters: 19 | - name: envoy.filters.http.router 20 | typed_config: 21 | "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router 22 | route_config: 23 | name: local_route 24 | virtual_hosts: 25 | - name: local_service 26 | domains: 27 | - "flagd-sync.service" 28 | routes: 29 | - match: 30 | prefix: "/" 31 | grpc: {} 32 | route: 33 | cluster: local-sync-service 34 | 35 | clusters: 36 | - name: local-sync-service 37 | type: LOGICAL_DNS 38 | # Comment out the following line to test on v6 networks 39 | dns_lookup_family: V4_ONLY 40 | http2_protocol_options: {} 41 | load_assignment: 42 | cluster_name: local-sync-service 43 | endpoints: 44 | - lb_endpoints: 45 | - endpoint: 46 | address: 47 | socket_address: 48 | address: sync-service 49 | port_value: 8015 -------------------------------------------------------------------------------- /test/integration/evaluation_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "flag" 5 | "testing" 6 | 7 | "github.com/cucumber/godog" 8 | flagd "github.com/open-feature/go-sdk-contrib/providers/flagd/pkg" 9 | "github.com/open-feature/go-sdk-contrib/tests/flagd/pkg/integration" 10 | "github.com/open-feature/go-sdk/openfeature" 11 | ) 12 | 13 | func TestEvaluation(t *testing.T) { 14 | if testing.Short() { 15 | t.Skip() 16 | } 17 | 18 | flag.Parse() 19 | 20 | var providerOptions []flagd.ProviderOption 21 | name := "evaluation.feature" 22 | 23 | if tls == "true" { 24 | name = "evaluation_tls.feature" 25 | providerOptions = []flagd.ProviderOption{flagd.WithTLS(certPath)} 26 | } 27 | 28 | testSuite := godog.TestSuite{ 29 | Name: name, 30 | TestSuiteInitializer: integration.InitializeTestSuite(func() openfeature.FeatureProvider { 31 | return flagd.NewProvider(providerOptions...) 32 | }), 33 | ScenarioInitializer: integration.InitializeEvaluationScenario, 34 | Options: &godog.Options{ 35 | Format: "pretty", 36 | Paths: []string{"../../spec/specification/assets/gherkin/evaluation.feature"}, 37 | TestingT: t, // Testing instance that will run subtests. 38 | Strict: true, 39 | }, 40 | } 41 | 42 | if testSuite.Run() != 0 { 43 | t.Fatal("non-zero status returned, failed to run evaluation tests") 44 | } 45 | } 46 | 47 | func TestEvaluationUsingEnvoy(t *testing.T) { 48 | if testing.Short() { 49 | t.Skip() 50 | } 51 | 52 | flag.Parse() 53 | 54 | name := "evaluation_envoy.feature" 55 | providerOptions := []flagd.ProviderOption{ 56 | flagd.WithTargetUri("envoy://localhost:9211/flagd-sync.service"), 57 | } 58 | 59 | testSuite := godog.TestSuite{ 60 | Name: name, 61 | TestSuiteInitializer: integration.InitializeTestSuite(func() openfeature.FeatureProvider { 62 | return flagd.NewProvider(providerOptions...) 63 | }), 64 | ScenarioInitializer: integration.InitializeEvaluationScenario, 65 | Options: &godog.Options{ 66 | Format: "pretty", 67 | Paths: []string{"../../spec/specification/assets/gherkin/evaluation.feature"}, 68 | TestingT: t, // Testing instance that will run subtests. 69 | Strict: true, 70 | }, 71 | } 72 | 73 | if testSuite.Run() != 0 { 74 | t.Fatal("non-zero status returned, failed to run evaluation tests") 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /test/integration/integration_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import "flag" 4 | 5 | var ( 6 | tls string 7 | certPath string 8 | ) 9 | 10 | func init() { 11 | flag.StringVar(&tls, "tls", "false", "tls enabled for testing") 12 | flag.StringVar(&certPath, "cert-path", "", "path to cert to use in tls tests") 13 | } 14 | -------------------------------------------------------------------------------- /test/integration/json_evaluator_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "flag" 5 | "testing" 6 | 7 | "github.com/cucumber/godog" 8 | flagd "github.com/open-feature/go-sdk-contrib/providers/flagd/pkg" 9 | "github.com/open-feature/go-sdk-contrib/tests/flagd/pkg/integration" 10 | "github.com/open-feature/go-sdk/openfeature" 11 | ) 12 | 13 | func TestJsonEvaluator(t *testing.T) { 14 | if testing.Short() { 15 | t.Skip() 16 | } 17 | 18 | flag.Parse() 19 | 20 | var providerOptions []flagd.ProviderOption 21 | name := "flagd-json-evaluator.feature" 22 | 23 | testSuite := godog.TestSuite{ 24 | Name: name, 25 | TestSuiteInitializer: integration.InitializeFlagdJsonTestSuite(func() openfeature.FeatureProvider { 26 | return flagd.NewProvider(providerOptions...) 27 | }), 28 | ScenarioInitializer: integration.InitializeFlagdJsonScenario, 29 | Options: &godog.Options{ 30 | Format: "pretty", 31 | Paths: []string{"../../test-harness/gherkin/flagd-json-evaluator.feature"}, 32 | TestingT: t, // Testing instance that will run subtests. 33 | Strict: true, 34 | }, 35 | } 36 | 37 | if testSuite.Run() != 0 { 38 | t.Fatal("non-zero status returned, failed to run evaluation tests") 39 | } 40 | } 41 | 42 | func TestJsonEvaluatorUsingEnvoy(t *testing.T) { 43 | if testing.Short() { 44 | t.Skip() 45 | } 46 | 47 | flag.Parse() 48 | 49 | name := "flagd-json-evaluator-envoy.feature" 50 | providerOptions := []flagd.ProviderOption{ 51 | flagd.WithTargetUri("envoy://localhost:9211/flagd-sync.service"), 52 | } 53 | 54 | testSuite := godog.TestSuite{ 55 | Name: name, 56 | TestSuiteInitializer: integration.InitializeFlagdJsonTestSuite(func() openfeature.FeatureProvider { 57 | return flagd.NewProvider(providerOptions...) 58 | }), 59 | ScenarioInitializer: integration.InitializeFlagdJsonScenario, 60 | Options: &godog.Options{ 61 | Format: "pretty", 62 | Paths: []string{"../../test-harness/gherkin/flagd-json-evaluator.feature"}, 63 | TestingT: t, // Testing instance that will run subtests. 64 | Strict: true, 65 | }, 66 | } 67 | 68 | if testSuite.Run() != 0 { 69 | t.Fatal("non-zero status returned, failed to run evaluation tests") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/loadtest/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore random FF jsons generated from ff_gen.go 2 | random.json -------------------------------------------------------------------------------- /test/loadtest/README.MD: -------------------------------------------------------------------------------- 1 | ## Load Testing 2 | 3 | This folder contains resources for flagd load testing. 4 | 5 | - ff_gen.go : simple, random feature flag generation utility. 6 | - sample_k6.js : sample K6 load test script 7 | 8 | ### Profiling 9 | 10 | It's possible to utilize `profiler.go` included with flagd source to profile flagd during 11 | load test. Profiling is enabled through [go pprof package](https://pkg.go.dev/net/http/pprof). 12 | 13 | To enable pprof profiling, build a docker image with the `profile.Dockerfile` 14 | 15 | ex:- `docker build . -f ./flagd/profile.Dockerfile -t flagdprofile` 16 | 17 | This image now exposes port `6060` for pprof data. 18 | 19 | ### Example test run 20 | 21 | First, let's create random feature flags using `ff_gen.go` utility. To generate 100 boolean feature flags, 22 | run the command 23 | 24 | `go run ff_gen.go -c 100 -t boolean` 25 | 26 | This command generates `random.json`in the same directory. 27 | 28 | Then, let's start pprof profiler enabled flagd docker container with newly generated feature flags. 29 | 30 | `docker run -p 8013:8013 -p 6060:6060 --rm -it -v $(pwd):/etc/flagd flagdprofile start --uri file:./etc/flagd/random.json` 31 | 32 | Finally, you can run the K6 test script to load test the flagd container. 33 | 34 | `k6 run sample_k6.js` 35 | 36 | To observe the pprof date, you can either visit [http://localhost:6060/debug/pprof/](http://localhost:6060/debug/pprof/) 37 | or use go pprof tool. Example tool usages are given below, 38 | 39 | - Analyze heap in command line: `go tool pprof http://localhost:6060/debug/pprof/heap` 40 | - Analyze heap in UI mode: `go tool pprof --http=:9090 http://localhost:6060/debug/pprof/heap` 41 | 42 | ### Performance observations 43 | 44 | flagd performs well under heavy loads. Consider the following results observed against the HTTP API of flagd, 45 | 46 | ![](../../images/loadTestResults.png) 47 | 48 | flagd is able to serve ~20K HTTP requests/second with just 64MB memory and 1 CPU. And the impact of flag type 49 | is minimal. There was no memory pressure observed throughout the test runs. 50 | 51 | #### Note on observations 52 | 53 | Above observations were made on a single system. Hence, throughput does not account for network delays. 54 | Also, there were no background syncs or context evaluations performed. 55 | 56 | -------------------------------------------------------------------------------- /test/loadtest/ff_gen.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "math/rand" 8 | "os" 9 | ) 10 | 11 | const ( 12 | BOOL = "boolean" 13 | STRING = "string" 14 | ) 15 | 16 | /* 17 | A simple random feature flag generator for testing purposes. Output is saved to "random.json". 18 | 19 | Configurable options: 20 | 21 | -c : feature flag count (ex:go run ff_gen.go -c 500) 22 | -t : type of feature flag (ex:go run ff_gen.go -t string). Support "boolean" and "string" 23 | */ 24 | //nolint:gosec 25 | func main() { 26 | // Get flag count 27 | var flagCount int 28 | flag.IntVar(&flagCount, "c", 100, "Number of flags to generate") 29 | 30 | // Get flag type : Boolean, String 31 | var flagType string 32 | flag.StringVar(&flagType, "t", BOOL, "Type of flags to generate") 33 | 34 | flag.Parse() 35 | 36 | if flagType != STRING && flagType != BOOL { 37 | fmt.Printf("Invalid type %s. Falling back to default %s", flagType, BOOL) 38 | flagType = BOOL 39 | } 40 | 41 | root := Flags{} 42 | root.Flags = make(map[string]Flag) 43 | 44 | switch flagType { 45 | case BOOL: 46 | root.setBoolFlags(flagCount) 47 | case STRING: 48 | root.setStringFlags(flagCount) 49 | } 50 | 51 | bytes, err := json.Marshal(root) 52 | if err != nil { 53 | fmt.Printf("Json error: %s ", err.Error()) 54 | return 55 | } 56 | 57 | err = os.WriteFile("./random.json", bytes, 0o444) 58 | if err != nil { 59 | fmt.Printf("File write error: %s ", err.Error()) 60 | return 61 | } 62 | } 63 | 64 | func (f *Flags) setBoolFlags(toGen int) { 65 | for i := 0; i < toGen; i++ { 66 | variant := make(map[string]any) 67 | variant["on"] = true 68 | variant["off"] = false 69 | 70 | f.Flags[fmt.Sprintf("flag%d", i)] = Flag{ 71 | State: "ENABLED", 72 | DefaultVariant: randomSelect("on", "off"), 73 | Variants: variant, 74 | } 75 | } 76 | } 77 | 78 | func (f *Flags) setStringFlags(toGen int) { 79 | for i := 0; i < toGen; i++ { 80 | variant := make(map[string]any) 81 | variant["key1"] = "value1" 82 | variant["key2"] = "value2" 83 | 84 | f.Flags[fmt.Sprintf("flag%d", i)] = Flag{ 85 | State: "ENABLED", 86 | DefaultVariant: randomSelect("key1", "key2"), 87 | Variants: variant, 88 | } 89 | } 90 | } 91 | 92 | type Flags struct { 93 | Flags map[string]Flag `json:"flags"` 94 | } 95 | 96 | type Flag struct { 97 | State string `json:"state"` 98 | DefaultVariant string `json:"defaultVariant"` 99 | Variants map[string]any `json:"variants"` 100 | } 101 | 102 | //nolint:gosec 103 | func randomSelect(chooseFrom ...string) string { 104 | return chooseFrom[rand.Intn(len(chooseFrom))] 105 | } 106 | -------------------------------------------------------------------------------- /test/loadtest/go.mod: -------------------------------------------------------------------------------- 1 | module tests.loadtest 2 | 3 | go 1.19 4 | -------------------------------------------------------------------------------- /test/loadtest/go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-feature/flagd/761d870a3c563b8eb1b83ee543b41316c98a1d48/test/loadtest/go.sum -------------------------------------------------------------------------------- /test/loadtest/sample_k6.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | 3 | /* 4 | * Sample K6 (https://k6.io/) load test script 5 | * */ 6 | 7 | // K6 options - Load generation pattern: Ramp up, hold and teardown 8 | export const options = { 9 | stages: [{duration: '10s', target: 50}, {duration: '30s', target: 50}, {duration: '10s', target: 0},] 10 | } 11 | 12 | // Flag prefix - See ff_gen.go to match 13 | export const prefix = "flag" 14 | 15 | // Custom options : Number of FFs flagd serves and type of the FFs being served 16 | export const customOptions = { 17 | ffCount: 100, 18 | type: "boolean" 19 | } 20 | 21 | export default function () { 22 | // Randomly select flag to evaluate 23 | let flag = prefix + Math.floor((Math.random() * customOptions.ffCount)) 24 | 25 | let resp = http.post(genUrl(customOptions.type), JSON.stringify({ 26 | flagKey: flag, context: {} 27 | }), {headers: {'Content-Type': 'application/json'}}); 28 | 29 | // Handle and report errors 30 | if (resp.status !== 200) { 31 | console.log("Error response - FlagId : " + flag + " Response :" + JSON.stringify(resp.body)) 32 | } 33 | } 34 | 35 | export function genUrl(type) { 36 | switch (type) { 37 | case "boolean": 38 | return "http://localhost:8013/flagd.evaluation.v1.Service/ResolveBoolean" 39 | case "string": 40 | return "http://localhost:8013/flagd.evaluation.v1.Service/ResolveString" 41 | } 42 | } -------------------------------------------------------------------------------- /test/zero-downtime-flagd-proxy/README.md: -------------------------------------------------------------------------------- 1 | # FlagD Proxy Zero downtime test 2 | 3 | ## How to run 4 | 5 | Clone this repository and run the following command: 6 | 7 | ```shell 8 | FLAGD_PROXY_IMG=your-flagd-proxy-image FLAGD_PROXY_IMG_ZD=your-flagd-proxy-second-image ZD_CLIENT_IMG=your-zd-client-image make run-flagd-proxy-zd-test 9 | ``` 10 | 11 | This will create a flagd-proxy and a job in `flagd-zd-test` namespace, 12 | where the test will be run. 13 | 14 | Please be aware, you need to build your custom image for the zd-client 15 | and two images for flagD-proxy first. 16 | 17 | To build your images using [ko](https://github.com/ko-build/ko), 18 | you need to login to your repository, where the images will be pushed: 19 | 20 | ```shell 21 | ko login your_repository_server -u username -p password 22 | ``` 23 | 24 | Afterwards, use this command to build flagd-proxy or zd-client: 25 | 26 | ```shell 27 | KO_DOCKER_REPO=your_repository_server ko build . --bare --tags your-tag 28 | ``` 29 | -------------------------------------------------------------------------------- /test/zero-downtime-flagd-proxy/go.mod: -------------------------------------------------------------------------------- 1 | module zero-downtime-test 2 | 3 | go 1.20 4 | 5 | require ( 6 | buf.build/gen/go/open-feature/flagd/grpc/go v1.3.0-20240215170432-1e611e2999cc.3 7 | buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.34.1-20240215170432-1e611e2999cc.1 8 | google.golang.org/grpc v1.63.2 9 | ) 10 | 11 | require ( 12 | golang.org/x/net v0.25.0 // indirect 13 | golang.org/x/sys v0.20.0 // indirect 14 | golang.org/x/text v0.15.0 // indirect 15 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240506185236-b8a5c65736ae // indirect 16 | google.golang.org/protobuf v1.34.1 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /test/zero-downtime-flagd-proxy/grpc.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | pb "buf.build/gen/go/open-feature/flagd/grpc/go/sync/v1/syncv1grpc" 9 | schemav1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/sync/v1" 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/credentials/insecure" 12 | ) 13 | 14 | //nolint:staticcheck 15 | func doRequests(grpcClient pb.FlagSyncServiceClient, waitSecondsBetweenRequests int) error { 16 | ctx := context.TODO() 17 | stream, err := grpcClient.SyncFlags(ctx, &schemav1.SyncFlagsRequest{ 18 | ProviderId: "zd", 19 | Selector: "file:/etc/flagd/config.json", 20 | }) 21 | if err != nil { 22 | return fmt.Errorf("error SyncFlags(): " + err.Error()) 23 | } 24 | 25 | for { 26 | // We do not care about the message received, only the error and then we try to re-connect. 27 | // If the re-connection fails; the server is down and ZD test should fail 28 | _, err = stream.Recv() 29 | if err != nil { 30 | fmt.Println("error Recv(): " + err.Error()) 31 | stream, err = grpcClient.SyncFlags(ctx, &schemav1.SyncFlagsRequest{ 32 | ProviderId: "zd", 33 | Selector: "file:/etc/flagd/config.json", 34 | }) 35 | if err != nil { 36 | return fmt.Errorf("error SyncFlags(): " + err.Error()) 37 | } 38 | } 39 | <-time.After(time.Duration(waitSecondsBetweenRequests) * time.Second) 40 | } 41 | } 42 | 43 | func establishGrpcConnection(url string) (*grpc.ClientConn, pb.FlagSyncServiceClient) { 44 | conn, err := grpc.NewClient(url, grpc.WithTransportCredentials(insecure.NewCredentials())) 45 | if err != nil { 46 | fmt.Println(err.Error()) 47 | } 48 | client := pb.NewFlagSyncServiceClient(conn) 49 | return conn, client 50 | } 51 | -------------------------------------------------------------------------------- /test/zero-downtime-flagd-proxy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | pb "buf.build/gen/go/open-feature/flagd/grpc/go/sync/v1/syncv1grpc" 9 | "google.golang.org/grpc" 10 | ) 11 | 12 | func main() { 13 | waitSecondsBetweenRequests := getWaitSecondsBetweenRequests() 14 | flagdURL := getURL() 15 | 16 | // Create a channel to receive a signal when the gRPC connection fails 17 | errChan := make(chan bool) 18 | 19 | // Use a goroutine to run your program logic 20 | go func() { 21 | if err := handleRequests(waitSecondsBetweenRequests, flagdURL); err != nil { 22 | errChan <- true 23 | } 24 | }() 25 | 26 | // The program should run until it receives an error 27 | <-errChan 28 | } 29 | 30 | func handleRequests(waitSecondsBetweenRequests int, flagdURL string) error { 31 | var conn *grpc.ClientConn 32 | var grpcClient pb.FlagSyncServiceClient 33 | // open the connection only once 34 | conn, grpcClient = establishGrpcConnection(flagdURL) 35 | 36 | defer func() { 37 | if conn != nil { 38 | // clean up 39 | err := conn.Close() 40 | if err != nil { 41 | fmt.Println(err.Error()) 42 | } 43 | } 44 | }() 45 | 46 | return doRequests(grpcClient, waitSecondsBetweenRequests) 47 | } 48 | 49 | func getWaitSecondsBetweenRequests() int { 50 | return getEnvVarOrDefault("WAIT_TIME_BETWEEN_REQUESTS_S", 1) 51 | } 52 | 53 | func getURL() string { 54 | return getEnvOrDefault("URL", "flagd-proxy-svc.flagd-dev:8015") 55 | } 56 | 57 | func getEnvVarOrDefault(envVar string, defaultValue int) int { 58 | if envVarValue := os.Getenv(envVar); envVarValue != "" { 59 | parsedEnvVarValue, err := strconv.ParseInt(envVarValue, 10, 64) 60 | if err == nil && parsedEnvVarValue > 0 { 61 | defaultValue = int(parsedEnvVarValue) 62 | } 63 | } 64 | return defaultValue 65 | } 66 | 67 | func getEnvOrDefault(envVar string, defaultValue string) string { 68 | if envVarValue := os.Getenv(envVar); envVarValue != "" { 69 | return envVarValue 70 | } 71 | return defaultValue 72 | } 73 | -------------------------------------------------------------------------------- /test/zero-downtime-flagd-proxy/manifests/pod.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: zd-test 5 | spec: 6 | containers: 7 | - name: flagd-proxy-zd 8 | image: ${ZD_CLIENT_IMG} 9 | env: 10 | - name: URL 11 | value: "flagd-proxy-svc:8015" 12 | - name: WAIT_TIME_BETWEEN_REQUESTS_S 13 | value: "1" 14 | -------------------------------------------------------------------------------- /test/zero-downtime-flagd-proxy/manifests/proxy/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: flagd-proxy 6 | name: flagd-proxy 7 | spec: 8 | replicas: 1 9 | strategy: 10 | type: RollingUpdate 11 | rollingUpdate: 12 | maxSurge: 1 13 | maxUnavailable: 0 14 | selector: 15 | matchLabels: 16 | app: flagd-proxy 17 | template: 18 | metadata: 19 | labels: 20 | app.kubernetes.io/name: flagd-proxy 21 | app: flagd-proxy 22 | spec: 23 | terminationGracePeriodSeconds: 10 24 | containers: 25 | - image: ${FLAGD_PROXY_IMG} 26 | name: flagd-proxy 27 | volumeMounts: 28 | - name: config-volume 29 | mountPath: /etc/flagd 30 | readinessProbe: 31 | httpGet: 32 | path: /readyz 33 | port: 8016 34 | initialDelaySeconds: 5 35 | periodSeconds: 5 36 | livenessProbe: 37 | httpGet: 38 | path: /healthz 39 | port: 8016 40 | initialDelaySeconds: 5 41 | periodSeconds: 60 42 | ports: 43 | - containerPort: 8015 44 | args: 45 | - start 46 | volumes: 47 | - name: config-volume 48 | configMap: 49 | name: open-feature-flags 50 | items: 51 | - key: flags 52 | path: config.json 53 | -------------------------------------------------------------------------------- /test/zero-downtime-flagd-proxy/manifests/proxy/flag-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # ConfigMap for Flagd OpenFeature provider 3 | apiVersion: v1 4 | kind: ConfigMap 5 | metadata: 6 | name: open-feature-flags 7 | data: 8 | flags: | 9 | { 10 | "flags": { 11 | "myStringFlag": { 12 | "state": "ENABLED", 13 | "variants": { 14 | "key1": "val1", 15 | "key2": "val2" 16 | }, 17 | "defaultVariant": "key1" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/zero-downtime-flagd-proxy/manifests/proxy/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: flagd-proxy-svc 5 | spec: 6 | selector: 7 | app.kubernetes.io/name: flagd-proxy 8 | ports: 9 | - port: 8015 10 | targetPort: 8015 11 | -------------------------------------------------------------------------------- /test/zero-downtime/README.md: -------------------------------------------------------------------------------- 1 | # FlagD Zero downtime test 2 | 3 | ## How to run 4 | 5 | Clone this repository and run the following command to deploy a standalone flagD: 6 | 7 | ```shell 8 | IMG=your-flagd-image make deploy-dev-env 9 | ``` 10 | 11 | This will create a flagd deployment `flagd-dev` namespace. 12 | 13 | To run the test, execute: 14 | 15 | ```shell 16 | IMG=your-flagd-image IMG_ZD=your-flagd-image2 make run-zd-test 17 | ``` 18 | 19 | Please be aware, you need to build your two custom images with different tags for flagD first. 20 | 21 | To build your images using Docker execute: 22 | 23 | ```shell 24 | docker build . -t image-name:tag -f flagd/build.Dockerfile 25 | ``` 26 | -------------------------------------------------------------------------------- /test/zero-downtime/test-pod.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: test-zd 5 | spec: 6 | containers: 7 | - name: test-zd 8 | image: curlimages/curl:8.1.2 9 | # yamllint disable rule:line-length 10 | command: 11 | - 'sh' 12 | - '-c' 13 | - | 14 | for i in $(seq 1 3000); do 15 | curl -H 'Cache-Control: no-cache, no-store' -X POST flagd-svc.$FLAGD_DEV_NAMESPACE.svc.cluster.local:8013/flagd.evaluation.v1.Service/ResolveString?$RANDOM -d '{"flagKey":"myStringFlag","context":{}}' -H "Content-Type: application/json" > ~/out.txt 16 | if ! grep -q "val1" ~/out.txt 17 | then 18 | cat ~/out.txt 19 | echo "\n\nCannot fetch data from flagD, exiting...\n\n" 20 | exit 1 21 | fi 22 | sleep 1 23 | done 24 | exit 0 25 | # yamllint enable rule:line-length 26 | -------------------------------------------------------------------------------- /test/zero-downtime/zd_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | # Store the flagD image to a helper variable 6 | IMG_ORIGINAL=$IMG 7 | 8 | # Create pod requesting the values from flagD 9 | envsubst < test/zero-downtime/test-pod.yaml | kubectl apply -f - -n $ZD_TEST_NAMESPACE 10 | 11 | for count in 1 2 3; 12 | do 13 | # Update the flagD deployment with the second image 14 | IMG=$IMG_ZD 15 | envsubst < config/deployments/flagd/deployment.yaml | kubectl apply -f - -n $FLAGD_DEV_NAMESPACE 16 | kubectl wait --for=condition=available deployment/flagd -n $FLAGD_DEV_NAMESPACE --timeout=30s 17 | 18 | # Wait until the client pod executes curl requests agains flagD 19 | sleep 20 20 | 21 | # Update the flagDT deployment back to original image 22 | IMG=$IMG_ORIGINAL 23 | envsubst < config/deployments/flagd/deployment.yaml | kubectl apply -f - -n $FLAGD_DEV_NAMESPACE 24 | kubectl wait --for=condition=available deployment/flagd -n $FLAGD_DEV_NAMESPACE --timeout=30s 25 | 26 | # Wait until the client pod executes curl requests agains flagD 27 | sleep 20 28 | done 29 | 30 | # Pod will fail only when it fails to get a proper response from curl (that means we do not have zero downtime) 31 | # If it is still running, the last curl request was successfull. 32 | kubectl wait --for=condition=ready pod/test-zd -n $ZD_TEST_NAMESPACE --timeout=30s 33 | 34 | # If curl request once not successful and another curl request was, pod might be in a ready state again. 35 | # Therefore we need to check that the restart count is equal to zero -> this means every request provided valid data. 36 | restart_count=$(kubectl get pods test-zd -o=jsonpath='{.status.containerStatuses[0].restartCount}' -n $ZD_TEST_NAMESPACE) 37 | if [ "$restart_count" -ne 0 ]; then 38 | echo "Restart count of the test-zd pod is not equal to zero." 39 | exit 1 40 | fi 41 | 42 | # Cleanup only when the test passed 43 | kubectl delete ns $ZD_TEST_NAMESPACE --ignore-not-found=true 44 | 45 | --------------------------------------------------------------------------------