From 1c0fd8d9d4e52823bd2c9907d9599b6bfe3c4b42 Mon Sep 17 00:00:00 2001 From: Mahesh Patil Date: Thu, 9 Apr 2026 06:17:43 +0100 Subject: [PATCH] feat: add GCP Secret Manager OpenFeature provider Adds a new OpenFeature provider for GCP Secret Manager that enables reading feature flags from GCP Secret Manager secrets. Includes the provider implementation with flag caching, value conversion, unit tests, integration tests, and a sample application demonstrating usage. --- .release-please-manifest.json | 1 + pom.xml | 1 + providers/gcp-secret-manager/CHANGELOG.md | 1 + providers/gcp-secret-manager/README.md | 121 ++++++++ providers/gcp-secret-manager/pom.xml | 77 ++++++ .../spotbugs-exclusions.xml | 39 +++ .../providers/gcpsecretmanager/FlagCache.java | 89 ++++++ .../gcpsecretmanager/FlagValueConverter.java | 162 +++++++++++ .../GcpSecretManagerProvider.java | 176 ++++++++++++ .../GcpSecretManagerProviderOptions.java | 83 ++++++ .../SecretManagerClientFactory.java | 34 +++ .../gcpsecretmanager/FlagCacheTest.java | 80 ++++++ .../FlagValueConverterTest.java | 134 +++++++++ ...pSecretManagerProviderIntegrationTest.java | 106 +++++++ .../GcpSecretManagerProviderTest.java | 259 ++++++++++++++++++ .../src/test/resources/fixtures/bool-flag.txt | 1 + .../test/resources/fixtures/double-flag.txt | 1 + .../src/test/resources/fixtures/int-flag.txt | 1 + .../test/resources/fixtures/object-flag.json | 1 + .../test/resources/fixtures/string-flag.txt | 1 + .../src/test/resources/log4j2-test.xml | 13 + providers/gcp-secret-manager/version.txt | 1 + release-please-config.json | 11 + samples/gcp-secret-manager-sample/README.md | 154 +++++++++++ samples/gcp-secret-manager-sample/pom.xml | 69 +++++ samples/gcp-secret-manager-sample/setup.sh | 70 +++++ .../SecretManagerSampleApp.java | 130 +++++++++ samples/gcp-secret-manager-sample/teardown.sh | 26 ++ 28 files changed, 1842 insertions(+) create mode 100644 providers/gcp-secret-manager/CHANGELOG.md create mode 100644 providers/gcp-secret-manager/README.md create mode 100644 providers/gcp-secret-manager/pom.xml create mode 100644 providers/gcp-secret-manager/spotbugs-exclusions.xml create mode 100644 providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCache.java create mode 100644 providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverter.java create mode 100644 providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProvider.java create mode 100644 providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderOptions.java create mode 100644 providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/SecretManagerClientFactory.java create mode 100644 providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java create mode 100644 providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java create mode 100644 providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java create mode 100644 providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java create mode 100644 providers/gcp-secret-manager/src/test/resources/fixtures/bool-flag.txt create mode 100644 providers/gcp-secret-manager/src/test/resources/fixtures/double-flag.txt create mode 100644 providers/gcp-secret-manager/src/test/resources/fixtures/int-flag.txt create mode 100644 providers/gcp-secret-manager/src/test/resources/fixtures/object-flag.json create mode 100644 providers/gcp-secret-manager/src/test/resources/fixtures/string-flag.txt create mode 100644 providers/gcp-secret-manager/src/test/resources/log4j2-test.xml create mode 100644 providers/gcp-secret-manager/version.txt create mode 100644 samples/gcp-secret-manager-sample/README.md create mode 100644 samples/gcp-secret-manager-sample/pom.xml create mode 100644 samples/gcp-secret-manager-sample/setup.sh create mode 100644 samples/gcp-secret-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpsecretmanager/SecretManagerSampleApp.java create mode 100644 samples/gcp-secret-manager-sample/teardown.sh diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b96f60247..2972d7d98 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -11,6 +11,7 @@ "providers/statsig": "0.2.1", "providers/multiprovider": "0.0.3", "providers/ofrep": "0.0.1", + "providers/gcp-secret-manager": "0.0.1", "tools/junit-openfeature": "0.2.1", "tools/flagd-http-connector": "0.0.4", "tools/flagd-api": "1.0.0", diff --git a/pom.xml b/pom.xml index f497d1a1e..3ffb35d08 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,7 @@ providers/optimizely providers/multiprovider providers/ofrep + providers/gcp-secret-manager tools/flagd-http-connector diff --git a/providers/gcp-secret-manager/CHANGELOG.md b/providers/gcp-secret-manager/CHANGELOG.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/providers/gcp-secret-manager/CHANGELOG.md @@ -0,0 +1 @@ + diff --git a/providers/gcp-secret-manager/README.md b/providers/gcp-secret-manager/README.md new file mode 100644 index 000000000..00e1a82a0 --- /dev/null +++ b/providers/gcp-secret-manager/README.md @@ -0,0 +1,121 @@ +# GCP Secret Manager Provider + +An OpenFeature provider that reads feature flags from [Google Cloud Secret Manager](https://cloud.google.com/secret-manager), purpose-built for secrets requiring versioning, rotation, and fine-grained IAM access control. + +## Installation + + +```xml + + dev.openfeature.contrib.providers + gcp-secret-manager + 0.0.1 + +``` + + +## Quick Start + +```java +import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProvider; +import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProviderOptions; +import dev.openfeature.sdk.OpenFeatureAPI; + +GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() + .projectId("my-gcp-project") + .build(); + +OpenFeatureAPI.getInstance().setProvider(new GcpSecretManagerProvider(options)); + +// Evaluate a boolean flag stored as secret "enable-dark-mode" with value "true" +boolean darkMode = OpenFeatureAPI.getInstance().getClient() + .getBooleanValue("enable-dark-mode", false); +``` + +## How It Works + +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. + +Supported raw value formats: + +| Flag type | Secret value example | +|-----------|---------------------| +| Boolean | `true` or `false` | +| Integer | `42` | +| Double | `3.14` | +| String | `dark-mode` | +| Object | `{"color":"blue","level":3}` | + +## Authentication + +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. + +To use explicit credentials: + +```java +import com.google.auth.oauth2.ServiceAccountCredentials; +import java.io.FileInputStream; + +GoogleCredentials credentials = ServiceAccountCredentials.fromStream( + new FileInputStream("/path/to/service-account-key.json")); + +GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() + .projectId("my-gcp-project") + .credentials(credentials) + .build(); +``` + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `projectId` | `String` | *(required)* | GCP project ID that owns the secrets | +| `credentials` | `GoogleCredentials` | `null` (ADC) | Explicit credentials; falls back to Application Default Credentials when null | +| `secretVersion` | `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 | +| `cacheExpiry` | `Duration` | `5 minutes` | How long fetched secret values are cached before re-fetching from GCP | +| `cacheMaxSize` | `int` | `500` | Maximum number of secret values held in the in-memory cache | +| `secretNamePrefix` | `String` | `null` | Optional prefix prepended to every flag key. E.g. prefix `"ff-"` maps flag `"my-flag"` to secret `"ff-my-flag"` | + +## Advanced Usage + +### Pinning to a specific secret version + +```java +GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() + .projectId("my-gcp-project") + .secretVersion("5") // always use version 5 + .build(); +``` + +### Secret name prefix + +```java +GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() + .projectId("my-gcp-project") + .secretNamePrefix("feature-flags/") + .build(); +``` + +### Tuning cache for high-throughput scenarios + +Secret Manager has API quotas (10,000 access operations per minute per project). Use a longer `cacheExpiry` to stay within quota. + +```java +GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() + .projectId("my-gcp-project") + .cacheExpiry(Duration.ofMinutes(10)) + .cacheMaxSize(1000) + .build(); +``` + +## Running Integration Tests + +Integration tests require real GCP credentials and pre-created test secrets. + +1. Configure ADC: `gcloud auth application-default login` +2. Create test secrets in your project (see `GcpSecretManagerProviderIntegrationTest` for the required secret names and values) +3. Run: + +```bash +GCP_PROJECT_ID=my-project mvn verify -pl providers/gcp-secret-manager -Dgroups=integration +``` diff --git a/providers/gcp-secret-manager/pom.xml b/providers/gcp-secret-manager/pom.xml new file mode 100644 index 000000000..ecca7d76c --- /dev/null +++ b/providers/gcp-secret-manager/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + [1.0,2.0) + ../../pom.xml + + + dev.openfeature.contrib.providers + gcp-secret-manager + 0.0.1 + + + + ${groupId}.gcpsecretmanager + + + gcp-secret-manager + GCP Secret Manager provider for OpenFeature Java SDK + https://openfeature.dev + + + + openfeaturebot + OpenFeature Bot + OpenFeature + https://openfeature.dev/ + + + + + + + com.google.cloud + google-cloud-secretmanager + 2.57.0 + + + + + com.fasterxml.jackson.core + jackson-databind + 2.21.1 + + + + org.slf4j + slf4j-api + 2.0.17 + + + + + org.apache.logging.log4j + log4j-slf4j2-impl + 2.25.0 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + integration + + + + + diff --git a/providers/gcp-secret-manager/spotbugs-exclusions.xml b/providers/gcp-secret-manager/spotbugs-exclusions.xml new file mode 100644 index 000000000..7f1d7ae12 --- /dev/null +++ b/providers/gcp-secret-manager/spotbugs-exclusions.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCache.java b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCache.java new file mode 100644 index 000000000..ff19edd0f --- /dev/null +++ b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCache.java @@ -0,0 +1,89 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Thread-safe TTL-based in-memory cache for flag values fetched from GCP Secret Manager. + * + *

