diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b96f60247..1fab46e59 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-parameter-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..30abe4f84 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,7 @@ providers/optimizely providers/multiprovider providers/ofrep + providers/gcp-parameter-manager tools/flagd-http-connector diff --git a/providers/gcp-parameter-manager/CHANGELOG.md b/providers/gcp-parameter-manager/CHANGELOG.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/providers/gcp-parameter-manager/CHANGELOG.md @@ -0,0 +1 @@ + diff --git a/providers/gcp-parameter-manager/README.md b/providers/gcp-parameter-manager/README.md new file mode 100644 index 000000000..63ddc65f4 --- /dev/null +++ b/providers/gcp-parameter-manager/README.md @@ -0,0 +1,121 @@ +# GCP Parameter Manager Provider + +An OpenFeature provider that reads feature flags from [Google Cloud Parameter Manager](https://cloud.google.com/secret-manager/parameter-manager/docs/overview), the GCP-native equivalent of AWS SSM Parameter Store. + +## Installation + + +```xml + + dev.openfeature.contrib.providers + gcp-parameter-manager + 0.0.1 + +``` + + +## Quick Start + +```java +import dev.openfeature.contrib.providers.gcpparametermanager.GcpParameterManagerProvider; +import dev.openfeature.contrib.providers.gcpparametermanager.GcpParameterManagerProviderOptions; +import dev.openfeature.sdk.OpenFeatureAPI; + +GcpParameterManagerProviderOptions options = GcpParameterManagerProviderOptions.builder() + .projectId("my-gcp-project") + .build(); + +OpenFeatureAPI.getInstance().setProvider(new GcpParameterManagerProvider(options)); + +// Evaluate a boolean flag stored in GCP as parameter "enable-dark-mode" +boolean darkMode = OpenFeatureAPI.getInstance().getClient() + .getBooleanValue("enable-dark-mode", false); +``` + +## How It Works + +Each feature flag is stored as an individual **parameter** in GCP Parameter Manager. The flag key maps directly to the parameter name (with an optional prefix). + +Supported raw value formats: + +| Flag type | Parameter 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")); + +GcpParameterManagerProviderOptions options = GcpParameterManagerProviderOptions.builder() + .projectId("my-gcp-project") + .credentials(credentials) + .build(); +``` + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `projectId` | `String` | *(required)* | GCP project ID that owns the parameters | +| `locationId` | `String` | `"global"` | GCP location for the Parameter Manager endpoint (`"global"` or a region such as `"us-central1"`) | +| `credentials` | `GoogleCredentials` | `null` (ADC) | Explicit credentials; falls back to Application Default Credentials when null | +| `cacheExpiry` | `Duration` | `5 minutes` | How long fetched values are cached before re-fetching from GCP | +| `cacheMaxSize` | `int` | `500` | Maximum number of flag values held in the in-memory cache | +| `parameterNamePrefix` | `String` | `null` | Optional prefix prepended to every flag key. E.g. prefix `"ff-"` maps flag `"my-flag"` to parameter `"ff-my-flag"` | + +## Advanced Usage + +### Regional endpoint + +```java +GcpParameterManagerProviderOptions options = GcpParameterManagerProviderOptions.builder() + .projectId("my-gcp-project") + .locationId("us-central1") + .build(); +``` + +### Parameter name prefix + +```java +GcpParameterManagerProviderOptions options = GcpParameterManagerProviderOptions.builder() + .projectId("my-gcp-project") + .parameterNamePrefix("feature-flags/") + .build(); +``` + +### Tuning cache for high-throughput scenarios + +GCP Parameter Manager has API quotas. Use a longer `cacheExpiry` to reduce quota consumption. + +```java +GcpParameterManagerProviderOptions options = GcpParameterManagerProviderOptions.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 parameters. + +1. Configure ADC: `gcloud auth application-default login` +2. Create test parameters in your project (see `GcpParameterManagerProviderIntegrationTest` for the required parameter names) +3. Run: + +```bash +GCP_PROJECT_ID=my-project mvn verify -pl providers/gcp-parameter-manager -Dgroups=integration +``` diff --git a/providers/gcp-parameter-manager/pom.xml b/providers/gcp-parameter-manager/pom.xml new file mode 100644 index 000000000..bc3a92343 --- /dev/null +++ b/providers/gcp-parameter-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-parameter-manager + 0.0.1 + + + + ${groupId}.gcpparametermanager + + + gcp-parameter-manager + GCP Parameter Manager provider for OpenFeature Java SDK + https://openfeature.dev + + + + openfeaturebot + OpenFeature Bot + OpenFeature + https://openfeature.dev/ + + + + + + + com.google.cloud + google-cloud-parametermanager + 0.31.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-parameter-manager/spotbugs-exclusions.xml b/providers/gcp-parameter-manager/spotbugs-exclusions.xml new file mode 100644 index 000000000..7f1d7ae12 --- /dev/null +++ b/providers/gcp-parameter-manager/spotbugs-exclusions.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCache.java b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCache.java new file mode 100644 index 000000000..05fd9e615 --- /dev/null +++ b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCache.java @@ -0,0 +1,89 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +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 Parameter 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-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverter.java b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverter.java new file mode 100644 index 000000000..78916e8ec --- /dev/null +++ b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverter.java @@ -0,0 +1,164 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +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 parameter values (fetched from GCP Parameter 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 Parameter 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 the string looks like JSON, parse it into an OpenFeature Value/Structure + 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}. + * Follows the same pattern as {@code FlagsmithProvider.objectToValue()}. + */ + 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-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProvider.java b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProvider.java new file mode 100644 index 000000000..379c6343e --- /dev/null +++ b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProvider.java @@ -0,0 +1,176 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +import com.google.api.gax.rpc.NotFoundException; +import com.google.cloud.parametermanager.v1.ParameterManagerClient; +import com.google.cloud.parametermanager.v1.ParameterVersionName; +import com.google.cloud.parametermanager.v1.RenderParameterVersionResponse; +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 Parameter Manager. + * + *

