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 | 
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 | [](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 | 
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 |
--------------------------------------------------------------------------------