Skip to content

Commit f3df150

Browse files
mahpatilgemini-code-assist[bot]toddbaert
authored
feat(gcp): add parameter manager support (#1808)
Signed-off-by: Mahesh Patil <maheshfinity@gmail.com> Signed-off-by: Mahesh Patil <17205424+mahpatil@users.noreply.github.com> Signed-off-by: Todd Baert <todd.baert@dynatrace.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Todd Baert <todd.baert@dynatrace.com>
1 parent 242dcbd commit f3df150

18 files changed

Lines changed: 967 additions & 444 deletions

providers/gcp/README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# GCP Provider
22

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.
3+
An OpenFeature provider that reads feature flags from Google Cloud. Currently supports the following
4+
1. [Google Cloud Secret Manager](https://cloud.google.com/secret-manager), purpose-built for secrets requiring versioning, rotation, and fine-grained IAM access control.
5+
2. [Google Cloud Parameter Manager](https://cloud.google.com/secret-manager/parameter-manager/docs/overview), the GCP-native equivalent of AWS SSM Parameter Store.
46

57
## Installation
68

@@ -16,6 +18,7 @@ An OpenFeature provider that reads feature flags from Google Cloud. Currently su
1618

1719
## Quick Start
1820

21+
### GCP Secret Manager
1922
```java
2023
import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProvider;
2124
import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProviderOptions;
@@ -32,9 +35,26 @@ boolean darkMode = OpenFeatureAPI.getInstance().getClient()
3235
.getBooleanValue("enable-dark-mode", false);
3336
```
3437

38+
### GCP Parameter Manager
39+
```java
40+
import dev.openfeature.contrib.providers.gcp.GcpParameterManagerProvider;
41+
import dev.openfeature.contrib.providers.gcp.GcpProviderOptions;
42+
import dev.openfeature.sdk.OpenFeatureAPI;
43+
44+
GcpProviderOptions options = GcpProviderOptions.builder()
45+
.projectId("my-gcp-project")
46+
.build();
47+
48+
OpenFeatureAPI.getInstance().setProvider(new GcpParameterManagerProvider(options));
49+
50+
// Evaluate a boolean flag stored as parameter "enable-dark-mode" with value "true"
51+
boolean darkMode = OpenFeatureAPI.getInstance().getClient()
52+
.getBooleanValue("enable-dark-mode", false);
53+
```
54+
3555
## How It Works
3656

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.
57+
Each feature flag is stored as an individual **secret** in GCP Secret Manager or **parameter** in GCP Parameter Manager. The flag key maps directly to the secret name (with an optional prefix). The `latest` version is accessed by default.
3858

3959
Supported raw value formats:
4060

@@ -78,7 +98,7 @@ GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builde
7898

7999
## Advanced Usage
80100

81-
### Pinning to a specific secret version
101+
### Pinning to a specific version
82102

83103
```java
84104
GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder()

providers/gcp/pom.xml

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,40 @@
3535
</developer>
3636
</developers>
3737

38+
<dependencyManagement>
39+
<dependencies>
40+
<dependency>
41+
<groupId>com.google.cloud</groupId>
42+
<artifactId>libraries-bom</artifactId>
43+
<version>26.83.0</version>
44+
<type>pom</type>
45+
<scope>import</scope>
46+
</dependency>
47+
<dependency>
48+
<groupId>com.fasterxml.jackson</groupId>
49+
<artifactId>jackson-bom</artifactId>
50+
<version>2.21.1</version>
51+
<type>pom</type>
52+
<scope>import</scope>
53+
</dependency>
54+
</dependencies>
55+
</dependencyManagement>
56+
3857
<dependencies>
3958
<!-- GCP Secret Manager client -->
4059
<dependency>
4160
<groupId>com.google.cloud</groupId>
4261
<artifactId>google-cloud-secretmanager</artifactId>
43-
<version>2.57.0</version>
62+
</dependency>
63+
<dependency>
64+
<groupId>com.google.cloud</groupId>
65+
<artifactId>google-cloud-parametermanager</artifactId>
4466
</dependency>
4567

4668
<!-- JSON parsing for structured flag values -->
4769
<dependency>
4870
<groupId>com.fasterxml.jackson.core</groupId>
4971
<artifactId>jackson-databind</artifactId>
50-
<version>2.21.1</version>
5172
</dependency>
5273

5374
<dependency>
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package dev.openfeature.contrib.providers.gcp;
2+
3+
import dev.openfeature.sdk.EvaluationContext;
4+
import dev.openfeature.sdk.FeatureProvider;
5+
import dev.openfeature.sdk.Metadata;
6+
import dev.openfeature.sdk.ProviderEvaluation;
7+
import dev.openfeature.sdk.Reason;
8+
import dev.openfeature.sdk.Value;
9+
import dev.openfeature.sdk.exceptions.GeneralError;
10+
import java.util.Optional;
11+
import java.util.concurrent.atomic.AtomicBoolean;
12+
import lombok.extern.slf4j.Slf4j;
13+
14+
@Slf4j
15+
abstract class AbstractGcpProvider<C> implements FeatureProvider {
16+
17+
protected final GcpProviderOptions options;
18+
protected C client;
19+
protected final FlagCache cache;
20+
private AtomicBoolean isInitialized = new AtomicBoolean(false);
21+
22+
AbstractGcpProvider(GcpProviderOptions options) {
23+
this.options = options;
24+
cache = new FlagCache(options.getCacheExpiry(), options.getCacheMaxSize());
25+
}
26+
27+
AbstractGcpProvider(GcpProviderOptions options, C client) {
28+
this(options);
29+
this.client = client;
30+
}
31+
32+
@Override
33+
public Metadata getMetadata() {
34+
return () -> getProviderName();
35+
}
36+
37+
@Override
38+
public void initialize(EvaluationContext evaluationContext) throws Exception {
39+
boolean initialized = isInitialized.getAndSet(true);
40+
if (initialized) {
41+
throw new GeneralError("already initialized");
42+
}
43+
options.validate();
44+
if (client == null) {
45+
createClient();
46+
}
47+
log.info("{} initialized for project '{}'", getProviderName(), options.getProjectId());
48+
}
49+
50+
@Override
51+
public void shutdown() {
52+
if (client != null) {
53+
try {
54+
closeClient();
55+
} catch (Exception e) {
56+
log.warn("Error closing client for {}", getProviderName(), e);
57+
}
58+
client = null;
59+
}
60+
log.info("{} shut down", getProviderName());
61+
}
62+
63+
@Override
64+
public final ProviderEvaluation<Boolean> getBooleanEvaluation(
65+
String key, Boolean defaultValue, EvaluationContext ctx) {
66+
return evaluate(key, Boolean.class);
67+
}
68+
69+
@Override
70+
public final ProviderEvaluation<String> getStringEvaluation(
71+
String key, String defaultValue, EvaluationContext ctx) {
72+
return evaluate(key, String.class);
73+
}
74+
75+
@Override
76+
public final ProviderEvaluation<Integer> getIntegerEvaluation(
77+
String key, Integer defaultValue, EvaluationContext ctx) {
78+
return evaluate(key, Integer.class);
79+
}
80+
81+
@Override
82+
public final ProviderEvaluation<Double> getDoubleEvaluation(
83+
String key, Double defaultValue, EvaluationContext ctx) {
84+
return evaluate(key, Double.class);
85+
}
86+
87+
@Override
88+
public final ProviderEvaluation<Value> getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) {
89+
return evaluate(key, Value.class);
90+
}
91+
92+
// -------------------------------------------------------------------------
93+
// Internal helpers
94+
// -------------------------------------------------------------------------
95+
96+
protected <T> ProviderEvaluation<T> evaluate(String key, Class<T> targetType) {
97+
String rawValue = fetchWithCache(key);
98+
T value = FlagValueConverter.convert(rawValue, targetType);
99+
return ProviderEvaluation.<T>builder()
100+
.value(value)
101+
.reason(Reason.STATIC.toString())
102+
.build();
103+
}
104+
105+
protected String fetchWithCache(String key) {
106+
String name = buildName(key);
107+
Optional<String> cached = cache.get(name);
108+
if (cached.isPresent()) {
109+
log.debug("Fetching from cache name '{}'", key);
110+
return cached.get();
111+
}
112+
synchronized (cache) {
113+
return cache.get(name).orElseGet(() -> {
114+
String value = fetchFromGcp(name);
115+
cache.put(name, value);
116+
return value;
117+
});
118+
}
119+
}
120+
121+
protected String buildName(String flagKey) {
122+
String prefix = options.getNamePrefix();
123+
return (prefix != null && !prefix.isEmpty()) ? prefix + flagKey : flagKey;
124+
}
125+
126+
// Subclasses must implement these
127+
protected abstract String getProviderName();
128+
129+
protected abstract void createClient() throws Exception;
130+
131+
protected abstract void closeClient() throws Exception;
132+
133+
protected abstract String fetchFromGcp(String name);
134+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package dev.openfeature.contrib.providers.gcp;
2+
3+
import com.google.api.gax.rpc.NotFoundException;
4+
import com.google.cloud.parametermanager.v1.ParameterManagerClient;
5+
import com.google.cloud.parametermanager.v1.ParameterVersionName;
6+
import com.google.cloud.parametermanager.v1.RenderParameterVersionResponse;
7+
import dev.openfeature.sdk.EvaluationContext;
8+
import dev.openfeature.sdk.FeatureProvider;
9+
import dev.openfeature.sdk.Value;
10+
import dev.openfeature.sdk.exceptions.FlagNotFoundError;
11+
import dev.openfeature.sdk.exceptions.GeneralError;
12+
import lombok.extern.slf4j.Slf4j;
13+
14+
/**
15+
* OpenFeature {@link FeatureProvider} backed by Google Cloud Parameter Manager.
16+
*
17+
* <p>Each feature flag is stored as an individual parameter in GCP Parameter Manager. The flag
18+
* key maps directly to the parameter name (with an optional prefix configured via
19+
* {@code GcpProviderOptions#getNamePrefix()}).
20+
*
21+
* <p>Flag values are read as strings and parsed to the requested type. Supported raw value
22+
* formats:
23+
* <ul>
24+
* <li>Boolean: {@code "true"} / {@code "false"} (case-insensitive)</li>
25+
* <li>Integer: numeric string, e.g. {@code "42"}</li>
26+
* <li>Double: numeric string, e.g. {@code "3.14"}</li>
27+
* <li>String: any string value</li>
28+
* <li>Object: JSON string that is parsed into an OpenFeature {@link Value}</li>
29+
* </ul>
30+
*
31+
* <p>Results are cached in-process for the duration configured in
32+
* {@code GcpProviderOptions#getCacheExpiry()}.
33+
*
34+
* <p>Example:
35+
* <pre>{@code
36+
* GcpProviderOptions opts = GcpProviderOptions.builder()
37+
* .projectId("my-gcp-project")
38+
* .locationId("global") // optional, defaults to "global"
39+
* .build();
40+
* OpenFeatureAPI.getInstance().setProvider(new GcpParameterManagerProvider(opts));
41+
* }</pre>
42+
*/
43+
@Slf4j
44+
public class GcpParameterManagerProvider extends AbstractGcpProvider<ParameterManagerClient> {
45+
46+
static final String PROVIDER_NAME = "GCP Parameter Manager Provider";
47+
48+
/**
49+
* Creates a new provider using the given options. The GCP client is created lazily
50+
* during {@link #initialize(EvaluationContext)}.
51+
*
52+
* @param options provider configuration; must not be null
53+
*/
54+
public GcpParameterManagerProvider(GcpProviderOptions options) {
55+
super(options);
56+
}
57+
58+
/**
59+
* Package-private constructor allowing injection of a pre-built client for testing.
60+
*/
61+
GcpParameterManagerProvider(GcpProviderOptions options, ParameterManagerClient client) {
62+
super(options, client);
63+
}
64+
65+
@Override
66+
protected String getProviderName() {
67+
return PROVIDER_NAME;
68+
}
69+
70+
@Override
71+
protected void createClient() throws Exception {
72+
this.client = ParameterManagerClientFactory.create(options);
73+
}
74+
75+
@Override
76+
protected void closeClient() throws Exception {
77+
this.client.close();
78+
}
79+
80+
@Override
81+
public void initialize(EvaluationContext evaluationContext) throws Exception {
82+
super.initialize(evaluationContext);
83+
log.info("{} initialized via initialize()", getProviderName());
84+
}
85+
86+
@Override
87+
public void shutdown() {
88+
super.shutdown();
89+
log.info("{} shutdown via shutdown()", getProviderName());
90+
}
91+
92+
@Override
93+
protected String fetchFromGcp(String parameterName) {
94+
try {
95+
ParameterVersionName versionName = ParameterVersionName.of(
96+
options.getProjectId(), options.getLocationId(), parameterName, options.getVersion());
97+
RenderParameterVersionResponse response = client.renderParameterVersion(versionName);
98+
return response.getRenderedPayload().toStringUtf8();
99+
} catch (NotFoundException e) {
100+
throw new FlagNotFoundError("Parameter not found: " + parameterName);
101+
} catch (Exception e) {
102+
throw new GeneralError("Error fetching parameter '" + parameterName + "': " + e.getMessage(), e);
103+
}
104+
}
105+
}

providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpProviderOptions.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
*
1111
* <p>Example usage:
1212
* <pre>{@code
13-
* GcpSecretManagerProviderOptions opts = GcpSecretManagerProviderOptions.builder()
13+
* GcpProviderOptions opts = GcpProviderOptions.builder()
1414
* .projectId("my-gcp-project")
15-
* .secretVersion("latest")
15+
* .version("latest")
1616
* .cacheExpiry(Duration.ofMinutes(2))
1717
* .build();
1818
* }</pre>
@@ -42,6 +42,12 @@ public class GcpProviderOptions {
4242
@Builder.Default
4343
private final String version = "latest";
4444

45+
/**
46+
* Optional location required for ParameterManager, ignored by SecretManager.
47+
*/
48+
@Builder.Default
49+
private final String locationId = "global";
50+
4551
/**
4652
* How long a fetched secret value is retained in the in-memory cache before
4753
* the next evaluation triggers a fresh GCP API call.
@@ -77,10 +83,10 @@ public class GcpProviderOptions {
7783
*/
7884
public void validate() {
7985
if (projectId == null || projectId.trim().isEmpty()) {
80-
throw new IllegalArgumentException("GcpSecretManagerProviderOptions: projectId must not be blank");
86+
throw new IllegalArgumentException("GcpProviderOptions: projectId must not be blank");
8187
}
8288
if (version == null || version.trim().isEmpty()) {
83-
throw new IllegalArgumentException("GcpSecretManagerProviderOptions: version must not be blank");
89+
throw new IllegalArgumentException("GcpProviderOptions: version must not be blank");
8490
}
8591
}
8692
}

0 commit comments

Comments
 (0)