Each feature flag is stored as an individual parameter in GCP Parameter Manager. The flag + * key maps directly to the parameter name (with an optional prefix configured via + * {@link GcpParameterManagerProviderOptions#getParameterNamePrefix()}). + * + *

Flag values are read as strings 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 GcpParameterManagerProviderOptions#getCacheExpiry()}. + * + *

Example: + *

{@code
+ * GcpParameterManagerProviderOptions opts = GcpParameterManagerProviderOptions.builder()
+ *     .projectId("my-gcp-project")
+ *     .build();
+ * OpenFeatureAPI.getInstance().setProvider(new GcpParameterManagerProvider(opts));
+ * }
+ */ +@Slf4j +public class GcpParameterManagerProvider implements FeatureProvider { + + static final String PROVIDER_NAME = "GCP Parameter Manager Provider"; + + private final GcpParameterManagerProviderOptions options; + private ParameterManagerClient 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 GcpParameterManagerProvider(GcpParameterManagerProviderOptions options) { + this.options = options; + } + + /** + * Package-private constructor allowing injection of a pre-built client for testing. + */ + GcpParameterManagerProvider(GcpParameterManagerProviderOptions options, ParameterManagerClient 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 = ParameterManagerClientFactory.create(options); + } + cache = new FlagCache(options.getCacheExpiry(), options.getCacheMaxSize()); + log.info("GcpParameterManagerProvider initialized for project '{}'", options.getProjectId()); + } + + @Override + public void shutdown() { + if (client != null) { + try { + client.close(); + } catch (Exception e) { + log.warn("Error closing ParameterManagerClient", e); + } + client = null; + } + log.info("GcpParameterManagerProvider 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 paramName = buildParameterName(key); + return cache.get(paramName).orElseGet(() -> { + String value = fetchFromGcp(paramName); + cache.put(paramName, value); + return value; + }); + } + + /** + * Applies the configured prefix (if any) and returns the GCP parameter name for the flag. + */ + private String buildParameterName(String flagKey) { + String prefix = options.getParameterNamePrefix(); + return (prefix != null && !prefix.isEmpty()) ? prefix + flagKey : flagKey; + } + + /** + * Fetches the latest version of the named parameter from GCP Parameter Manager. + * + * @param parameterName the GCP parameter name (without project/location path) + * @return the rendered string value of the parameter + * @throws FlagNotFoundError when the parameter does not exist + * @throws GeneralError for any other GCP API error + */ + private String fetchFromGcp(String parameterName) { + try { + ParameterVersionName versionName = + ParameterVersionName.of(options.getProjectId(), options.getLocationId(), parameterName, options.getParameterVersion()); + log.debug("Fetching parameter '{}' from GCP", versionName); + RenderParameterVersionResponse response = client.renderParameterVersion(versionName); + return response.getRenderedPayload().toStringUtf8(); + } catch (NotFoundException e) { + throw new FlagNotFoundError("Parameter not found: " + parameterName); + } catch (Exception e) { + throw new GeneralError("Error fetching parameter '" + parameterName + "': " + e.getMessage()); + } + } +} diff --git a/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderOptions.java b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderOptions.java new file mode 100644 index 000000000..333faf356 --- /dev/null +++ b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderOptions.java @@ -0,0 +1,90 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +import com.google.auth.oauth2.GoogleCredentials; +import java.time.Duration; +import lombok.Builder; +import lombok.Getter; + +/** + * Configuration options for {@link GcpParameterManagerProvider}. + * + *

Example usage: + *

{@code
+ * GcpParameterManagerProviderOptions opts = GcpParameterManagerProviderOptions.builder()
+ *     .projectId("my-gcp-project")
+ *     .locationId("us-central1")
+ *     .cacheExpiry(Duration.ofMinutes(2))
+ *     .build();
+ * }
+ */ +@Getter +@Builder +public class GcpParameterManagerProviderOptions { + + /** + * GCP project ID that owns the parameters. Required. + * Example: "my-gcp-project" or numeric project number "123456789". + */ + private final String projectId; + + /** + * GCP location for the Parameter Manager endpoint. Optional. + * Use "global" (default) for the global endpoint, or a region such as "us-central1" + * when parameters are stored regionally. + */ + @Builder.Default + private final String locationId = "global"; + + /** + * Explicit Google credentials to use when creating the Parameter Manager client. + * When {@code null} (default), Application Default Credentials (ADC) are used + * automatically by the GCP client library. + */ + private final GoogleCredentials credentials; + + /** + * How long a fetched parameter value is retained in the in-memory cache before + * the next evaluation triggers a fresh GCP API call. + * + *

GCP Parameter Manager has API quotas. 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 parameter 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; + + /** + * The parameter 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 parameter updates. + */ + @Builder.Default + private final String parameterVersion = "latest"; + + /** + * Optional prefix prepended to every flag key before constructing the GCP + * parameter name. For example, setting {@code parameterNamePrefix = "ff-"} maps + * flag key {@code "my-flag"} to parameter name {@code "ff-my-flag"}. + */ + private final String parameterNamePrefix; + + /** + * 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("GcpParameterManagerProviderOptions: projectId must not be blank"); + } + } +} diff --git a/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/ParameterManagerClientFactory.java b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/ParameterManagerClientFactory.java new file mode 100644 index 000000000..3ab8eee57 --- /dev/null +++ b/providers/gcp-parameter-manager/src/main/java/dev/openfeature/contrib/providers/gcpparametermanager/ParameterManagerClientFactory.java @@ -0,0 +1,34 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.cloud.parametermanager.v1.ParameterManagerClient; +import com.google.cloud.parametermanager.v1.ParameterManagerSettings; +import java.io.IOException; + +/** + * Factory for creating a {@link ParameterManagerClient}, separated to allow injection + * of mock clients in unit tests. + */ +final class ParameterManagerClientFactory { + + private ParameterManagerClientFactory() {} + + /** + * Creates a new {@link ParameterManagerClient} using the provided options. + * + *

When {@link GcpParameterManagerProviderOptions#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 ParameterManagerClient} + * @throws IOException if the client cannot be created + */ + static ParameterManagerClient create(GcpParameterManagerProviderOptions options) throws IOException { + ParameterManagerSettings.Builder settingsBuilder = ParameterManagerSettings.newBuilder(); + if (options.getCredentials() != null) { + settingsBuilder.setCredentialsProvider(FixedCredentialsProvider.create(options.getCredentials())); + } + return ParameterManagerClient.create(settingsBuilder.build()); + } +} diff --git a/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCacheTest.java b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCacheTest.java new file mode 100644 index 000000000..25141cc0a --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagCacheTest.java @@ -0,0 +1,83 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +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); // ensure different insertion time + tinyCache.put("second", "2"); + Thread.sleep(5); + // inserting a third entry should evict the oldest + tinyCache.put("third", "3"); + // at least the newest entry must be present + assertThat(tinyCache.get("third")).isPresent().hasValue("3"); + // total size should be at most maxSize + 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-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverterTest.java b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverterTest.java new file mode 100644 index 000000000..f75a55f2b --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/FlagValueConverterTest.java @@ -0,0 +1,134 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +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-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderIntegrationTest.java b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderIntegrationTest.java new file mode 100644 index 000000000..eafcf5f6c --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderIntegrationTest.java @@ -0,0 +1,106 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +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 GcpParameterManagerProvider}. + * + *

These tests require real GCP credentials and a pre-configured project with + * test parameters. 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 parameters in GCP Parameter Manager under the project: + *
      + *
    • {@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-parameter-manager -Dgroups=integration
+ * }
+ */ +@Tag("integration") +@DisabledIfEnvironmentVariable(named = "GCP_PROJECT_ID", matches = "") +@DisplayName("GcpParameterManagerProvider integration tests") +class GcpParameterManagerProviderIntegrationTest { + + private GcpParameterManagerProvider provider; + + @BeforeEach + void setUp() throws Exception { + String projectId = System.getenv("GCP_PROJECT_ID"); + GcpParameterManagerProviderOptions opts = GcpParameterManagerProviderOptions.builder() + .projectId(projectId) + .build(); + provider = new GcpParameterManagerProvider(opts); + provider.initialize(new ImmutableContext()); + } + + @AfterEach + void tearDown() { + if (provider != null) { + provider.shutdown(); + } + } + + @Test + @DisplayName("evaluates boolean parameter") + void booleanFlag() { + assertThat(provider.getBooleanEvaluation("it-bool-flag", false, new ImmutableContext()) + .getValue()) + .isTrue(); + } + + @Test + @DisplayName("evaluates string parameter") + void stringFlag() { + assertThat(provider.getStringEvaluation("it-string-flag", "", new ImmutableContext()) + .getValue()) + .isEqualTo("hello"); + } + + @Test + @DisplayName("evaluates integer parameter") + void integerFlag() { + assertThat(provider.getIntegerEvaluation("it-int-flag", 0, new ImmutableContext()) + .getValue()) + .isEqualTo(99); + } + + @Test + @DisplayName("evaluates double parameter") + void doubleFlag() { + assertThat(provider.getDoubleEvaluation("it-double-flag", 0.0, new ImmutableContext()) + .getValue()) + .isEqualTo(2.71); + } + + @Test + @DisplayName("evaluates object parameter 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-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderTest.java b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderTest.java new file mode 100644 index 000000000..3328f297c --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/java/dev/openfeature/contrib/providers/gcpparametermanager/GcpParameterManagerProviderTest.java @@ -0,0 +1,257 @@ +package dev.openfeature.contrib.providers.gcpparametermanager; + +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.parametermanager.v1.ParameterManagerClient; +import com.google.cloud.parametermanager.v1.ParameterVersionName; +import com.google.cloud.parametermanager.v1.RenderParameterVersionResponse; +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("GcpParameterManagerProvider") +@ExtendWith(MockitoExtension.class) +class GcpParameterManagerProviderTest { + + @Mock + private ParameterManagerClient mockClient; + + private GcpParameterManagerProviderOptions options; + private GcpParameterManagerProvider provider; + + @BeforeEach + void setUp() throws Exception { + options = GcpParameterManagerProviderOptions.builder() + .projectId("test-project") + .build(); + provider = new GcpParameterManagerProvider(options, mockClient); + provider.initialize(new ImmutableContext()); + } + + private void stubParameter(String key, String value) { + RenderParameterVersionResponse response = RenderParameterVersionResponse.newBuilder() + .setRenderedPayload(ByteString.copyFromUtf8(value)) + .build(); + when(mockClient.renderParameterVersion(any(ParameterVersionName.class))).thenReturn(response); + } + + private void stubParameterNotFound() { + when(mockClient.renderParameterVersion(any(ParameterVersionName.class))).thenThrow(NotFoundException.class); + } + + private void stubParameterError(String message) { + when(mockClient.renderParameterVersion(any(ParameterVersionName.class))) + .thenThrow(new RuntimeException(message)); + } + + @Nested + @DisplayName("Metadata") + class MetadataTests { + @Test + @DisplayName("returns the correct provider name") + void providerName() { + assertThat(provider.getMetadata().getName()).isEqualTo(GcpParameterManagerProvider.PROVIDER_NAME); + } + } + + @Nested + @DisplayName("Initialization") + class InitializationTests { + @Test + @DisplayName("throws IllegalArgumentException when projectId is blank") + void blankProjectIdThrows() { + GcpParameterManagerProviderOptions badOpts = + GcpParameterManagerProviderOptions.builder().projectId("").build(); + GcpParameterManagerProvider badProvider = new GcpParameterManagerProvider(badOpts, mockClient); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("throws IllegalArgumentException when projectId is null") + void nullProjectIdThrows() { + GcpParameterManagerProviderOptions badOpts = + GcpParameterManagerProviderOptions.builder().build(); + GcpParameterManagerProvider badProvider = new GcpParameterManagerProvider(badOpts, mockClient); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("Boolean evaluation") + class BooleanEvaluation { + @Test + @DisplayName("returns true for parameter value 'true'") + void trueValue() { + stubParameter("bool-flag", "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 parameter value 'false'") + void falseValue() { + stubParameter("bool-flag", "false"); + ProviderEvaluation result = + provider.getBooleanEvaluation("bool-flag", true, new ImmutableContext()); + assertThat(result.getValue()).isFalse(); + } + + @Test + @DisplayName("throws ParseError for malformed boolean value") + void malformedBooleanThrows() { + stubParameter("bool-flag", "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() { + stubParameter("str-flag", "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() { + stubParameter("int-flag", "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() { + stubParameter("int-flag", "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() { + stubParameter("double-flag", "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() { + stubParameter("obj-flag", "{\"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 parameter does not exist") + void flagNotFound() { + stubParameterNotFound(); + assertThatThrownBy(() -> provider.getBooleanEvaluation("missing-flag", false, new ImmutableContext())) + .isInstanceOf(FlagNotFoundError.class); + } + + @Test + @DisplayName("throws GeneralError on unexpected GCP API exception") + void gcpApiError() { + stubParameterError("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() { + stubParameter("cached-flag", "true"); + provider.getBooleanEvaluation("cached-flag", false, new ImmutableContext()); + provider.getBooleanEvaluation("cached-flag", false, new ImmutableContext()); + verify(mockClient, times(1)).renderParameterVersion(any(ParameterVersionName.class)); + } + } + + @Nested + @DisplayName("Parameter name prefix") + class PrefixTests { + @Test + @DisplayName("prefix is prepended to the flag key when building parameter name") + void prefixApplied() { + GcpParameterManagerProviderOptions prefixedOpts = GcpParameterManagerProviderOptions.builder() + .projectId("test-project") + .parameterNamePrefix("ff-") + .build(); + stubParameter("ff-my-flag", "true"); + GcpParameterManagerProvider prefixedProvider = new GcpParameterManagerProvider(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-parameter-manager/src/test/resources/fixtures/bool-flag.txt b/providers/gcp-parameter-manager/src/test/resources/fixtures/bool-flag.txt new file mode 100644 index 000000000..f32a5804e --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/resources/fixtures/bool-flag.txt @@ -0,0 +1 @@ +true \ No newline at end of file diff --git a/providers/gcp-parameter-manager/src/test/resources/fixtures/double-flag.txt b/providers/gcp-parameter-manager/src/test/resources/fixtures/double-flag.txt new file mode 100644 index 000000000..3767b4b17 --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/resources/fixtures/double-flag.txt @@ -0,0 +1 @@ +3.14 \ No newline at end of file diff --git a/providers/gcp-parameter-manager/src/test/resources/fixtures/int-flag.txt b/providers/gcp-parameter-manager/src/test/resources/fixtures/int-flag.txt new file mode 100644 index 000000000..f70d7bba4 --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/resources/fixtures/int-flag.txt @@ -0,0 +1 @@ +42 \ No newline at end of file diff --git a/providers/gcp-parameter-manager/src/test/resources/fixtures/object-flag.json b/providers/gcp-parameter-manager/src/test/resources/fixtures/object-flag.json new file mode 100644 index 000000000..5b3335c01 --- /dev/null +++ b/providers/gcp-parameter-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-parameter-manager/src/test/resources/fixtures/string-flag.txt b/providers/gcp-parameter-manager/src/test/resources/fixtures/string-flag.txt new file mode 100644 index 000000000..3643aef41 --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/resources/fixtures/string-flag.txt @@ -0,0 +1 @@ +dark-mode \ No newline at end of file diff --git a/providers/gcp-parameter-manager/src/test/resources/log4j2-test.xml b/providers/gcp-parameter-manager/src/test/resources/log4j2-test.xml new file mode 100644 index 000000000..c52aaeaf2 --- /dev/null +++ b/providers/gcp-parameter-manager/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/providers/gcp-parameter-manager/version.txt b/providers/gcp-parameter-manager/version.txt new file mode 100644 index 000000000..8acdd82b7 --- /dev/null +++ b/providers/gcp-parameter-manager/version.txt @@ -0,0 +1 @@ +0.0.1 diff --git a/release-please-config.json b/release-please-config.json index 33ad09f17..3968cadfa 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -135,6 +135,17 @@ "README.md" ] }, + "providers/gcp-parameter-manager": { + "package-name": "dev.openfeature.contrib.providers.gcp-parameter-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-parameter-manager-sample/README.md b/samples/gcp-parameter-manager-sample/README.md new file mode 100644 index 000000000..2eadcb591 --- /dev/null +++ b/samples/gcp-parameter-manager-sample/README.md @@ -0,0 +1,173 @@ +# GCP Parameter Manager — OpenFeature Sample + +A runnable Java application demonstrating the [GCP Parameter Manager OpenFeature provider](../../providers/gcp-parameter-manager). + +It evaluates five feature flags (covering every supported type) that are stored as +parameters in Google Cloud Parameter Manager. + +## Feature Flags Used + +| Parameter 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 parameters 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 **Parameter Manager Parameter Version Accessor** role +(`roles/parametermanager.parameterVersionAccessor`) on the project. + +--- + +## Step 1 — Enable the API + +```bash +export GCP_PROJECT_ID=my-gcp-project # replace with your project ID + +gcloud services enable parametermanager.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 parameters + +```bash +cd samples/gcp-parameter-manager-sample +bash setup.sh +``` + +You should see output like: + +``` +Creating sample feature-flag parameters in project: my-gcp-project (location: global) + [CREATED] of-sample-dark-mode + [VERSION] of-sample-dark-mode → true + [CREATED] of-sample-banner-text + ... +✓ All parameters 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 Parameter Manager — OpenFeature Sample +======================================================= +Project : my-gcp-project +Location : global +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 parameter version: + +```bash +gcloud parametermanager parameters versions create "v2" \ + --parameter="of-sample-dark-mode" \ + --project="$GCP_PROJECT_ID" \ + --location=global \ + --payload-data="false" +``` + +The provider fetches the `latest` version by default (the highest version number). +Re-run the sample after the 30-second cache expires to see the updated value. + +--- + +## Regional parameters + +If your parameters are stored in a specific region instead of `global`, update the +`locationId` in the app or pass a different location in `setup.sh`: + +```java +GcpParameterManagerProviderOptions.builder() + .projectId(projectId) + .locationId("us-central1") // regional endpoint + ... +``` + +--- + +## Troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `GCP_PROJECT_ID is not set` | Env var missing | `export GCP_PROJECT_ID=my-project` | +| `FlagNotFoundError` | Parameter doesn't exist | Run `setup.sh` first | +| `PERMISSION_DENIED` | Missing IAM role | Grant `roles/parametermanager.parameterVersionAccessor` | +| `UNAUTHENTICATED` | No credentials | Run `gcloud auth application-default login` | +| `parametermanager.googleapis.com is not enabled` | API disabled | Run Step 1 | +| `Could not find artifact ...gcp-parameter-manager` | Provider not installed | Run Step 3 | diff --git a/samples/gcp-parameter-manager-sample/pom.xml b/samples/gcp-parameter-manager-sample/pom.xml new file mode 100644 index 000000000..a910c5890 --- /dev/null +++ b/samples/gcp-parameter-manager-sample/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + dev.openfeature.contrib.samples + gcp-parameter-manager-sample + 1.0-SNAPSHOT + jar + + GCP Parameter Manager OpenFeature Sample + + Runnable sample demonstrating the GCP Parameter Manager OpenFeature provider. + Evaluates five feature flags (bool, string, int, double, object) stored as + GCP parameters against a real GCP project. + + + + 17 + ${java.version} + ${java.version} + UTF-8 + + ${env.GCP_PROJECT_ID} + + + + + + dev.openfeature.contrib.providers + gcp-parameter-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.gcpparametermanager.ParameterManagerSampleApp + + + GCP_PROJECT_ID + ${GCP_PROJECT_ID} + + + + + + + diff --git a/samples/gcp-parameter-manager-sample/setup.sh b/samples/gcp-parameter-manager-sample/setup.sh new file mode 100644 index 000000000..a2d341f62 --- /dev/null +++ b/samples/gcp-parameter-manager-sample/setup.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# setup.sh — Creates the five sample feature-flag parameters in GCP Parameter Manager. +# +# Prerequisites: +# - gcloud CLI installed and authenticated (gcloud auth application-default login) +# - Parameter Manager API enabled: gcloud services enable parametermanager.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)}" +LOCATION="global" + +echo "Creating sample feature-flag parameters in project: ${PROJECT} (location: ${LOCATION})" +echo "All parameters are prefixed with 'of-sample-' to match the sample app." +echo "" + +# ──────────────────────────────────────────────────────────────────────────────── +create_parameter() { + local name="$1" + local value="$2" + local full_name="of-sample-${name}" + + # Create the parameter resource (idempotent — ignores "already exists") + if gcloud parametermanager parameters describe "${full_name}" \ + --project="${PROJECT}" --location="${LOCATION}" &>/dev/null; then + echo " [EXISTS] ${full_name} — adding new version" + else + gcloud parametermanager parameters create "${full_name}" \ + --project="${PROJECT}" \ + --location="${LOCATION}" \ + --parameter-format=UNFORMATTED \ + --quiet + echo " [CREATED] ${full_name}" + fi + + # Add a parameter version with the flag value + # NOTE: versions in Parameter Manager are named; we use "v1" for the initial version + gcloud parametermanager parameters versions create "v1" \ + --parameter="${full_name}" \ + --project="${PROJECT}" \ + --location="${LOCATION}" \ + --payload-data="${value}" \ + --quiet 2>/dev/null || \ + gcloud parametermanager parameters versions create "v$(date +%s)" \ + --parameter="${full_name}" \ + --project="${PROJECT}" \ + --location="${LOCATION}" \ + --payload-data="${value}" \ + --quiet + echo " [VERSION] ${full_name} → ${value}" +} +# ──────────────────────────────────────────────────────────────────────────────── + +# Boolean flag: dark UI theme toggle +create_parameter "dark-mode" "true" + +# String flag: hero banner text +create_parameter "banner-text" "Welcome! 10% off today only" + +# Integer flag: maximum items in cart +create_parameter "max-cart-items" "25" + +# Double flag: discount multiplier (10%) +create_parameter "discount-rate" "0.10" + +# Object flag: structured checkout configuration (JSON) +create_parameter "checkout-config" '{"paymentMethods":["card","paypal"],"expressCheckout":true,"maxRetries":3}' + +echo "" +echo "✓ All parameters created successfully." +echo "" +echo "Next steps:" +echo " 1. Authenticate: gcloud auth application-default login" +echo " 2. Run the sample:" +echo " cd gcp-parameter-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-parameter-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpparametermanager/ParameterManagerSampleApp.java b/samples/gcp-parameter-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpparametermanager/ParameterManagerSampleApp.java new file mode 100644 index 000000000..1d6df703d --- /dev/null +++ b/samples/gcp-parameter-manager-sample/src/main/java/dev/openfeature/contrib/samples/gcpparametermanager/ParameterManagerSampleApp.java @@ -0,0 +1,131 @@ +package dev.openfeature.contrib.samples.gcpparametermanager; + +import dev.openfeature.contrib.providers.gcpparametermanager.GcpParameterManagerProvider; +import dev.openfeature.contrib.providers.gcpparametermanager.GcpParameterManagerProviderOptions; +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 Parameter Manager OpenFeature provider. + * + *

This app evaluates five feature flags backed by GCP Parameter Manager parameters: + *

    + *
  • {@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 parameters in your GCP project, then: + *

+ *   export GCP_PROJECT_ID=my-gcp-project
+ *   mvn exec:java
+ * 
+ */ +public class ParameterManagerSampleApp { + + 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 Parameter Manager — OpenFeature Sample"); + System.out.println("======================================================="); + System.out.println("Project : " + projectId); + System.out.println("Location : global"); + System.out.println("Prefix : " + PREFIX); + System.out.println(); + + // Build provider options + GcpParameterManagerProviderOptions options = + GcpParameterManagerProviderOptions.builder() + .projectId(projectId) + .locationId("global") // use "us-central1" etc. for regional parameters + .parameterNamePrefix(PREFIX) // parameters named "of-sample-" + .cacheExpiry(Duration.ofSeconds(30)) + .build(); + + // Register the provider with OpenFeature + GcpParameterManagerProvider provider = new GcpParameterManagerProvider(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-parameter-manager-sample/teardown.sh b/samples/gcp-parameter-manager-sample/teardown.sh new file mode 100755 index 000000000..c1db0db17 --- /dev/null +++ b/samples/gcp-parameter-manager-sample/teardown.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# teardown.sh — Deletes the sample feature-flag parameters from GCP Parameter 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)}" +LOCATION="global" + +echo "Deleting sample parameters from project: ${PROJECT} (location: ${LOCATION})" +echo "" + +for name in dark-mode banner-text max-cart-items discount-rate checkout-config; do + full_name="of-sample-${name}" + if gcloud parametermanager parameters describe "${full_name}" \ + --project="${PROJECT}" --location="${LOCATION}" &>/dev/null; then + for version in $(gcloud parametermanager parameters versions list \ + --parameter="${full_name}" --project="${PROJECT}" --location="${LOCATION}" \ + --format="value(name.basename())" 2>/dev/null); do + gcloud parametermanager parameters versions delete "${version}" \ + --parameter="${full_name}" --project="${PROJECT}" --location="${LOCATION}" --quiet + echo " [DELETED] ${full_name}/versions/${version}" + done + gcloud parametermanager parameters delete "${full_name}" \ + --project="${PROJECT}" --location="${LOCATION}" --quiet + echo " [DELETED] ${full_name}" + else + echo " [SKIP] ${full_name} (not found)" + fi +done + +echo "" +echo "✓ Cleanup complete."