diff --git a/providers/gcp/CHANGELOG.md b/providers/gcp/CHANGELOG.md index 7b09cce02..63e85b9bb 100644 --- a/providers/gcp/CHANGELOG.md +++ b/providers/gcp/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog + +## [0.0.3] + +### ✨ New Features + +* Add support for GCP Parameter Manager. + ## 0.0.1 ### ✨ New Features diff --git a/providers/gcp/README.md b/providers/gcp/README.md index 5d7fab28b..968ebf19d 100644 --- a/providers/gcp/README.md +++ b/providers/gcp/README.md @@ -1,6 +1,8 @@ # GCP Provider -An OpenFeature provider that reads feature flags from Google Cloud. Currently supports [Google Cloud Secret Manager](https://cloud.google.com/secret-manager), purpose-built for secrets requiring versioning, rotation, and fine-grained IAM access control. +An OpenFeature provider that reads feature flags from Google Cloud. Currently supports the following +1. [Google Cloud Secret Manager](https://cloud.google.com/secret-manager), purpose-built for secrets requiring versioning, rotation, and fine-grained IAM access control. +2. [Google Cloud Parameter Manager](https://cloud.google.com/secret-manager/parameter-manager/docs/overview), the GCP-native equivalent of AWS SSM Parameter Store. ## Installation @@ -16,6 +18,7 @@ An OpenFeature provider that reads feature flags from Google Cloud. Currently su ## Quick Start +### GCP Secret Manager ```java import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProvider; import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProviderOptions; @@ -32,9 +35,26 @@ boolean darkMode = OpenFeatureAPI.getInstance().getClient() .getBooleanValue("enable-dark-mode", false); ``` +### GCP Parameter Manager +```java +import dev.openfeature.contrib.providers.gcp.GcpParameterManagerProvider; +import dev.openfeature.contrib.providers.gcp.GcpProviderOptions; +import dev.openfeature.sdk.OpenFeatureAPI; + +GcpProviderOptions options = GcpProviderOptions.builder() + .projectId("my-gcp-project") + .build(); + +OpenFeatureAPI.getInstance().setProvider(new GcpParameterManagerProvider(options)); + +// Evaluate a boolean flag stored as parameter "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. +Each feature flag is stored as an individual **secret** in GCP Secret Manager or **parameter** in GCP Parameter Manager. The flag key maps directly to the secret name (with an optional prefix). The `latest` version is accessed by default. Supported raw value formats: @@ -78,7 +98,7 @@ GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builde ## Advanced Usage -### Pinning to a specific secret version +### Pinning to a specific version ```java GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() diff --git a/providers/gcp/pom.xml b/providers/gcp/pom.xml index 101a07043..2c4759f2e 100644 --- a/providers/gcp/pom.xml +++ b/providers/gcp/pom.xml @@ -15,7 +15,7 @@ dev.openfeature.contrib.providers gcp - 0.0.1 + 0.0.2 @@ -35,19 +35,40 @@ + + + + com.google.cloud + libraries-bom + 26.83.0 + pom + import + + + com.fasterxml.jackson + jackson-bom + 2.21.1 + pom + import + + + + com.google.cloud google-cloud-secretmanager - 2.57.0 + + + com.google.cloud + google-cloud-parametermanager com.fasterxml.jackson.core jackson-databind - 2.21.1 diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/AbstractGcpProvider.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/AbstractGcpProvider.java new file mode 100644 index 000000000..dd059da5c --- /dev/null +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/AbstractGcpProvider.java @@ -0,0 +1,127 @@ +package dev.openfeature.contrib.providers.gcp; + +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 java.util.Optional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +abstract class AbstractGcpProvider implements FeatureProvider { + + protected final GcpProviderOptions options; + protected C client; + protected FlagCache cache; + + AbstractGcpProvider(GcpProviderOptions options) { + this.options = options; + } + + AbstractGcpProvider(GcpProviderOptions options, C client) { + this.options = options; + this.client = client; + } + + @Override + public Metadata getMetadata() { + return () -> getProviderName(); + } + + @Override + public void initialize(EvaluationContext evaluationContext) throws Exception { + options.validate(); + if (client == null) { + createClient(); + } + cache = new FlagCache(options.getCacheExpiry(), options.getCacheMaxSize()); + log.info("{} initialized for project '{}'", getProviderName(), options.getProjectId()); + } + + @Override + public void shutdown() { + if (client != null) { + try { + closeClient(); + } catch (Exception e) { + log.warn("Error closing client for {}", getProviderName(), e); + } + client = null; + } + log.info("{} shut down", getProviderName()); + } + + @Override + public final ProviderEvaluation getBooleanEvaluation( + String key, Boolean defaultValue, EvaluationContext ctx) { + return evaluate(key, Boolean.class); + } + + @Override + public final ProviderEvaluation getStringEvaluation( + String key, String defaultValue, EvaluationContext ctx) { + return evaluate(key, String.class); + } + + @Override + public final ProviderEvaluation getIntegerEvaluation( + String key, Integer defaultValue, EvaluationContext ctx) { + return evaluate(key, Integer.class); + } + + @Override + public final ProviderEvaluation getDoubleEvaluation( + String key, Double defaultValue, EvaluationContext ctx) { + return evaluate(key, Double.class); + } + + @Override + public final ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + return evaluate(key, Value.class); + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + protected 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(); + } + + protected String fetchWithCache(String key) { + String name = buildName(key); + Optional cached = cache.get(name); + if (cached.isPresent()) { + log.debug("Fetching from cache name '{}'", key); + return cached.get(); + } + synchronized (cache) { + return cache.get(name).orElseGet(() -> { + String value = fetchFromGcp(name); + cache.put(name, value); + return value; + }); + } + } + + protected String buildName(String flagKey) { + String prefix = options.getNamePrefix(); + return (prefix != null && !prefix.isEmpty()) ? prefix + flagKey : flagKey; + } + + // Subclasses must implement these + protected abstract String getProviderName(); + + protected abstract void createClient() throws Exception; + + protected abstract void closeClient() throws Exception; + + protected abstract String fetchFromGcp(String name); +} diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpParameterManagerProvider.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpParameterManagerProvider.java new file mode 100644 index 000000000..42baf905b --- /dev/null +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpParameterManagerProvider.java @@ -0,0 +1,105 @@ +package dev.openfeature.contrib.providers.gcp; + +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.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 + * {@code GcpProviderOptions#getNamePrefix()}). + * + *

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 + * {@code GcpProviderOptions#getCacheExpiry()}. + * + *

Example: + *

{@code
+ * GcpProviderOptions opts = GcpProviderOptions.builder()
+ *     .projectId("my-gcp-project")
+ *     .locationId("global") // optional, defaults to "global"
+ *     .build();
+ * OpenFeatureAPI.getInstance().setProvider(new GcpParameterManagerProvider(opts));
+ * }
+ */ +@Slf4j +public class GcpParameterManagerProvider extends AbstractGcpProvider { + + static final String PROVIDER_NAME = "GCP Parameter Manager Provider"; + + /** + * 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(GcpProviderOptions options) { + super(options); + } + + /** + * Package-private constructor allowing injection of a pre-built client for testing. + */ + GcpParameterManagerProvider(GcpProviderOptions options, ParameterManagerClient client) { + super(options, client); + } + + @Override + protected String getProviderName() { + return PROVIDER_NAME; + } + + @Override + protected void createClient() throws Exception { + this.client = ParameterManagerClientFactory.create(options); + } + + @Override + protected void closeClient() throws Exception { + this.client.close(); + } + + @Override + public void initialize(EvaluationContext evaluationContext) throws Exception { + super.initialize(evaluationContext); + log.info("{} initialized via initialize()", getProviderName()); + } + + @Override + public void shutdown() { + super.shutdown(); + log.info("{} shutdown via shutdown()", getProviderName()); + } + + @Override + protected String fetchFromGcp(String parameterName) { + try { + ParameterVersionName versionName = ParameterVersionName.of( + options.getProjectId(), options.getLocationId(), parameterName, options.getVersion()); + 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(), e); + } + } +} diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpProviderOptions.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpProviderOptions.java index 95949f1a5..5be526a87 100644 --- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpProviderOptions.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpProviderOptions.java @@ -10,9 +10,9 @@ * *

