Skip to content

Commit 17de2b7

Browse files
mahpatilAyushman-Gauropenfeaturebotthomaspoignanttoddbaert
authored
feat: add GCP Secret Manager OpenFeature provider (#1772)
Signed-off-by: Ayushman Gaur <ayushmangaur2017@gmail.com> Signed-off-by: Mahesh Patil <maheshfinity@gmail.com> Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: Thomas Poignant <thomas.poignant@gofeatureflag.org> Signed-off-by: Mahesh Patil <17205424+mahpatil@users.noreply.github.com> Signed-off-by: Todd Baert <todd.baert@dynatrace.com> Co-authored-by: Ayushman-Gaur <120575155+Ayushman-Gaur@users.noreply.github.com> Co-authored-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Co-authored-by: Thomas Poignant <thomas.poignant@gofeatureflag.org> Co-authored-by: Todd Baert <todd.baert@dynatrace.com>
1 parent 2b3fc73 commit 17de2b7

29 files changed

Lines changed: 2036 additions & 0 deletions

.github/component_owners.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ components:
2828
providers/flipt:
2929
- liran2000
3030
- markphelps
31+
providers/gcp:
32+
- mahpatil
3133
providers/configcat:
3234
- liran2000
3335
- z4kn4fein

.release-please-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"providers/statsig": "0.2.1",
1212
"providers/multiprovider": "0.0.3",
1313
"providers/ofrep": "0.0.1",
14+
"providers/gcp": "0.0.1",
1415
"tools/junit-openfeature": "0.2.1",
1516
"tools/flagd-http-connector": "0.0.4",
1617
"tools/flagd-api": "1.0.0",

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
<module>providers/optimizely</module>
4646
<module>providers/multiprovider</module>
4747
<module>providers/ofrep</module>
48+
<module>providers/gcp</module>
4849
<module>tools/flagd-http-connector</module>
4950
</modules>
5051

providers/gcp/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Changelog
2+
3+
## 0.0.1
4+
5+
### ✨ New Features
6+
7+
* Initial release with scaffolding for Google Cloud and GCP Secret Manager OpenFeature provider.

providers/gcp/README.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# GCP Provider
2+
3+
An OpenFeature provider that reads feature flags from Google Cloud. Currently supports [Google Cloud Secret Manager](https://cloud.google.com/secret-manager), purpose-built for secrets requiring versioning, rotation, and fine-grained IAM access control.
4+
5+
## Installation
6+
7+
<!-- x-release-please-start-version -->
8+
```xml
9+
<dependency>
10+
<groupId>dev.openfeature.contrib.providers</groupId>
11+
<artifactId>gcp</artifactId>
12+
<version>0.0.1</version>
13+
</dependency>
14+
```
15+
<!-- x-release-please-end-version -->
16+
17+
## Quick Start
18+
19+
```java
20+
import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProvider;
21+
import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProviderOptions;
22+
import dev.openfeature.sdk.OpenFeatureAPI;
23+
24+
GcpProviderOptions options = GcpSecretManagerProviderOptions.builder()
25+
.projectId("my-gcp-project")
26+
.build();
27+
28+
OpenFeatureAPI.getInstance().setProvider(new GcpSecretManagerProvider(options));
29+
30+
// Evaluate a boolean flag stored as secret "enable-dark-mode" with value "true"
31+
boolean darkMode = OpenFeatureAPI.getInstance().getClient()
32+
.getBooleanValue("enable-dark-mode", false);
33+
```
34+
35+
## How It Works
36+
37+
Each feature flag is stored as an individual **secret** in GCP Secret Manager. The flag key maps directly to the secret name (with an optional prefix). The `latest` version is accessed by default.
38+
39+
Supported raw value formats:
40+
41+
| Flag type | Secret value example |
42+
|-----------|---------------------|
43+
| Boolean | `true` or `false` |
44+
| Integer | `42` |
45+
| Double | `3.14` |
46+
| String | `dark-mode` |
47+
| Object | `{"color":"blue","level":3}` |
48+
49+
## Authentication
50+
51+
The provider uses [Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/provide-credentials-adc) by default. No explicit credentials are required when running on GCP infrastructure (Cloud Run, GKE, Compute Engine) or when `gcloud auth application-default login` has been run locally.
52+
53+
To use explicit credentials:
54+
55+
```java
56+
import com.google.auth.oauth2.ServiceAccountCredentials;
57+
import java.io.FileInputStream;
58+
59+
GoogleCredentials credentials = ServiceAccountCredentials.fromStream(
60+
new FileInputStream("/path/to/service-account-key.json"));
61+
62+
GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder()
63+
.projectId("my-gcp-project")
64+
.credentials(credentials)
65+
.build();
66+
```
67+
68+
## Configuration Options
69+
70+
| Option | Type | Default | Description |
71+
|--------|------|---------|-------------|
72+
| `projectId` | `String` | *(required)* | GCP project ID that owns the secrets |
73+
| `credentials` | `GoogleCredentials` | `null` (ADC) | Explicit credentials; falls back to Application Default Credentials when null |
74+
| `version` | `String` | `"latest"` | Secret version to access. Use `"latest"` for the current version or a numeric string (e.g. `"3"`) to pin to a specific version |
75+
| `cacheExpiry` | `Duration` | `5 minutes` | How long fetched secret values are cached before re-fetching from GCP |
76+
| `cacheMaxSize` | `int` | `500` | Maximum number of secret values held in the in-memory cache |
77+
| `namePrefix` | `String` | `null` | Optional prefix prepended to every flag key. E.g. prefix `"ff-"` maps flag `"my-flag"` to secret `"ff-my-flag"` |
78+
79+
## Advanced Usage
80+
81+
### Pinning to a specific secret version
82+
83+
```java
84+
GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder()
85+
.projectId("my-gcp-project")
86+
.secretVersion("5") // always use version 5
87+
.build();
88+
```
89+
90+
### Secret name prefix
91+
92+
```java
93+
GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder()
94+
.projectId("my-gcp-project")
95+
.namePrefix("feature-flags/")
96+
.build();
97+
```
98+
99+
### Tuning cache for high-throughput scenarios
100+
101+
Secret Manager has API quotas (10,000 access operations per minute per project). Use a longer `cacheExpiry` to stay within quota.
102+
103+
```java
104+
GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder()
105+
.projectId("my-gcp-project")
106+
.cacheExpiry(Duration.ofMinutes(10))
107+
.cacheMaxSize(1000)
108+
.build();
109+
```
110+
111+
## Running Integration Tests
112+
113+
Integration tests require real GCP credentials and pre-created test secrets.
114+
115+
1. Configure ADC: `gcloud auth application-default login`
116+
2. Create test secrets in your project (see `GcpSecretManagerProviderIntegrationTest` for the required secret names and values)
117+
3. Run:
118+
119+
```bash
120+
GCP_PROJECT_ID=my-project mvn verify -pl providers/gcp -Dgroups=integration
121+
```

providers/gcp/pom.xml

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<project
3+
xmlns="http://maven.apache.org/POM/4.0.0"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
6+
https://maven.apache.org/xsd/maven-4.0.0.xsd"
7+
>
8+
<modelVersion>4.0.0</modelVersion>
9+
<parent>
10+
<groupId>dev.openfeature.contrib</groupId>
11+
<artifactId>parent</artifactId>
12+
<version>[1.0,2.0)</version>
13+
<relativePath>../../pom.xml</relativePath>
14+
</parent>
15+
16+
<groupId>dev.openfeature.contrib.providers</groupId>
17+
<artifactId>gcp</artifactId>
18+
<version>0.0.1</version> <!--x-release-please-version -->
19+
20+
<properties>
21+
<!-- "-" is not allowed in automatic module names -->
22+
<module-name>${groupId}.gcp</module-name>
23+
</properties>
24+
25+
<name>gcp</name>
26+
<description>GCP provider for OpenFeature Java SDK</description>
27+
<url>https://openfeature.dev</url>
28+
29+
<developers>
30+
<developer>
31+
<id>openfeaturebot</id>
32+
<name>OpenFeature Bot</name>
33+
<organization>OpenFeature</organization>
34+
<url>https://openfeature.dev/</url>
35+
</developer>
36+
</developers>
37+
38+
<dependencies>
39+
<!-- GCP Secret Manager client -->
40+
<dependency>
41+
<groupId>com.google.cloud</groupId>
42+
<artifactId>google-cloud-secretmanager</artifactId>
43+
<version>2.57.0</version>
44+
</dependency>
45+
46+
<!-- JSON parsing for structured flag values -->
47+
<dependency>
48+
<groupId>com.fasterxml.jackson.core</groupId>
49+
<artifactId>jackson-databind</artifactId>
50+
<version>2.21.1</version>
51+
</dependency>
52+
53+
<dependency>
54+
<groupId>org.slf4j</groupId>
55+
<artifactId>slf4j-api</artifactId>
56+
<version>2.0.17</version>
57+
</dependency>
58+
59+
<!-- test-only logging implementation -->
60+
<dependency>
61+
<groupId>org.apache.logging.log4j</groupId>
62+
<artifactId>log4j-slf4j2-impl</artifactId>
63+
<version>2.25.0</version>
64+
<scope>test</scope>
65+
</dependency>
66+
67+
<!-- concurrency testing -->
68+
<dependency>
69+
<groupId>com.vmlens</groupId>
70+
<artifactId>api</artifactId>
71+
<version>1.2.27</version>
72+
<scope>test</scope>
73+
</dependency>
74+
</dependencies>
75+
76+
<build>
77+
<plugins>
78+
<plugin>
79+
<groupId>org.apache.maven.plugins</groupId>
80+
<artifactId>maven-surefire-plugin</artifactId>
81+
<configuration>
82+
<excludedGroups>integration</excludedGroups>
83+
</configuration>
84+
</plugin>
85+
</plugins>
86+
</build>
87+
88+
<profiles>
89+
<profile>
90+
<id>concurrency-tests</id>
91+
<build>
92+
<plugins>
93+
<plugin>
94+
<groupId>com.vmlens</groupId>
95+
<artifactId>vmlens-maven-plugin</artifactId>
96+
<version>1.2.27</version>
97+
<executions>
98+
<execution>
99+
<id>test</id>
100+
<goals>
101+
<goal>test</goal>
102+
</goals>
103+
<configuration>
104+
<includes>
105+
<include>**/*CTest.java</include>
106+
</includes>
107+
<failIfNoTests>true</failIfNoTests>
108+
</configuration>
109+
</execution>
110+
</executions>
111+
</plugin>
112+
</plugins>
113+
</build>
114+
</profile>
115+
</profiles>
116+
</project>
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package dev.openfeature.contrib.providers.gcp;
2+
3+
import java.time.Clock;
4+
import java.time.Duration;
5+
import java.time.Instant;
6+
import java.util.Collections;
7+
import java.util.LinkedHashMap;
8+
import java.util.Map;
9+
import java.util.Optional;
10+
11+
/**
12+
* Thread-safe TTL-based in-memory cache for flag values fetched from GCP services.
13+
*
14+
* <p>Entries expire after the configured {@code ttl}. When the cache reaches {@code maxSize},
15+
* the entry with the earliest insertion time is evicted in O(1) via {@link LinkedHashMap}'s
16+
* insertion-order iteration and {@code removeEldestEntry}.
17+
*/
18+
class FlagCache {
19+
20+
private final Map<String, CacheEntry> store;
21+
private final Duration ttl;
22+
private final Clock clock;
23+
24+
FlagCache(Duration ttl, int maxSize) {
25+
this(ttl, maxSize, Clock.systemUTC());
26+
}
27+
28+
FlagCache(Duration ttl, int maxSize, Clock clock) {
29+
this.ttl = ttl;
30+
this.clock = clock;
31+
this.store = Collections.synchronizedMap(new LinkedHashMap<String, CacheEntry>(16, 0.75f, false) {
32+
@Override
33+
protected boolean removeEldestEntry(Map.Entry<String, CacheEntry> eldest) {
34+
return size() > maxSize;
35+
}
36+
});
37+
}
38+
39+
/**
40+
* Returns the cached value for {@code key} if present and not expired.
41+
*
42+
* @param key the cache key
43+
* @return an {@link Optional} containing the cached string, or empty if absent/expired
44+
*/
45+
Optional<String> get(String key) {
46+
synchronized (store) {
47+
CacheEntry entry = store.get(key);
48+
if (entry == null) {
49+
return Optional.empty();
50+
}
51+
if (entry.isExpired()) {
52+
store.remove(key);
53+
return Optional.empty();
54+
}
55+
return Optional.of(entry.value);
56+
}
57+
}
58+
59+
/**
60+
* Stores {@code value} under {@code key}. Eviction of the oldest entry (when the cache is
61+
* full) is handled automatically by the underlying {@link LinkedHashMap}.
62+
*
63+
* @param key the cache key
64+
* @param value the value to cache
65+
*/
66+
void put(String key, String value) {
67+
store.put(key, new CacheEntry(value, ttl, clock));
68+
}
69+
70+
/**
71+
* Removes the entry for {@code key}, forcing re-fetch on next access.
72+
*
73+
* @param key the cache key to invalidate
74+
*/
75+
void invalidate(String key) {
76+
store.remove(key);
77+
}
78+
79+
/** Removes all entries from the cache. */
80+
void clear() {
81+
store.clear();
82+
}
83+
84+
private static final class CacheEntry {
85+
86+
final String value;
87+
final Instant expiresAt;
88+
final Clock clock;
89+
90+
CacheEntry(String value, Duration ttl, Clock clock) {
91+
this.value = value;
92+
this.clock = clock;
93+
this.expiresAt = Instant.now(clock).plus(ttl);
94+
}
95+
96+
boolean isExpired() {
97+
return Instant.now(clock).isAfter(expiresAt);
98+
}
99+
}
100+
}

0 commit comments

Comments
 (0)