Entries expire after the configured {@code ttl}. When the cache reaches {@code maxSize}, + * the entry with the earliest insertion time is evicted in O(1) via {@link LinkedHashMap}'s + * insertion-order iteration and {@code removeEldestEntry}. + */ +class FlagCache { + + private final Map store; + private final Duration ttl; + + FlagCache(Duration ttl, int maxSize) { + this.ttl = ttl; + this.store = Collections.synchronizedMap( + new LinkedHashMap(16, 0.75f, false) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxSize; + } + }); + } + + /** + * Returns the cached value for {@code key} if present and not expired. + * + * @param key the cache key + * @return an {@link Optional} containing the cached string, or empty if absent/expired + */ + Optional get(String key) { + CacheEntry entry = store.get(key); + if (entry == null) { + return Optional.empty(); + } + if (entry.isExpired()) { + store.remove(key); + return Optional.empty(); + } + return Optional.of(entry.value); + } + + /** + * Stores {@code value} under {@code key}. Eviction of the oldest entry (when the cache is + * full) is handled automatically by the underlying {@link LinkedHashMap}. + * + * @param key the cache key + * @param value the value to cache + */ + void put(String key, String value) { + store.put(key, new CacheEntry(value, ttl)); + } + + /** + * Removes the entry for {@code key}, forcing re-fetch on next access. + * + * @param key the cache key to invalidate + */ + void invalidate(String key) { + store.remove(key); + } + + /** Removes all entries from the cache. */ + void clear() { + store.clear(); + } + + private static final class CacheEntry { + final String value; + final Instant expiresAt; + + CacheEntry(String value, Duration ttl) { + this.value = value; + this.expiresAt = Instant.now().plus(ttl); + } + + boolean isExpired() { + return Instant.now().isAfter(expiresAt); + } + } +} diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverter.java b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverter.java new file mode 100644 index 000000000..4705f0c97 --- /dev/null +++ b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverter.java @@ -0,0 +1,162 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.ParseError; +import dev.openfeature.sdk.exceptions.TypeMismatchError; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; + +/** + * Converts raw string secret values (fetched from GCP Secret Manager) into the + * typed values expected by the OpenFeature SDK evaluation methods. + */ +@Slf4j +final class FlagValueConverter { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private FlagValueConverter() {} + + /** + * Converts a raw string value to the given target type. + * + *

Supported conversions: + *