Example usage: *

{@code
- * GcpSecretManagerProviderOptions opts = GcpSecretManagerProviderOptions.builder()
+ * GcpProviderOptions opts = GcpProviderOptions.builder()
  *     .projectId("my-gcp-project")
- *     .secretVersion("latest")
+ *     .version("latest")
  *     .cacheExpiry(Duration.ofMinutes(2))
  *     .build();
  * }
@@ -42,6 +42,12 @@ public class GcpProviderOptions { @Builder.Default private final String version = "latest"; + /** + * Optional location required for ParameterManager, ignored by SecretManager. + */ + @Builder.Default + private final String locationId = "global"; + /** * How long a fetched secret value is retained in the in-memory cache before * the next evaluation triggers a fresh GCP API call. diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java index becf03090..c235f4bda 100644 --- a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProvider.java @@ -6,13 +6,9 @@ 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 java.util.Optional; import lombok.extern.slf4j.Slf4j; /** @@ -20,7 +16,7 @@ * *

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 GcpProviderOptions}'s {@code namePrefix}). + * {@code GcpProviderOptions#getNamePrefix()}). * *

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

Results are cached in-process for the duration configured in - * {@link GcpProviderOptions}'s {@code cacheExpiry}. + * {@code GcpProviderOptions#getCacheExpiry()}. * *

Example: *

{@code
@@ -44,14 +40,10 @@
  * }
*/ @Slf4j -public class GcpSecretManagerProvider implements FeatureProvider { +public class GcpSecretManagerProvider extends AbstractGcpProvider { static final String PROVIDER_NAME = "GCP Secret Manager Provider"; - private final GcpProviderOptions 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)}. @@ -59,115 +51,45 @@ public class GcpSecretManagerProvider implements FeatureProvider { * @param options provider configuration; must not be null */ public GcpSecretManagerProvider(GcpProviderOptions options) { - this.options = options; + super(options); } /** * Package-private constructor allowing injection of a pre-built client for testing. */ GcpSecretManagerProvider(GcpProviderOptions options, SecretManagerServiceClient client) { - this.options = options; - this.client = client; - } - - @Override - public Metadata getMetadata() { - return () -> PROVIDER_NAME; + super(options, client); } @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()); + protected String getProviderName() { + return PROVIDER_NAME; } @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"); + protected void createClient() throws Exception { + this.client = SecretManagerClientFactory.create(options); } @Override - public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { - return evaluate(key, Boolean.class); + protected void closeClient() throws Exception { + this.client.close(); } @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); + public void initialize(EvaluationContext evaluationContext) throws Exception { + super.initialize(evaluationContext); + log.info("{} initialized via initialize()", getProviderName()); } @Override - public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { - return evaluate(key, Double.class); + public void shutdown() { + super.shutdown(); + log.info("{} shutdown via shutdown()", getProviderName()); } @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); - Optional cached = cache.get(secretName); - if (cached.isPresent()) { - return cached.get(); - } - synchronized (this) { - 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.getNamePrefix(); - 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) { + protected String fetchFromGcp(String secretName) { try { SecretVersionName versionName = SecretVersionName.of(options.getProjectId(), secretName, options.getVersion()); diff --git a/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/ParameterManagerClientFactory.java b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/ParameterManagerClientFactory.java new file mode 100644 index 000000000..9a0b0f3da --- /dev/null +++ b/providers/gcp/src/main/java/dev/openfeature/contrib/providers/gcp/ParameterManagerClientFactory.java @@ -0,0 +1,34 @@ +package dev.openfeature.contrib.providers.gcp; + +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 GcpProviderOptions#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(GcpProviderOptions 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/src/test/java/dev/openfeature/contrib/providers/gcp/AbstractGcpProviderTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/AbstractGcpProviderTest.java new file mode 100644 index 000000000..0691e1b41 --- /dev/null +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/AbstractGcpProviderTest.java @@ -0,0 +1,245 @@ +package dev.openfeature.contrib.providers.gcp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.openfeature.sdk.FeatureProvider; +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; + +@DisplayName("GCP provider shared behavior") +abstract class AbstractGcpProviderTest { + + protected GcpProviderOptions options; + protected FeatureProvider provider; + + protected abstract FeatureProvider createProvider(GcpProviderOptions options); + + protected abstract FeatureProvider createProvider(GcpProviderOptions options, Object client); + + protected abstract void stubFetchSuccess(String value); + + protected abstract void stubFetchNotFound(); + + protected abstract void stubFetchError(String message); + + protected abstract void verifyFetchCalled(int times); + + protected abstract void verifyClientClosed(int times); + + protected abstract String getProviderName(); + + protected abstract GcpProviderOptions.GcpProviderOptionsBuilder newOptionsBuilder(); + + @BeforeEach + void setUp() throws Exception { + options = newOptionsBuilder().build(); + provider = createProvider(options); + provider.initialize(new ImmutableContext()); + } + + @Nested + @DisplayName("Metadata") + class MetadataTests { + + @Test + @DisplayName("returns the correct provider name") + void providerName() { + assertThat(provider.getMetadata().getName()).isEqualTo(getProviderName()); + } + } + + @Nested + @DisplayName("Initialization") + class InitializationTests { + + @Test + @DisplayName("throws IllegalArgumentException when projectId is blank") + void blankProjectIdThrows() { + GcpProviderOptions badOpts = newOptionsBuilder().projectId("").build(); + FeatureProvider badProvider = createProvider(badOpts); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("throws IllegalArgumentException when projectId is null") + void nullProjectIdThrows() { + GcpProviderOptions badOpts = newOptionsBuilder().projectId(null).build(); + FeatureProvider badProvider = createProvider(badOpts); + assertThatThrownBy(() -> badProvider.initialize(new ImmutableContext())) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("Boolean evaluation") + class BooleanEvaluation { + + @Test + @DisplayName("returns true for value 'true'") + void trueValue() { + stubFetchSuccess("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 value 'false'") + void falseValue() { + stubFetchSuccess("false"); + ProviderEvaluation result = + provider.getBooleanEvaluation("bool-flag", true, new ImmutableContext()); + assertThat(result.getValue()).isFalse(); + } + + @Test + @DisplayName("throws ParseError for malformed boolean value") + void malformedBooleanThrows() { + stubFetchSuccess("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() { + stubFetchSuccess("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() { + stubFetchSuccess("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() { + stubFetchSuccess("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() { + stubFetchSuccess("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() { + stubFetchSuccess("{\"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 value does not exist") + void flagNotFound() { + stubFetchNotFound(); + assertThatThrownBy(() -> provider.getBooleanEvaluation("missing-flag", false, new ImmutableContext())) + .isInstanceOf(FlagNotFoundError.class); + } + + @Test + @DisplayName("throws GeneralError on unexpected API exception") + void apiError() { + stubFetchError("Connection refused"); + assertThatThrownBy(() -> provider.getBooleanEvaluation("flag", false, new ImmutableContext())) + .isInstanceOf(GeneralError.class); + } + } + + @Nested + @DisplayName("Caching") + class CachingTests { + + @Test + @DisplayName("cache hit: API called only once for repeated evaluations") + void cacheHit() { + stubFetchSuccess("true"); + provider.getBooleanEvaluation("cached-flag", false, new ImmutableContext()); + provider.getBooleanEvaluation("cached-flag", false, new ImmutableContext()); + verifyFetchCalled(1); + } + } + + @Nested + @DisplayName("Name prefix") + class PrefixTests { + + @Test + @DisplayName("prefix is prepended to the flag key when building the resource name") + void prefixApplied() throws Exception { + GcpProviderOptions prefixedOpts = + newOptionsBuilder().namePrefix("ff-").build(); + FeatureProvider prefixedProvider = createProvider(prefixedOpts); + stubFetchSuccess("true"); + prefixedProvider.initialize(new ImmutableContext()); + ProviderEvaluation result = + prefixedProvider.getBooleanEvaluation("my-flag", false, new ImmutableContext()); + assertThat(result.getValue()).isTrue(); + } + } + + @Nested + @DisplayName("Lifecycle") + class LifecycleTests { + + @Test + @DisplayName("shutdown() closes the client") + void shutdownClosesClient() { + provider.shutdown(); + verifyClientClosed(1); + } + } +} diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpParameterManagerProviderTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpParameterManagerProviderTest.java new file mode 100644 index 000000000..4716fcc02 --- /dev/null +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpParameterManagerProviderTest.java @@ -0,0 +1,74 @@ +package dev.openfeature.contrib.providers.gcp; + +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.FeatureProvider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("GcpParameterManagerProvider") +@ExtendWith(MockitoExtension.class) +class GcpParameterManagerProviderTest extends AbstractGcpProviderTest { + + @Mock + private ParameterManagerClient mockClient; + + @Override + protected FeatureProvider createProvider(GcpProviderOptions options) { + return new GcpParameterManagerProvider(options, mockClient); + } + + @Override + protected FeatureProvider createProvider(GcpProviderOptions options, Object client) { + return new GcpParameterManagerProvider(options, (ParameterManagerClient) client); + } + + @Override + protected void stubFetchSuccess(String value) { + RenderParameterVersionResponse response = RenderParameterVersionResponse.newBuilder() + .setRenderedPayload(ByteString.copyFromUtf8(value)) + .build(); + when(mockClient.renderParameterVersion(any(ParameterVersionName.class))).thenReturn(response); + } + + @Override + protected void stubFetchNotFound() { + when(mockClient.renderParameterVersion(any(ParameterVersionName.class))).thenThrow(NotFoundException.class); + } + + @Override + protected void stubFetchError(String message) { + when(mockClient.renderParameterVersion(any(ParameterVersionName.class))) + .thenThrow(new RuntimeException(message)); + } + + @Override + protected void verifyFetchCalled(int times) { + verify(mockClient, times(times)).renderParameterVersion(any(ParameterVersionName.class)); + } + + @Override + protected void verifyClientClosed(int times) { + verify(mockClient, times(times)).close(); + } + + @Override + protected String getProviderName() { + return GcpParameterManagerProvider.PROVIDER_NAME; + } + + @Override + protected GcpProviderOptions.GcpProviderOptionsBuilder newOptionsBuilder() { + return GcpProviderOptions.builder().projectId("test-project").locationId("europe-west1"); + } +} diff --git a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java index 5e6618ee3..b976883f9 100644 --- a/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java +++ b/providers/gcp/src/test/java/dev/openfeature/contrib/providers/gcp/GcpSecretManagerProviderTest.java @@ -1,7 +1,5 @@ package dev.openfeature.contrib.providers.gcp; -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; @@ -13,39 +11,31 @@ 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 dev.openfeature.sdk.FeatureProvider; 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 { +class GcpSecretManagerProviderTest extends AbstractGcpProviderTest { @Mock private SecretManagerServiceClient mockClient; - private GcpProviderOptions options; - private GcpSecretManagerProvider provider; + @Override + protected FeatureProvider createProvider(GcpProviderOptions options) { + return new GcpSecretManagerProvider(options, mockClient); + } - @BeforeEach - void setUp() throws Exception { - options = GcpProviderOptions.builder().projectId("test-project").build(); - provider = new GcpSecretManagerProvider(options, mockClient); - provider.initialize(new ImmutableContext()); + @Override + protected FeatureProvider createProvider(GcpProviderOptions options, Object client) { + return new GcpSecretManagerProvider(options, (SecretManagerServiceClient) client); } - private void stubSecret(String value) { + @Override + protected void stubFetchSuccess(String value) { AccessSecretVersionResponse response = AccessSecretVersionResponse.newBuilder() .setPayload(SecretPayload.newBuilder() .setData(ByteString.copyFromUtf8(value)) @@ -54,214 +44,33 @@ private void stubSecret(String value) { when(mockClient.accessSecretVersion(any(SecretVersionName.class))).thenReturn(response); } - private void stubSecretNotFound() { + @Override + protected void stubFetchNotFound() { when(mockClient.accessSecretVersion(any(SecretVersionName.class))).thenThrow(NotFoundException.class); } - private void stubSecretError(String message) { + @Override + protected void stubFetchError(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() { - GcpProviderOptions badOpts = - GcpProviderOptions.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() { - GcpProviderOptions badOpts = GcpProviderOptions.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); - } + @Override + protected void verifyFetchCalled(int times) { + verify(mockClient, times(times)).accessSecretVersion(any(SecretVersionName.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"); - } + @Override + protected void verifyClientClosed(int times) { + verify(mockClient, times(times)).close(); } - @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); - } + @Override + protected String getProviderName() { + return GcpSecretManagerProvider.PROVIDER_NAME; } - @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() { - GcpProviderOptions prefixedOpts = GcpProviderOptions.builder() - .projectId("test-project") - .namePrefix("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(); - } + @Override + protected GcpProviderOptions.GcpProviderOptionsBuilder newOptionsBuilder() { + return GcpProviderOptions.builder().projectId("test-project"); } } diff --git a/providers/gcp/version.txt b/providers/gcp/version.txt index 8acdd82b7..4e379d2bf 100644 --- a/providers/gcp/version.txt +++ b/providers/gcp/version.txt @@ -1 +1 @@ -0.0.1 +0.0.2 diff --git a/samples/gcp/README.md b/samples/gcp/README.md index d27981e46..52426413a 100644 --- a/samples/gcp/README.md +++ b/samples/gcp/README.md @@ -1,9 +1,10 @@ # GCP — OpenFeature Sample -A runnable Java application demonstrating the [GCP Secret Manager OpenFeature provider](../../providers/gcp). +A runnable Java application demonstrating the [GCP Secret Manager](../../providers/gcp) and +[GCP Parameter Manager](../../providers/gcp) OpenFeature providers. -It evaluates five feature flags (covering every supported type) that are stored as secrets in -Google Cloud Secret Manager. +It evaluates five feature flags (covering every supported type) that are stored with the +`of-sample-` prefix in either Google Cloud Secret Manager or Google Cloud Parameter Manager. ## Feature Flags Used @@ -57,26 +58,30 @@ mvn install -DskipTests -P '!deploy' This installs the provider JAR to your local Maven repository (`~/.m2`). -## Step 4 — Create the feature-flag secrets +## Step 4 — Create the feature-flag secrets or parameters ```bash cd samples/gcp -bash setup.sh +bash setup.sh # Creates secrets (default: Secret Manager) +# OR +bash setup.sh parameter-manager # Creates parameters in Parameter Manager ``` You should see output like: ``` -Creating sample feature-flag secrets in project: my-gcp-project +Creating sample feature-flag secret-manager 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. +✓ All secret-manager entries created successfully. ``` ## Step 5 — Run the sample +By default the module runs the Secret Manager sample: + ```bash mvn exec:java ``` @@ -87,6 +92,15 @@ The app reads `GCP_PROJECT_ID` from the environment. You can also pass it explic mvn exec:java -DGCP_PROJECT_ID=my-gcp-project ``` +### Run the Parameter Manager sample + +```bash +mvn exec:java -Dexec.mainClass=dev.openfeature.contrib.samples.gcp.ParameterManagerSampleApp +``` + +This uses the same sample flag names and prefix. If you want to evaluate with Parameter Manager, +create the sample parameters in your project under `of-sample-`. + ### Expected output ``` @@ -124,7 +138,9 @@ Express checkout : true ## Step 6 — Clean up ```bash -bash teardown.sh +bash teardown.sh # Deletes secrets (default) +# OR +bash teardown.sh parameter-manager # Deletes Parameter Manager parameters ``` --- @@ -147,8 +163,9 @@ Re-run the sample to see the new value (cache expires after 30 seconds in this s | 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` | +| `FlagNotFoundError` | Secret/parameter doesn't exist | Run `setup.sh` first (or `setup.sh parameter-manager`) | +| `PERMISSION_DENIED` | Missing IAM role | Grant `roles/secretmanager.secretAccessor` or `roles/secretmanager.parameterAccessor` | | `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` | Provider not installed | Run Step 3 | +| `Invalid choice: 'parameter-manager'` | gcloud alpha components missing | Run `gcloud components install alpha` | diff --git a/samples/gcp/pom.xml b/samples/gcp/pom.xml index 664ceef42..9a79bccfb 100644 --- a/samples/gcp/pom.xml +++ b/samples/gcp/pom.xml @@ -23,6 +23,7 @@ ${java.version} ${java.version} UTF-8 + dev.openfeature.contrib.samples.gcp.SecretManagerSampleApp ${env.GCP_PROJECT_ID} @@ -32,7 +33,7 @@ dev.openfeature.contrib.providers gcp - 0.0.1 + 0.0.2 @@ -57,8 +58,7 @@ exec-maven-plugin 3.5.0 - dev.openfeature.contrib.samples.gcpsecretmanager.SecretManagerSampleApp + ${exec.mainClass} GCP_PROJECT_ID diff --git a/samples/gcp/setup.sh b/samples/gcp/setup.sh index f66e9b0e6..fa4ada8e2 100644 --- a/samples/gcp/setup.sh +++ b/samples/gcp/setup.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# setup.sh — Creates the five sample feature-flag secrets in GCP Secret Manager. +# setup.sh — Creates the five sample feature-flag secrets or parameters in GCP. # # Prerequisites: # - gcloud CLI installed and authenticated (gcloud auth application-default login) @@ -8,63 +8,100 @@ # # Usage: # export GCP_PROJECT_ID=my-gcp-project -# bash setup.sh +# bash setup.sh [secret-manager|parameter-manager] +# # Default: secret-manager set -euo pipefail PROJECT="${GCP_PROJECT_ID:?Please set GCP_PROJECT_ID (e.g. export GCP_PROJECT_ID=my-project)}" +BACKEND="${1:-secret-manager}" -echo "Creating sample feature-flag secrets in project: ${PROJECT}" -echo "All secrets are prefixed with 'of-sample-' to match the sample app." +if [[ "$BACKEND" != "secret-manager" && "$BACKEND" != "parameter-manager" ]]; then + echo "ERROR: Backend must be 'secret-manager' or 'parameter-manager'. Got: $BACKEND" + exit 1 +fi + +echo "Creating sample feature-flag ${BACKEND} in project: ${PROJECT}" +echo "All entries are prefixed with 'of-sample-' to match the sample app." echo "" # ──────────────────────────────────────────────────────────────────────────────── -create_secret() { +create_entry() { 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}" \ + if [[ "$BACKEND" == "secret-manager" ]]; then + # Create or update Secret Manager secret + 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 + echo -n "${value}" | gcloud secrets versions add "${full_name}" \ --project="${PROJECT}" \ - --replication-policy=automatic \ + --data-file=- \ --quiet - echo " [CREATED] ${full_name}" - fi + echo " [VERSION] ${full_name} → ${value}" + elif [[ "$BACKEND" == "parameter-manager" ]]; then + # Create or update Parameter Manager parameter + if gcloud parametermanager parameters describe "${full_name}" --location=global --project="${PROJECT}" &>/dev/null 2>&1; then + echo " [EXISTS] ${full_name} — create new version" + gcloud parametermanager parameters versions create VERSION_ID \ + --parameter="${full_name}" \ + --project="${PROJECT}" \ + --location=global \ + --payload-data="${value}" + else + gcloud parametermanager parameters create "${full_name}" \ + --location=global \ + --project="${PROJECT}" \ + --parameter-format=UNFORMATTED \ + --quiet || echo " [WARN] Could not create parameter (may require gcloud alpha components)" + echo " [CREATED] ${full_name}" + + gcloud parametermanager parameters versions create VERSION_ID \ + --parameter="${full_name}" \ + --project="${PROJECT}" \ + --location=global \ + --payload-data="${value}" - # 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}" + + fi + echo " [SET] ${full_name} → ${value}" + fi } # ──────────────────────────────────────────────────────────────────────────────── # Boolean flag: dark UI theme toggle -create_secret "dark-mode" "true" +create_entry "dark-mode" "true" # String flag: hero banner text -create_secret "banner-text" "Welcome! 10% off today only" +create_entry "banner-text" "Welcome! 10% off today only" # Integer flag: maximum items in cart -create_secret "max-cart-items" "25" +create_entry "max-cart-items" "25" # Double flag: discount multiplier (10%) -create_secret "discount-rate" "0.10" +create_entry "discount-rate" "0.10" # Object flag: structured checkout configuration (JSON) -create_secret "checkout-config" '{"paymentMethods":["card","paypal"],"expressCheckout":true,"maxRetries":3}' +create_entry "checkout-config" '{"paymentMethods":["card","paypal"],"expressCheckout":true,"maxRetries":3}' echo "" -echo "✓ All secrets created successfully." +echo "✓ All ${BACKEND} entries created successfully." echo "" echo "Next steps:" echo " 1. Authenticate: gcloud auth application-default login" echo " 2. Run the sample:" echo " cd samples/gcp" -echo " mvn exec:java # GCP_PROJECT_ID must still be set in your shell" -echo " 3. To clean up: bash teardown.sh" +if [[ "$BACKEND" == "secret-manager" ]]; then + echo " mvn exec:java # Uses Secret Manager (default)" +else + echo " mvn exec:java -Dexec.mainClass=dev.openfeature.contrib.samples.gcp.ParameterManagerSampleApp" +fi +echo " 3. To clean up: bash teardown.sh $BACKEND" diff --git a/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/AbstractGcpSampleApp.java b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/AbstractGcpSampleApp.java new file mode 100644 index 000000000..435d77952 --- /dev/null +++ b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/AbstractGcpSampleApp.java @@ -0,0 +1,68 @@ +package dev.openfeature.contrib.samples.gcp; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.Value; + +abstract class AbstractGcpSampleApp { + + static final String PREFIX = "of-sample-"; + + static String resolveProjectId(String[] args) { + if (args.length > 0 && !args[0].isBlank()) { + return args[0]; + } + + String fromEnv = System.getenv("GCP_PROJECT_ID"); + if (fromEnv != null && !fromEnv.isBlank()) { + return fromEnv; + } + + 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; + } + + static void printHeader(String title) { + System.out.println(); + System.out.println("── " + title + " " + "─".repeat(Math.max(0, 50 - title.length()))); + } + + static void evaluateCommonFlags(Client client, MutableContext ctx) { + 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")); + + printHeader("String Flag » banner-text"); + String bannerText = client.getStringValue("banner-text", "Welcome!", ctx); + System.out.println("Value : " + bannerText); + + 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"); + + 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); + + 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); + } + } +} diff --git a/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/ParameterManagerSampleApp.java b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/ParameterManagerSampleApp.java new file mode 100644 index 000000000..43fc1fccc --- /dev/null +++ b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/ParameterManagerSampleApp.java @@ -0,0 +1,51 @@ +package dev.openfeature.contrib.samples.gcp; + +import dev.openfeature.contrib.providers.gcp.GcpParameterManagerProvider; +import dev.openfeature.contrib.providers.gcp.GcpProviderOptions; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import java.time.Duration; + +/** + * Sample application demonstrating the GCP Parameter Manager OpenFeature provider. + * + *

This app evaluates the same five feature flags as the Secret Manager sample, but reads + * values from GCP Parameter Manager parameters with names prefixed by {@code of-sample-}. + */ +public class ParameterManagerSampleApp extends AbstractGcpSampleApp { + + 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("Prefix : " + PREFIX); + System.out.println(); + + GcpProviderOptions options = GcpProviderOptions.builder() + .projectId(projectId) + .namePrefix(PREFIX) + .cacheExpiry(Duration.ofSeconds(30)) + .build(); + + GcpParameterManagerProvider provider = new GcpParameterManagerProvider(options); + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(provider); + Client client = api.getClient(); + + MutableContext ctx = new MutableContext(); + ctx.add("userId", "user-42"); + + evaluateCommonFlags(client, ctx); + + System.out.println(); + System.out.println("======================================================="); + System.out.println(" All flags evaluated successfully."); + System.out.println("======================================================="); + + api.shutdown(); + } +} diff --git a/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java index 838ece1d3..d168d7652 100644 --- a/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java +++ b/samples/gcp/src/main/java/dev/openfeature/contrib/samples/gcp/SecretManagerSampleApp.java @@ -1,7 +1,7 @@ package dev.openfeature.contrib.samples.gcp; import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProvider; -import dev.openfeature.contrib.providers.gcp.GcpSecretManagerProviderOptions; +import dev.openfeature.contrib.providers.gcp.GcpProviderOptions; import dev.openfeature.sdk.Client; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.OpenFeatureAPI; @@ -26,9 +26,7 @@ * mvn exec:java * */ -public class SecretManagerSampleApp { - - private static final String PREFIX = "of-sample-"; +public class SecretManagerSampleApp extends AbstractGcpSampleApp { public static void main(String[] args) throws Exception { String projectId = resolveProjectId(args); @@ -40,57 +38,22 @@ public static void main(String[] args) throws Exception { System.out.println("Prefix : " + PREFIX); System.out.println(); - // Build provider options - GcpSecretManagerProviderOptions options = GcpSecretManagerProviderOptions.builder() + GcpProviderOptions options = GcpProviderOptions.builder() .projectId(projectId) .namePrefix(PREFIX) // secrets are named "of-sample-" - .secretVersion("latest") + .version("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); - } + evaluateCommonFlags(client, ctx); System.out.println(); System.out.println("======================================================="); @@ -99,31 +62,4 @@ public static void main(String[] args) throws Exception { 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/teardown.sh b/samples/gcp/teardown.sh index ec5997e77..b6a51a6a7 100644 --- a/samples/gcp/teardown.sh +++ b/samples/gcp/teardown.sh @@ -1,26 +1,52 @@ #!/usr/bin/env bash -# teardown.sh — Deletes the sample feature-flag secrets from GCP Secret Manager. +# teardown.sh — Deletes the sample feature-flag secrets or parameters from GCP. # # Usage: # export GCP_PROJECT_ID=my-gcp-project -# bash teardown.sh +# bash teardown.sh [secret-manager|parameter-manager] +# # Default: secret-manager set -euo pipefail PROJECT="${GCP_PROJECT_ID:?Please set GCP_PROJECT_ID (e.g. export GCP_PROJECT_ID=my-project)}" +BACKEND="${1:-secret-manager}" -echo "Deleting sample secrets from project: ${PROJECT}" +if [[ "$BACKEND" != "secret-manager" && "$BACKEND" != "parameter-manager" ]]; then + echo "ERROR: Backend must be 'secret-manager' or 'parameter-manager'. Got: $BACKEND" + exit 1 +fi + +echo "Deleting sample ${BACKEND} entries 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)" + if [[ "$BACKEND" == "secret-manager" ]]; then + 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 + elif [[ "$BACKEND" == "parameter-manager" ]]; then + if gcloud parametermanager parameters describe "${full_name}" --location=global --project="${PROJECT}" &>/dev/null 2>&1; then + for v in $(gcloud parametermanager parameters versions list \ + --parameter="${full_name}" --location=global \ + --format="value(name.basename())" 2>/dev/null || true); do + gcloud parametermanager parameters versions delete "$v" \ + --parameter="${full_name}" --location=global --quiet || true + done + + gcloud parametermanager parameters delete "${full_name}" \ + --location=global \ + --project="${PROJECT}" \ + --quiet || echo " [WARN] Could not delete parameter" + echo " [DELETED] ${full_name}" + else + echo " [SKIP] ${full_name} (not found)" + fi fi done echo "" -echo "✓ Cleanup complete." +echo "✓ ${BACKEND} cleanup complete."