+ * + * @param raw the raw string value from GCP Secret Manager + * @param targetType the desired OpenFeature type + * @param the target type + * @return the converted value + * @throws ParseError when the string cannot be parsed into the expected type + * @throws TypeMismatchError when the runtime type of the parsed value does not match + */ + @SuppressWarnings("unchecked") + static T convert(String raw, Class targetType) { + if (raw == null) { + throw new ParseError("Flag value is null"); + } + + if (targetType == Boolean.class) { + return (T) convertBoolean(raw); + } + if (targetType == Integer.class) { + return (T) convertInteger(raw); + } + if (targetType == Double.class) { + return (T) convertDouble(raw); + } + if (targetType == String.class) { + return (T) raw; + } + if (targetType == Value.class) { + return (T) convertValue(raw); + } + + throw new TypeMismatchError("Unsupported target type: " + targetType.getName()); + } + + private static Boolean convertBoolean(String raw) { + String trimmed = raw.trim(); + if ("true".equalsIgnoreCase(trimmed)) { + return Boolean.TRUE; + } + if ("false".equalsIgnoreCase(trimmed)) { + return Boolean.FALSE; + } + throw new ParseError("Cannot convert '" + raw + "' to Boolean. Expected \"true\" or \"false\"."); + } + + private static Integer convertInteger(String raw) { + try { + return Integer.valueOf(raw.trim()); + } catch (NumberFormatException e) { + throw new ParseError("Cannot convert '" + raw + "' to Integer: " + e.getMessage(), e); + } + } + + private static Double convertDouble(String raw) { + try { + return Double.valueOf(raw.trim()); + } catch (NumberFormatException e) { + throw new ParseError("Cannot convert '" + raw + "' to Double: " + e.getMessage(), e); + } + } + + private static Value convertValue(String raw) { + String trimmed = raw.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + JsonNode node = OBJECT_MAPPER.readTree(trimmed); + return jsonNodeToValue(node); + } catch (JsonProcessingException e) { + log.debug("Value '{}' is not valid JSON, wrapping as plain string", raw); + } + } + return new Value(raw); + } + + /** + * Recursively converts a Jackson {@link JsonNode} to an OpenFeature {@link Value}. + */ + static Value jsonNodeToValue(JsonNode node) { + if (node == null || node.isNull()) { + return new Value(); + } + if (node.isBoolean()) { + return new Value(node.booleanValue()); + } + if (node.isInt()) { + return new Value(node.intValue()); + } + if (node.isDouble() || node.isFloat()) { + return new Value(node.doubleValue()); + } + if (node.isNumber()) { + return new Value(node.doubleValue()); + } + if (node.isTextual()) { + return new Value(node.textValue()); + } + if (node.isObject()) { + return new Value(objectNodeToStructure((ObjectNode) node)); + } + if (node.isArray()) { + return new Value(arrayNodeToList((ArrayNode) node)); + } + return new Value(node.toString()); + } + + private static MutableStructure objectNodeToStructure(ObjectNode objectNode) { + MutableStructure structure = new MutableStructure(); + Iterator> fields = objectNode.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + structure.add(field.getKey(), jsonNodeToValue(field.getValue())); + } + return structure; + } + + private static List arrayNodeToList(ArrayNode arrayNode) { + List list = new ArrayList<>(arrayNode.size()); + for (JsonNode element : arrayNode) { + list.add(jsonNodeToValue(element)); + } + return list; + } +} diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProvider.java b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProvider.java new file mode 100644 index 000000000..0c24eb547 --- /dev/null +++ b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProvider.java @@ -0,0 +1,176 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import com.google.api.gax.rpc.NotFoundException; +import com.google.cloud.secretmanager.v1.AccessSecretVersionResponse; +import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; +import com.google.cloud.secretmanager.v1.SecretVersionName; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import dev.openfeature.sdk.exceptions.GeneralError; +import lombok.extern.slf4j.Slf4j; + +/** + * OpenFeature {@link FeatureProvider} backed by Google Cloud Secret Manager. + * + *

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 configured via + * {@link GcpSecretManagerProviderOptions#getSecretNamePrefix()}). + * + *

Flag values are read as UTF-8 strings from the secret payload and parsed to the requested + * type. Supported raw value formats: + *

    + *
  • Boolean: {@code "true"} / {@code "false"} (case-insensitive)
  • + *
  • Integer: numeric string, e.g. {@code "42"}
  • + *
  • Double: numeric string, e.g. {@code "3.14"}
  • + *
  • String: any string value
  • + *
  • Object: JSON string that is parsed into an OpenFeature {@link Value}
  • + *
+ * + *

Results are cached in-process for the duration configured in + * {@link GcpSecretManagerProviderOptions#getCacheExpiry()}. + * + *

Example: + *

{@code
+ * GcpSecretManagerProviderOptions opts = GcpSecretManagerProviderOptions.builder()
+ *     .projectId("my-gcp-project")
+ *     .build();
+ * OpenFeatureAPI.getInstance().setProvider(new GcpSecretManagerProvider(opts));
+ * }
+ */ +@Slf4j +public class GcpSecretManagerProvider implements FeatureProvider { + + static final String PROVIDER_NAME = "GCP Secret Manager Provider"; + + private final GcpSecretManagerProviderOptions options; + private SecretManagerServiceClient client; + private FlagCache cache; + + /** + * Creates a new provider using the given options. The GCP client is created lazily + * during {@link #initialize(EvaluationContext)}. + * + * @param options provider configuration; must not be null + */ + public GcpSecretManagerProvider(GcpSecretManagerProviderOptions options) { + this.options = options; + } + + /** + * Package-private constructor allowing injection of a pre-built client for testing. + */ + GcpSecretManagerProvider(GcpSecretManagerProviderOptions options, SecretManagerServiceClient client) { + this.options = options; + this.client = client; + } + + @Override + public Metadata getMetadata() { + return () -> PROVIDER_NAME; + } + + @Override + public void initialize(EvaluationContext evaluationContext) throws Exception { + options.validate(); + if (client == null) { + client = SecretManagerClientFactory.create(options); + } + cache = new FlagCache(options.getCacheExpiry(), options.getCacheMaxSize()); + log.info("GcpSecretManagerProvider initialized for project '{}'", options.getProjectId()); + } + + @Override + public void shutdown() { + if (client != null) { + try { + client.close(); + } catch (Exception e) { + log.warn("Error closing SecretManagerServiceClient", e); + } + client = null; + } + log.info("GcpSecretManagerProvider shut down"); + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + return evaluate(key, Boolean.class); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + return evaluate(key, String.class); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + return evaluate(key, Integer.class); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + return evaluate(key, Double.class); + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + return evaluate(key, Value.class); + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private ProviderEvaluation evaluate(String key, Class targetType) { + String rawValue = fetchWithCache(key); + T value = FlagValueConverter.convert(rawValue, targetType); + return ProviderEvaluation.builder() + .value(value) + .reason(Reason.STATIC.toString()) + .build(); + } + + private String fetchWithCache(String key) { + String secretName = buildSecretName(key); + return cache.get(secretName).orElseGet(() -> { + String value = fetchFromGcp(secretName); + cache.put(secretName, value); + return value; + }); + } + + /** + * Applies the configured prefix (if any) and returns the GCP secret name for the flag. + */ + private String buildSecretName(String flagKey) { + String prefix = options.getSecretNamePrefix(); + return (prefix != null && !prefix.isEmpty()) ? prefix + flagKey : flagKey; + } + + /** + * Accesses the configured version of the named secret from GCP Secret Manager. + * + * @param secretName the GCP secret name (without project path) + * @return the UTF-8 string value of the secret payload + * @throws FlagNotFoundError when the secret does not exist + * @throws GeneralError for any other GCP API error + */ + private String fetchFromGcp(String secretName) { + try { + SecretVersionName versionName = + SecretVersionName.of(options.getProjectId(), secretName, options.getSecretVersion()); + log.debug("Accessing secret '{}' from GCP", versionName); + AccessSecretVersionResponse response = client.accessSecretVersion(versionName); + return response.getPayload().getData().toStringUtf8(); + } catch (NotFoundException e) { + throw new FlagNotFoundError("Secret not found: " + secretName); + } catch (Exception e) { + throw new GeneralError("Error accessing secret '" + secretName + "': " + e.getMessage()); + } + } +} diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderOptions.java b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderOptions.java new file mode 100644 index 000000000..6090d65e5 --- /dev/null +++ b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderOptions.java @@ -0,0 +1,83 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import com.google.auth.oauth2.GoogleCredentials; +import java.time.Duration; +import lombok.Builder; +import lombok.Getter; + +/** + * Configuration options for {@link GcpSecretManagerProvider}. + * + *

Example usage: + *

{@code
+ * GcpSecretManagerProviderOptions opts = GcpSecretManagerProviderOptions.builder()
+ *     .projectId("my-gcp-project")
+ *     .secretVersion("latest")
+ *     .cacheExpiry(Duration.ofMinutes(2))
+ *     .build();
+ * }
+ */ +@Getter +@Builder +public class GcpSecretManagerProviderOptions { + + /** + * GCP project ID that owns the secrets. Required. + * Example: "my-gcp-project" or numeric project number "123456789". + */ + private final String projectId; + + /** + * Explicit Google credentials to use when creating the Secret Manager client. + * When {@code null} (default), Application Default Credentials (ADC) are used + * automatically by the GCP client library. + */ + private final GoogleCredentials credentials; + + /** + * The secret version to retrieve. Defaults to {@code "latest"}. + * Override with a specific version number (e.g. {@code "3"}) for pinned deployments + * where you want consistent behaviour regardless of secret rotation. + */ + @Builder.Default + private final String secretVersion = "latest"; + + /** + * How long a fetched secret value is retained in the in-memory cache before + * the next evaluation triggers a fresh GCP API call. + * + *

Secret Manager has API quotas (10,000 access operations per minute per project + * by default). Set this to at least {@code Duration.ofSeconds(30)} in + * high-throughput scenarios. + * + *

Default: 5 minutes. + */ + @Builder.Default + private final Duration cacheExpiry = Duration.ofMinutes(5); + + /** + * Maximum number of distinct secret names held in the cache at once. + * When the cache is full, the oldest entry is evicted before inserting a new one. + * Default: 500. + */ + @Builder.Default + private final int cacheMaxSize = 500; + + /** + * Optional prefix prepended to every flag key before constructing the GCP + * secret name. For example, setting {@code secretNamePrefix = "ff-"} maps + * flag key {@code "my-flag"} to secret name {@code "ff-my-flag"}. + */ + private final String secretNamePrefix; + + /** + * Validates that required options are present and well-formed. + * + * @throws IllegalArgumentException when {@code projectId} is null or blank + */ + public void validate() { + if (projectId == null || projectId.trim().isEmpty()) { + throw new IllegalArgumentException("GcpSecretManagerProviderOptions: projectId must not be blank"); + } + } +} diff --git a/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/SecretManagerClientFactory.java b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/SecretManagerClientFactory.java new file mode 100644 index 000000000..cb535c31a --- /dev/null +++ b/providers/gcp-secret-manager/src/main/java/dev/openfeature/contrib/providers/gcpsecretmanager/SecretManagerClientFactory.java @@ -0,0 +1,34 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; +import com.google.cloud.secretmanager.v1.SecretManagerServiceSettings; +import java.io.IOException; + +/** + * Factory for creating a {@link SecretManagerServiceClient}, separated to allow injection + * of mock clients in unit tests. + */ +final class SecretManagerClientFactory { + + private SecretManagerClientFactory() {} + + /** + * Creates a new {@link SecretManagerServiceClient} using the provided options. + * + *

When {@link GcpSecretManagerProviderOptions#getCredentials()} is non-null, those + * credentials are used explicitly. Otherwise, the GCP client library falls back to + * Application Default Credentials (ADC) automatically. + * + * @param options the provider options + * @return a configured {@link SecretManagerServiceClient} + * @throws IOException if the client cannot be created + */ + static SecretManagerServiceClient create(GcpSecretManagerProviderOptions options) throws IOException { + SecretManagerServiceSettings.Builder settingsBuilder = SecretManagerServiceSettings.newBuilder(); + if (options.getCredentials() != null) { + settingsBuilder.setCredentialsProvider(FixedCredentialsProvider.create(options.getCredentials())); + } + return SecretManagerServiceClient.create(settingsBuilder.build()); + } +} diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java new file mode 100644 index 000000000..c82adb6a4 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagCacheTest.java @@ -0,0 +1,80 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("FlagCache") +class FlagCacheTest { + + private FlagCache cache; + + @BeforeEach + void setUp() { + cache = new FlagCache(Duration.ofMinutes(5), 100); + } + + @Test + @DisplayName("get() returns empty for an unknown key") + void getUnknownKeyReturnsEmpty() { + assertThat(cache.get("unknown")).isEmpty(); + } + + @Test + @DisplayName("put() then get() returns value before expiry") + void putAndGetBeforeExpiry() { + cache.put("my-flag", "true"); + Optional result = cache.get("my-flag"); + assertThat(result).isPresent().hasValue("true"); + } + + @Test + @DisplayName("get() returns empty after TTL expires") + void getAfterTtlExpiry() throws InterruptedException { + FlagCache shortTtlCache = new FlagCache(Duration.ofMillis(50), 100); + shortTtlCache.put("flag", "value"); + Thread.sleep(100); + assertThat(shortTtlCache.get("flag")).isEmpty(); + } + + @Test + @DisplayName("invalidate() removes the entry") + void invalidateRemovesEntry() { + cache.put("flag", "hello"); + cache.invalidate("flag"); + assertThat(cache.get("flag")).isEmpty(); + } + + @Test + @DisplayName("clear() removes all entries") + void clearRemovesAll() { + cache.put("a", "1"); + cache.put("b", "2"); + cache.clear(); + assertThat(cache.get("a")).isEmpty(); + assertThat(cache.get("b")).isEmpty(); + } + + @Test + @DisplayName("maxSize evicts the oldest entry on overflow") + void maxSizeEvictsOldest() throws InterruptedException { + FlagCache tinyCache = new FlagCache(Duration.ofMinutes(5), 2); + tinyCache.put("first", "1"); + Thread.sleep(5); + tinyCache.put("second", "2"); + Thread.sleep(5); + tinyCache.put("third", "3"); + assertThat(tinyCache.get("third")).isPresent().hasValue("3"); + int present = 0; + for (String key : new String[] {"first", "second", "third"}) { + if (tinyCache.get(key).isPresent()) { + present++; + } + } + assertThat(present).isLessThanOrEqualTo(2); + } +} diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java new file mode 100644 index 000000000..1ff73a92b --- /dev/null +++ b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/FlagValueConverterTest.java @@ -0,0 +1,134 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.ParseError; +import dev.openfeature.sdk.exceptions.TypeMismatchError; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("FlagValueConverter") +class FlagValueConverterTest { + + @Nested + @DisplayName("Boolean conversion") + class BooleanConversion { + + @ParameterizedTest + @ValueSource(strings = {"true", "True", "TRUE", "tRuE"}) + @DisplayName("converts truthy strings to true") + void trueVariants(String input) { + assertThat(FlagValueConverter.convert(input, Boolean.class)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"false", "False", "FALSE", "fAlSe"}) + @DisplayName("converts falsy strings to false") + void falseVariants(String input) { + assertThat(FlagValueConverter.convert(input, Boolean.class)).isFalse(); + } + + @Test + @DisplayName("throws ParseError for non-boolean string") + void nonBooleanThrows() { + assertThatThrownBy(() -> FlagValueConverter.convert("yes", Boolean.class)) + .isInstanceOf(ParseError.class); + } + } + + @Nested + @DisplayName("Integer conversion") + class IntegerConversion { + + @Test + @DisplayName("converts numeric string to Integer") + void numericString() { + assertThat(FlagValueConverter.convert("42", Integer.class)).isEqualTo(42); + } + + @Test + @DisplayName("throws ParseError for non-numeric string") + void nonNumericThrows() { + assertThatThrownBy(() -> FlagValueConverter.convert("abc", Integer.class)) + .isInstanceOf(ParseError.class); + } + } + + @Nested + @DisplayName("Double conversion") + class DoubleConversion { + + @Test + @DisplayName("converts numeric string to Double") + void numericString() { + assertThat(FlagValueConverter.convert("3.14", Double.class)).isEqualTo(3.14); + } + + @Test + @DisplayName("throws ParseError for non-numeric string") + void nonNumericThrows() { + assertThatThrownBy(() -> FlagValueConverter.convert("not-a-number", Double.class)) + .isInstanceOf(ParseError.class); + } + } + + @Nested + @DisplayName("String conversion") + class StringConversion { + + @Test + @DisplayName("returns string as-is") + void returnsAsIs() { + assertThat(FlagValueConverter.convert("dark-mode", String.class)).isEqualTo("dark-mode"); + } + } + + @Nested + @DisplayName("Value (object) conversion") + class ValueConversion { + + @Test + @DisplayName("parses JSON object string to Value with Structure") + void jsonObject() { + Value value = FlagValueConverter.convert("{\"color\":\"blue\",\"count\":3}", Value.class); + assertThat(value).isNotNull(); + assertThat(value.asStructure()).isNotNull(); + assertThat(value.asStructure().getValue("color").asString()).isEqualTo("blue"); + assertThat(value.asStructure().getValue("count").asInteger()).isEqualTo(3); + } + + @Test + @DisplayName("parses JSON array string to Value with List") + void jsonArray() { + Value value = FlagValueConverter.convert("[\"a\",\"b\"]", Value.class); + assertThat(value).isNotNull(); + assertThat(value.asList()).hasSize(2); + } + + @Test + @DisplayName("wraps plain string in Value") + void plainStringWrapped() { + Value value = FlagValueConverter.convert("plain", Value.class); + assertThat(value.asString()).isEqualTo("plain"); + } + } + + @Test + @DisplayName("throws TypeMismatchError for unsupported type") + void unsupportedTypeThrows() { + assertThatThrownBy(() -> FlagValueConverter.convert("foo", Object.class)) + .isInstanceOf(TypeMismatchError.class); + } + + @Test + @DisplayName("throws ParseError when raw value is null") + void nullRawThrows() { + assertThatThrownBy(() -> FlagValueConverter.convert(null, Boolean.class)) + .isInstanceOf(ParseError.class); + } +} diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java new file mode 100644 index 000000000..3d0f4f8d9 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderIntegrationTest.java @@ -0,0 +1,106 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.openfeature.sdk.ImmutableContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; + +/** + * Integration tests for {@link GcpSecretManagerProvider}. + * + *

These tests require real GCP credentials and a pre-configured project with test secrets. + * They are excluded from the default test run via {@code @Tag("integration")}. + * + *

Pre-requisites: + *

    + *
  1. Set the {@code GCP_PROJECT_ID} environment variable to your GCP project ID.
  2. + *
  3. Ensure Application Default Credentials are configured + * ({@code gcloud auth application-default login}).
  4. + *
  5. Create the following secrets in GCP Secret Manager under the project (each with a + * {@code "latest"} version): + *
      + *
    • {@code it-bool-flag} with value {@code "true"}
    • + *
    • {@code it-string-flag} with value {@code "hello"}
    • + *
    • {@code it-int-flag} with value {@code "99"}
    • + *
    • {@code it-double-flag} with value {@code "2.71"}
    • + *
    • {@code it-object-flag} with value {@code {"key":"val"}}
    • + *
    + *
  6. + *
+ * + *

To run these tests: + *

{@code
+ * GCP_PROJECT_ID=my-project mvn verify -pl providers/gcp-secret-manager -Dgroups=integration
+ * }
+ */ +@Tag("integration") +@DisabledIfEnvironmentVariable(named = "GCP_PROJECT_ID", matches = "") +@DisplayName("GcpSecretManagerProvider integration tests") +class GcpSecretManagerProviderIntegrationTest { + + private GcpSecretManagerProvider provider; + + @BeforeEach + void setUp() throws Exception { + String projectId = System.getenv("GCP_PROJECT_ID"); + GcpSecretManagerProviderOptions opts = + GcpSecretManagerProviderOptions.builder().projectId(projectId).build(); + provider = new GcpSecretManagerProvider(opts); + provider.initialize(new ImmutableContext()); + } + + @AfterEach + void tearDown() { + if (provider != null) { + provider.shutdown(); + } + } + + @Test + @DisplayName("evaluates boolean secret") + void booleanFlag() { + assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()) + .getValue()) + .isTrue(); + } + + @Test + @DisplayName("evaluates string secret") + void stringFlag() { + assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()) + .getValue()) + .isEqualTo("hello"); + } + + @Test + @DisplayName("evaluates integer secret") + void integerFlag() { + assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()) + .getValue()) + .isEqualTo(99); + } + + @Test + @DisplayName("evaluates double secret") + void doubleFlag() { + assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()) + .getValue()) + .isEqualTo(2.71); + } + + @Test + @DisplayName("evaluates object secret as Value/Structure") + void objectFlag() { + assertThat(provider.getObjectEvaluation("it-object-flag", null, new ImmutableContext()) + .getValue() + .asStructure() + .getValue("key") + .asString()) + .isEqualTo("val"); + } +} diff --git a/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java new file mode 100644 index 000000000..298e08bc0 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/java/dev/openfeature/contrib/providers/gcpsecretmanager/GcpSecretManagerProviderTest.java @@ -0,0 +1,259 @@ +package dev.openfeature.contrib.providers.gcpsecretmanager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.api.gax.rpc.NotFoundException; +import com.google.cloud.secretmanager.v1.AccessSecretVersionResponse; +import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; +import com.google.cloud.secretmanager.v1.SecretPayload; +import com.google.cloud.secretmanager.v1.SecretVersionName; +import com.google.protobuf.ByteString; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import dev.openfeature.sdk.exceptions.GeneralError; +import dev.openfeature.sdk.exceptions.ParseError; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("GcpSecretManagerProvider") +@ExtendWith(MockitoExtension.class) +class GcpSecretManagerProviderTest { + + @Mock + private SecretManagerServiceClient mockClient; + + private GcpSecretManagerProviderOptions options; + private GcpSecretManagerProvider provider; + + @BeforeEach + void setUp() throws Exception { + options = GcpSecretManagerProviderOptions.builder() + .projectId("test-project") + .build(); + provider = new GcpSecretManagerProvider(options, mockClient); + provider.initialize(new ImmutableContext()); + } + + private void stubSecret(String value) { + AccessSecretVersionResponse response = AccessSecretVersionResponse.newBuilder() + .setPayload(SecretPayload.newBuilder() + .setData(ByteString.copyFromUtf8(value)) + .build()) + .build(); + when(mockClient.accessSecretVersion(any(SecretVersionName.class))).thenReturn(response); + } + + private void stubSecretNotFound() { + when(mockClient.accessSecretVersion(any(SecretVersionName.class))).thenThrow(NotFoundException.class); + } + + private void stubSecretError(String message) { + when(mockClient.accessSecretVersion(any(SecretVersionName.class))).thenThrow(new RuntimeException(message)); + } + + @Nested + @DisplayName("Metadata") + class MetadataTests { + @Test + @DisplayName("returns the correct provider name") + void providerName() { + assertThat(provider.getMetadata().getName()).isEqualTo(GcpSecretManagerProvider.PROVIDER_NAME); + } + } + + @Nested + @DisplayName("Initialization") + class InitializationTests { + @Test + @DisplayName("throws IllegalArgumentException when projectId is blank") + void blankProjectIdThrows() { + GcpSecretManagerProviderOptions badOpts = + GcpSecretManagerProviderOptions.builder().projectId("").build(); + GcpSecretManagerProvider badProvider = new GcpSecretManagerProvider(badOpts, mockClient); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("throws IllegalArgumentException when projectId is null") + void nullProjectIdThrows() { + GcpSecretManagerProviderOptions badOpts = + GcpSecretManagerProviderOptions.builder().build(); + GcpSecretManagerProvider badProvider = new GcpSecretManagerProvider(badOpts, mockClient); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("Boolean evaluation") + class BooleanEvaluation { + @Test + @DisplayName("returns true for secret value 'true'") + void trueValue() { + stubSecret("true"); + ProviderEvaluation result = + provider.getBooleanEvaluation("bool-flag", false, new ImmutableContext()); + assertThat(result.getValue()).isTrue(); + assertThat(result.getReason()).isEqualTo(Reason.STATIC.toString()); + } + + @Test + @DisplayName("returns false for secret value 'false'") + void falseValue() { + stubSecret("false"); + ProviderEvaluation result = + provider.getBooleanEvaluation("bool-flag", true, new ImmutableContext()); + assertThat(result.getValue()).isFalse(); + } + + @Test + @DisplayName("throws ParseError for malformed boolean value") + void malformedBooleanThrows() { + stubSecret("not-a-bool"); + assertThatThrownBy(() -> provider.getBooleanEvaluation("bool-flag", false, new ImmutableContext())) + .isInstanceOf(ParseError.class); + } + } + + @Nested + @DisplayName("String evaluation") + class StringEvaluation { + @Test + @DisplayName("returns string value as-is") + void stringValue() { + stubSecret("dark-mode"); + ProviderEvaluation result = + provider.getStringEvaluation("str-flag", "light-mode", new ImmutableContext()); + assertThat(result.getValue()).isEqualTo("dark-mode"); + } + } + + @Nested + @DisplayName("Integer evaluation") + class IntegerEvaluation { + @Test + @DisplayName("parses numeric string to Integer") + void integerValue() { + stubSecret("42"); + ProviderEvaluation result = provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext()); + assertThat(result.getValue()).isEqualTo(42); + } + + @Test + @DisplayName("throws ParseError for non-numeric value") + void nonNumericThrows() { + stubSecret("abc"); + assertThatThrownBy(() -> provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext())) + .isInstanceOf(ParseError.class); + } + } + + @Nested + @DisplayName("Double evaluation") + class DoubleEvaluation { + @Test + @DisplayName("parses numeric string to Double") + void doubleValue() { + stubSecret("3.14"); + ProviderEvaluation result = + provider.getDoubleEvaluation("double-flag", 0.0, new ImmutableContext()); + assertThat(result.getValue()).isEqualTo(3.14); + } + } + + @Nested + @DisplayName("Object evaluation") + class ObjectEvaluation { + @Test + @DisplayName("parses JSON string to Value/Structure") + void jsonValue() { + stubSecret("{\"color\":\"blue\",\"count\":3}"); + ProviderEvaluation result = + provider.getObjectEvaluation("obj-flag", new Value(), new ImmutableContext()); + assertThat(result.getValue().asStructure()).isNotNull(); + assertThat(result.getValue().asStructure().getValue("color").asString()) + .isEqualTo("blue"); + } + } + + @Nested + @DisplayName("Error handling") + class ErrorHandling { + @Test + @DisplayName("throws FlagNotFoundError when secret does not exist") + void flagNotFound() { + stubSecretNotFound(); + assertThatThrownBy(() -> provider.getBooleanEvaluation("missing-flag", false, new ImmutableContext())) + .isInstanceOf(FlagNotFoundError.class); + } + + @Test + @DisplayName("throws GeneralError on unexpected GCP API exception") + void gcpApiError() { + stubSecretError("Connection refused"); + assertThatThrownBy(() -> provider.getBooleanEvaluation("flag", false, new ImmutableContext())) + .isInstanceOf(GeneralError.class); + } + } + + @Nested + @DisplayName("Caching") + class CachingTests { + @Test + @DisplayName("cache hit: GCP client called only once for two consecutive evaluations") + void cacheHit() { + stubSecret("true"); + provider.getBooleanEvaluation("cached-flag", false, new ImmutableContext()); + provider.getBooleanEvaluation("cached-flag", false, new ImmutableContext()); + verify(mockClient, times(1)).accessSecretVersion(any(SecretVersionName.class)); + } + } + + @Nested + @DisplayName("Secret name prefix") + class PrefixTests { + @Test + @DisplayName("prefix is prepended to the flag key when building secret name") + void prefixApplied() { + GcpSecretManagerProviderOptions prefixedOpts = GcpSecretManagerProviderOptions.builder() + .projectId("test-project") + .secretNamePrefix("ff-") + .build(); + stubSecret("true"); + GcpSecretManagerProvider prefixedProvider = new GcpSecretManagerProvider(prefixedOpts, mockClient); + try { + prefixedProvider.initialize(new ImmutableContext()); + } catch (Exception e) { + throw new RuntimeException(e); + } + ProviderEvaluation result = + prefixedProvider.getBooleanEvaluation("my-flag", false, new ImmutableContext()); + assertThat(result.getValue()).isTrue(); + } + } + + @Nested + @DisplayName("Lifecycle") + class LifecycleTests { + @Test + @DisplayName("shutdown() closes the GCP client") + void shutdownClosesClient() { + provider.shutdown(); + verify(mockClient, times(1)).close(); + } + } +} diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/bool-flag.txt b/providers/gcp-secret-manager/src/test/resources/fixtures/bool-flag.txt new file mode 100644 index 000000000..f32a5804e --- /dev/null +++ b/providers/gcp-secret-manager/src/test/resources/fixtures/bool-flag.txt @@ -0,0 +1 @@ +true \ No newline at end of file diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/double-flag.txt b/providers/gcp-secret-manager/src/test/resources/fixtures/double-flag.txt new file mode 100644 index 000000000..3767b4b17 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/resources/fixtures/double-flag.txt @@ -0,0 +1 @@ +3.14 \ No newline at end of file diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/int-flag.txt b/providers/gcp-secret-manager/src/test/resources/fixtures/int-flag.txt new file mode 100644 index 000000000..f70d7bba4 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/resources/fixtures/int-flag.txt @@ -0,0 +1 @@ +42 \ No newline at end of file diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/object-flag.json b/providers/gcp-secret-manager/src/test/resources/fixtures/object-flag.json new file mode 100644 index 000000000..5b3335c01 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/resources/fixtures/object-flag.json @@ -0,0 +1 @@ +{"color":"blue","count":3} \ No newline at end of file diff --git a/providers/gcp-secret-manager/src/test/resources/fixtures/string-flag.txt b/providers/gcp-secret-manager/src/test/resources/fixtures/string-flag.txt new file mode 100644 index 000000000..3643aef41 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/resources/fixtures/string-flag.txt @@ -0,0 +1 @@ +dark-mode \ No newline at end of file diff --git a/providers/gcp-secret-manager/src/test/resources/log4j2-test.xml b/providers/gcp-secret-manager/src/test/resources/log4j2-test.xml new file mode 100644 index 000000000..c52aaeaf2 --- /dev/null +++ b/providers/gcp-secret-manager/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/providers/gcp-secret-manager/version.txt b/providers/gcp-secret-manager/version.txt new file mode 100644 index 000000000..8acdd82b7 --- /dev/null +++ b/providers/gcp-secret-manager/version.txt @@ -0,0 +1 @@ +0.0.1 diff --git a/release-please-config.json b/release-please-config.json index 33ad09f17..1e9ab07d7 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -135,6 +135,17 @@ "README.md" ] }, + "providers/gcp-secret-manager": { + "package-name": "dev.openfeature.contrib.providers.gcp-secret-manager", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "pom.xml", + "README.md" + ] + }, "hooks/open-telemetry": { "package-name": "dev.openfeature.contrib.hooks.otel", "release-type": "simple", diff --git a/samples/gcp-secret-manager-sample/README.md b/samples/gcp-secret-manager-sample/README.md new file mode 100644 index 000000000..99fffaa9e --- /dev/null +++ b/samples/gcp-secret-manager-sample/README.md @@ -0,0 +1,154 @@ +# GCP Secret Manager — OpenFeature Sample + +A runnable Java application demonstrating the [GCP Secret Manager OpenFeature provider](../../providers/gcp-secret-manager). + +It evaluates five feature flags (covering every supported type) that are stored as secrets in +Google Cloud Secret Manager. + +## Feature Flags Used + +| Secret name (GCP) | Flag key | Type | Example value | +|---|---|---|---| +| `of-sample-dark-mode` | `dark-mode` | boolean | `true` | +| `of-sample-banner-text` | `banner-text` | string | `"Welcome! 10% off today only"` | +| `of-sample-max-cart-items` | `max-cart-items` | integer | `25` | +| `of-sample-discount-rate` | `discount-rate` | double | `0.10` | +| `of-sample-checkout-config` | `checkout-config` | object (JSON) | `{"paymentMethods":["card","paypal"],...}` | + +All secrets are prefixed with `of-sample-` so they are easy to identify and clean up. + +--- + +## Prerequisites + +| Tool | Version | Install | +|---|---|---| +| Java | 17+ | [adoptium.net](https://adoptium.net) | +| Maven | 3.8+ | [maven.apache.org](https://maven.apache.org) | +| gcloud CLI | any | [cloud.google.com/sdk](https://cloud.google.com/sdk/docs/install) | +| GCP project | — | [console.cloud.google.com](https://console.cloud.google.com) | + +Your GCP account needs the **Secret Manager Secret Accessor** role (`roles/secretmanager.secretAccessor`) +on the project. + +--- + +## Step 1 — Enable the API + +```bash +export GCP_PROJECT_ID=my-gcp-project # replace with your project ID + +gcloud services enable secretmanager.googleapis.com --project="$GCP_PROJECT_ID" +``` + +## Step 2 — Authenticate + +```bash +gcloud auth application-default login +``` + +## Step 3 — Build the provider + +From the **root** of `java-sdk-contrib`: + +```bash +mvn install -DskipTests -P '!deploy' +``` + +This installs the provider JAR to your local Maven repository (`~/.m2`). + +## Step 4 — Create the feature-flag secrets + +```bash +cd samples/gcp-secret-manager-sample +bash setup.sh +``` + +You should see output like: + +``` +Creating sample feature-flag secrets in project: my-gcp-project + [CREATED] of-sample-dark-mode + [VERSION] of-sample-dark-mode → true + [CREATED] of-sample-banner-text + ... +✓ All secrets created successfully. +``` + +## Step 5 — Run the sample + +```bash +mvn exec:java +``` + +The app reads `GCP_PROJECT_ID` from the environment. You can also pass it explicitly: + +```bash +mvn exec:java -DGCP_PROJECT_ID=my-gcp-project +``` + +### Expected output + +``` +======================================================= + GCP Secret Manager — OpenFeature Sample +======================================================= +Project : my-gcp-project +Prefix : of-sample- + +── Boolean Flag » dark-mode ────────────────────────────────────── +Value : true +Effect : Dark theme activated + +── String Flag » banner-text ───────────────────────────────────── +Value : Welcome! 10% off today only + +── Integer Flag » max-cart-items ───────────────────────────────── +Value : 25 +Effect : Cart is capped at 25 items + +── Double Flag » discount-rate ─────────────────────────────────── +Value : 0.10 +Effect : 10% discount applied to cart total + +── Object Flag » checkout-config ───────────────────────────────── +Value : Structure{...} +Payment methods : ["card", "paypal"] +Express checkout : true + +======================================================= + All flags evaluated successfully. +======================================================= +``` + +## Step 6 — Clean up + +```bash +bash teardown.sh +``` + +--- + +## Changing flag values + +To update a flag, add a new secret version: + +```bash +echo -n "false" | gcloud secrets versions add of-sample-dark-mode \ + --project="$GCP_PROJECT_ID" --data-file=- +``` + +Re-run the sample to see the new value (cache expires after 30 seconds in this sample). + +--- + +## Troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `GCP_PROJECT_ID is not set` | Env var missing | `export GCP_PROJECT_ID=my-project` | +| `FlagNotFoundError` | Secret doesn't exist | Run `setup.sh` first | +| `PERMISSION_DENIED` | Missing IAM role | Grant `roles/secretmanager.secretAccessor` | +| `UNAUTHENTICATED` | No credentials | Run `gcloud auth application-default login` | +| `secretmanager.googleapis.com is not enabled` | API disabled | Run Step 1 | +| `Could not find artifact ...gcp-secret-manager` | Provider not installed | Run Step 3 | diff --git a/samples/gcp-secret-manager-sample/pom.xml b/samples/gcp-secret-manager-sample/pom.xml new file mode 100644 index 000000000..3689ae726 --- /dev/null +++ b/samples/gcp-secret-manager-sample/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + dev.openfeature.contrib.samples + gcp-secret-manager-sample + 1.0-SNAPSHOT + jar + + GCP Secret Manager OpenFeature Sample + + Runnable sample demonstrating the GCP Secret Manager OpenFeature provider. + Evaluates five feature flags (bool, string, int, double, object) stored as + GCP secrets against a real GCP project. + + + + 17 + ${java.version} + ${java.version} + UTF-8 + + ${env.GCP_PROJECT_ID} + + + + + + dev.openfeature.contrib.providers + gcp-secret-manager + 0.0.1 + + + + + dev.openfeature + sdk + 1.12.1 + + + + + org.slf4j + slf4j-simple + 2.0.17 + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + dev.openfeature.contrib.samples.gcpsecretmanager.SecretManagerSampleApp + + + GCP_PROJECT_ID + ${GCP_PROJECT_ID} + + + + + + + diff --git a/samples/gcp-secret-manager-sample/setup.sh b/samples/gcp-secret-manager-sample/setup.sh new file mode 100644 index 000000000..777a1a25a --- /dev/null +++ b/samples/gcp-secret-manager-sample/setup.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# setup.sh — Creates the five sample feature-flag secrets in GCP Secret Manager. +# +# Prerequisites: +# - gcloud CLI installed and authenticated (gcloud auth application-default login) +# - Secret Manager API enabled: gcloud services enable secretmanager.googleapis.com +# - GCP_PROJECT_ID environment variable set to your GCP project ID +# +# Usage: +# export GCP_PROJECT_ID=my-gcp-project +# bash setup.sh + +set -euo pipefail + +PROJECT="${GCP_PROJECT_ID:?Please set GCP_PROJECT_ID (e.g. export GCP_PROJECT_ID=my-project)}" + +echo "Creating sample feature-flag secrets in project: ${PROJECT}" +echo "All secrets are prefixed with 'of-sample-' to match the sample app." +echo "" + +# ──────────────────────────────────────────────────────────────────────────────── +create_secret() { + local name="$1" + local value="$2" + local full_name="of-sample-${name}" + + # Create the secret resource (idempotent — ignores "already exists") + if gcloud secrets describe "${full_name}" --project="${PROJECT}" &>/dev/null; then + echo " [EXISTS] ${full_name} — adding new version" + else + gcloud secrets create "${full_name}" \ + --project="${PROJECT}" \ + --replication-policy=automatic \ + --quiet + echo " [CREATED] ${full_name}" + fi + + # Add a secret version with the flag value + echo -n "${value}" | gcloud secrets versions add "${full_name}" \ + --project="${PROJECT}" \ + --data-file=- \ + --quiet + echo " [VERSION] ${full_name} → ${value}" +} +# ──────────────────────────────────────────────────────────────────────────────── + +# Boolean flag: dark UI theme toggle +create_secret "dark-mode" "true" + +# String flag: hero banner text +create_secret "banner-text" "Welcome! 10% off today only" + +# Integer flag: maximum items in cart +create_secret "max-cart-items" "25" + +# Double flag: discount multiplier (10%) +create_secret "discount-rate" "0.10" + +# Object flag: structured checkout configuration (JSON) +create_secret "checkout-config" '{"paymentMethods":["card","paypal"],"expressCheckout":true,"maxRetries":3}' + +echo "" +echo "✓ All secrets created successfully." +echo "" +echo "Next steps:" +echo " 1. Authenticate: gcloud auth application-default login" +echo " 2. Run the sample:" +echo " cd gcp-secret-manager-sample" +echo " mvn exec:java # GCP_PROJECT_ID must still be set in your shell" +echo " 3. To clean up: bash teardown.sh" diff --git a/samples/gcp-secret-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpsecretmanager/SecretManagerSampleApp.java b/samples/gcp-secret-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpsecretmanager/SecretManagerSampleApp.java new file mode 100644 index 000000000..4e1d24276 --- /dev/null +++ b/samples/gcp-secret-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpsecretmanager/SecretManagerSampleApp.java @@ -0,0 +1,130 @@ +package dev.openfeature.contrib.samples.gcpsecretmanager; + +import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProvider; +import dev.openfeature.contrib.providers.gcpsecretmanager.GcpSecretManagerProviderOptions; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Value; +import java.time.Duration; + +/** + * Sample application demonstrating the GCP Secret Manager OpenFeature provider. + * + *

This app evaluates five feature flags backed by GCP Secret Manager secrets: + *

    + *
  • {@code of-sample-dark-mode} (boolean) — whether the dark UI theme is enabled
  • + *
  • {@code of-sample-banner-text} (string) — hero banner copy shown to users
  • + *
  • {@code of-sample-max-cart-items} (integer) — maximum items allowed in the cart
  • + *
  • {@code of-sample-discount-rate} (double) — discount multiplier (0.0 – 1.0)
  • + *
  • {@code of-sample-checkout-config} (object/JSON) — structured checkout settings
  • + *
+ * + *

Run {@code setup.sh} first to create these secrets in your GCP project, then: + *

+ *   export GCP_PROJECT_ID=my-gcp-project
+ *   mvn exec:java
+ * 
+ */ +public class SecretManagerSampleApp { + + private static final String PREFIX = "of-sample-"; + + public static void main(String[] args) throws Exception { + String projectId = resolveProjectId(args); + + System.out.println("======================================================="); + System.out.println(" GCP Secret Manager — OpenFeature Sample"); + System.out.println("======================================================="); + System.out.println("Project : " + projectId); + System.out.println("Prefix : " + PREFIX); + System.out.println(); + + // Build provider options + GcpSecretManagerProviderOptions options = + GcpSecretManagerProviderOptions.builder() + .projectId(projectId) + .secretNamePrefix(PREFIX) // secrets are named "of-sample-" + .secretVersion("latest") + .cacheExpiry(Duration.ofSeconds(30)) + .build(); + + // Register the provider with OpenFeature + GcpSecretManagerProvider provider = new GcpSecretManagerProvider(options); + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(provider); + Client client = api.getClient(); + + // Evaluation context (optional — demonstrates passing user context) + MutableContext ctx = new MutableContext(); + ctx.add("userId", "user-42"); + + // ── Boolean flag ──────────────────────────────────────────────────────────── + printHeader("Boolean Flag » dark-mode"); + boolean darkMode = client.getBooleanValue("dark-mode", false, ctx); + System.out.println("Value : " + darkMode); + System.out.println("Effect : " + (darkMode ? "Dark theme activated" : "Light theme active")); + + // ── String flag ───────────────────────────────────────────────────────────── + printHeader("String Flag » banner-text"); + String bannerText = client.getStringValue("banner-text", "Welcome!", ctx); + System.out.println("Value : " + bannerText); + + // ── Integer flag ───────────────────────────────────────────────────────────── + printHeader("Integer Flag » max-cart-items"); + int maxCartItems = client.getIntegerValue("max-cart-items", 10, ctx); + System.out.println("Value : " + maxCartItems); + System.out.println("Effect : Cart is capped at " + maxCartItems + " items"); + + // ── Double flag ────────────────────────────────────────────────────────────── + printHeader("Double Flag » discount-rate"); + double discountRate = client.getDoubleValue("discount-rate", 0.0, ctx); + System.out.printf("Value : %.2f%n", discountRate); + System.out.printf("Effect : %.0f%% discount applied to cart total%n", discountRate * 100); + + // ── Object flag (JSON) ─────────────────────────────────────────────────────── + printHeader("Object Flag » checkout-config"); + Value checkoutConfig = client.getObjectValue("checkout-config", new Value(), ctx); + System.out.println("Value : " + checkoutConfig); + if (checkoutConfig.isStructure()) { + Value paymentMethods = checkoutConfig.asStructure().getValue("paymentMethods"); + Value expressCheckout = checkoutConfig.asStructure().getValue("expressCheckout"); + System.out.println("Payment methods : " + paymentMethods); + System.out.println("Express checkout : " + expressCheckout); + } + + System.out.println(); + System.out.println("======================================================="); + System.out.println(" All flags evaluated successfully."); + System.out.println("======================================================="); + + api.shutdown(); + } + + private static String resolveProjectId(String[] args) { + // 1. CLI argument + if (args.length > 0 && !args[0].isBlank()) { + return args[0]; + } + // 2. Environment variable + String fromEnv = System.getenv("GCP_PROJECT_ID"); + if (fromEnv != null && !fromEnv.isBlank()) { + return fromEnv; + } + // 3. System property (set via -DGCP_PROJECT_ID=... or exec plugin config) + String fromProp = System.getProperty("GCP_PROJECT_ID"); + if (fromProp != null && !fromProp.isBlank()) { + return fromProp; + } + System.err.println("ERROR: GCP_PROJECT_ID is not set."); + System.err.println("Usage: export GCP_PROJECT_ID=my-project && mvn exec:java"); + System.err.println(" or: mvn exec:java -DGCP_PROJECT_ID=my-project"); + System.exit(1); + return null; // unreachable + } + + private static void printHeader(String title) { + System.out.println(); + System.out.println("── " + title + " " + "─".repeat(Math.max(0, 50 - title.length()))); + } +} diff --git a/samples/gcp-secret-manager-sample/teardown.sh b/samples/gcp-secret-manager-sample/teardown.sh new file mode 100644 index 000000000..ec5997e77 --- /dev/null +++ b/samples/gcp-secret-manager-sample/teardown.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# teardown.sh — Deletes the sample feature-flag secrets from GCP Secret Manager. +# +# Usage: +# export GCP_PROJECT_ID=my-gcp-project +# bash teardown.sh + +set -euo pipefail + +PROJECT="${GCP_PROJECT_ID:?Please set GCP_PROJECT_ID (e.g. export GCP_PROJECT_ID=my-project)}" + +echo "Deleting sample secrets from project: ${PROJECT}" +echo "" + +for name in dark-mode banner-text max-cart-items discount-rate checkout-config; do + full_name="of-sample-${name}" + if gcloud secrets describe "${full_name}" --project="${PROJECT}" &>/dev/null; then + gcloud secrets delete "${full_name}" --project="${PROJECT}" --quiet + echo " [DELETED] ${full_name}" + else + echo " [SKIP] ${full_name} (not found)" + fi +done + +echo "" +echo "✓ Cleanup complete."