-
Notifications
You must be signed in to change notification settings - Fork 173
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(flags): Add OpenFeature integration
This adds an OpenFeature-based feature flags module along with a built-in provider based on flagd.
- Loading branch information
Showing
26 changed files
with
947 additions
and
40 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
44 changes: 44 additions & 0 deletions
44
kork-core/src/main/java/com/netflix/spinnaker/kork/BootstrapComponents.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
/* | ||
* Copyright 2024 Apple, Inc. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package com.netflix.spinnaker.kork; | ||
|
||
import com.netflix.spinnaker.kork.dynamicconfig.OpenFeatureImportBeanDefinitionRegistrar; | ||
import com.netflix.spinnaker.kork.dynamicconfig.TransientConfigConfiguration; | ||
import com.netflix.spinnaker.kork.version.ServiceVersion; | ||
import com.netflix.spinnaker.kork.version.SpringPackageVersionResolver; | ||
import com.netflix.spinnaker.kork.version.VersionResolver; | ||
import java.util.List; | ||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; | ||
import org.springframework.context.ApplicationContext; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Import; | ||
|
||
@Import({OpenFeatureImportBeanDefinitionRegistrar.class, TransientConfigConfiguration.class}) | ||
public class BootstrapComponents { | ||
@Bean | ||
@ConditionalOnMissingBean(VersionResolver.class) | ||
public static VersionResolver versionResolver(ApplicationContext applicationContext) { | ||
return new SpringPackageVersionResolver(applicationContext); | ||
} | ||
|
||
@Bean | ||
@ConditionalOnMissingBean(ServiceVersion.class) | ||
public static ServiceVersion serviceVersion( | ||
ApplicationContext applicationContext, List<VersionResolver> versionResolvers) { | ||
return new ServiceVersion(applicationContext, versionResolvers); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
118 changes: 118 additions & 0 deletions
118
...c/main/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
/* | ||
* Copyright 2024 Apple, Inc. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
* | ||
*/ | ||
|
||
package com.netflix.spinnaker.kork.dynamicconfig; | ||
|
||
import com.netflix.spinnaker.kork.annotations.NonnullByDefault; | ||
import dev.openfeature.sdk.ErrorCode; | ||
import dev.openfeature.sdk.EvaluationContext; | ||
import dev.openfeature.sdk.Features; | ||
import dev.openfeature.sdk.FlagEvaluationDetails; | ||
import dev.openfeature.sdk.MutableContext; | ||
import dev.openfeature.sdk.Value; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.log4j.Log4j2; | ||
|
||
/** | ||
* Dynamic configuration service using {@linkplain dev.openfeature.sdk.OpenFeatureAPI OpenFeature} | ||
* as the primary lookup mechanism with the ability to fall back to using another {@link | ||
* DynamicConfigService}. | ||
*/ | ||
@Log4j2 | ||
@NonnullByDefault | ||
@RequiredArgsConstructor | ||
public class OpenFeatureDynamicConfigService implements DynamicConfigService { | ||
private final Features features; | ||
private final DynamicConfigService fallbackDynamicConfigService; | ||
|
||
@Override | ||
@SuppressWarnings("unchecked") | ||
public <T> T getConfig(Class<T> configType, String configName, T defaultValue) { | ||
FlagEvaluationDetails<?> details; | ||
T value; | ||
if (configType == Boolean.class) { | ||
details = features.getBooleanDetails(configName, (Boolean) defaultValue); | ||
value = (T) details.getValue(); | ||
} else if (configType == Integer.class) { | ||
details = features.getIntegerDetails(configName, (Integer) defaultValue); | ||
value = (T) details.getValue(); | ||
} else if (configType == Long.class) { | ||
// TODO(jvz): https://github.com/open-feature/java-sdk/issues/501 | ||
var intDetails = features.getIntegerDetails(configName, ((Long) defaultValue).intValue()); | ||
details = intDetails; | ||
value = (T) Long.valueOf(intDetails.getValue().longValue()); | ||
} else if (configType == Double.class) { | ||
details = features.getDoubleDetails(configName, (Double) defaultValue); | ||
value = (T) details.getValue(); | ||
} else if (configType == String.class) { | ||
details = features.getStringDetails(configName, (String) defaultValue); | ||
value = (T) details.getValue(); | ||
} else { | ||
var objectDetails = features.getObjectDetails(configName, Value.objectToValue(defaultValue)); | ||
details = objectDetails; | ||
value = (T) objectDetails.getValue().asObject(); | ||
} | ||
var errorCode = details.getErrorCode(); | ||
if (errorCode == ErrorCode.FLAG_NOT_FOUND) { | ||
return fallbackDynamicConfigService.getConfig(configType, configName, defaultValue); | ||
} | ||
if (errorCode != null) { | ||
log.warn("Unable to resolve configuration key '{}': {}", configName, details); | ||
} | ||
return value; | ||
} | ||
|
||
@Override | ||
public boolean isEnabled(String flagName, boolean defaultValue) { | ||
var details = features.getBooleanDetails(flagPropertyName(flagName), defaultValue); | ||
var errorCode = details.getErrorCode(); | ||
if (errorCode == ErrorCode.FLAG_NOT_FOUND) { | ||
return fallbackDynamicConfigService.isEnabled(flagName, defaultValue); | ||
} | ||
if (errorCode != null) { | ||
log.warn("Unable to resolve configuration flag '{}': {}", flagName, details); | ||
} | ||
return details.getValue(); | ||
} | ||
|
||
@Override | ||
public boolean isEnabled(String flagName, boolean defaultValue, ScopedCriteria criteria) { | ||
var context = convertCriteria(criteria); | ||
var details = features.getBooleanDetails(flagPropertyName(flagName), defaultValue, context); | ||
var errorCode = details.getErrorCode(); | ||
if (errorCode == ErrorCode.FLAG_NOT_FOUND) { | ||
return fallbackDynamicConfigService.isEnabled(flagName, defaultValue, criteria); | ||
} | ||
if (errorCode != null) { | ||
log.warn( | ||
"Unable to resolve configuration flag '{}' with {}: {}", flagName, criteria, details); | ||
} | ||
return details.getValue(); | ||
} | ||
|
||
private static String flagPropertyName(String flagName) { | ||
return flagName.endsWith(".enabled") ? flagName : flagName + ".enabled"; | ||
} | ||
|
||
private static EvaluationContext convertCriteria(ScopedCriteria criteria) { | ||
return new MutableContext() | ||
.add("region", criteria.region) | ||
.add("account", criteria.account) | ||
.add("cloudProvider", criteria.cloudProvider) | ||
.add("application", criteria.application); | ||
} | ||
} |
84 changes: 84 additions & 0 deletions
84
...va/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureImportBeanDefinitionRegistrar.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
/* | ||
* Copyright 2024 Apple, Inc. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
* | ||
*/ | ||
|
||
package com.netflix.spinnaker.kork.dynamicconfig; | ||
|
||
import com.netflix.spinnaker.kork.annotations.NonnullByDefault; | ||
import dev.openfeature.sdk.Features; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.log4j.Log4j2; | ||
import org.springframework.beans.factory.BeanFactory; | ||
import org.springframework.beans.factory.config.RuntimeBeanReference; | ||
import org.springframework.beans.factory.support.BeanDefinitionRegistry; | ||
import org.springframework.beans.factory.support.BeanNameGenerator; | ||
import org.springframework.beans.factory.support.GenericBeanDefinition; | ||
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; | ||
import org.springframework.core.env.Environment; | ||
import org.springframework.core.type.AnnotationMetadata; | ||
|
||
/** Handles creation of an OpenFeature-based {@link DynamicConfigService} bean. */ | ||
@Log4j2 | ||
@NonnullByDefault | ||
@RequiredArgsConstructor | ||
public class OpenFeatureImportBeanDefinitionRegistrar implements ImportBeanDefinitionRegistrar { | ||
private final Environment environment; | ||
private final BeanFactory beanFactory; | ||
|
||
@Override | ||
public void registerBeanDefinitions( | ||
AnnotationMetadata importingClassMetadata, | ||
BeanDefinitionRegistry registry, | ||
BeanNameGenerator importBeanNameGenerator) { | ||
var enableOpenFeatureFlags = | ||
environment.getProperty("flags.providers.openfeature.enabled", Boolean.class, true); | ||
if (!enableOpenFeatureFlags) { | ||
// no need to configure anything; let default SpringDynamicConfigService bean be created | ||
// elsewhere | ||
return; | ||
} | ||
var features = beanFactory.getBeanProvider(Features.class).getIfUnique(); | ||
if (features == null) { | ||
return; | ||
} | ||
|
||
var dynamicConfigServiceFallbackBean = new GenericBeanDefinition(); | ||
var enableSpringFlags = | ||
environment.getProperty("flags.providers.spring.enabled", Boolean.class, true); | ||
// not trying to auto-inject a dependent bean anywhere | ||
dynamicConfigServiceFallbackBean.setAutowireCandidate(false); | ||
if (enableSpringFlags) { | ||
dynamicConfigServiceFallbackBean.setBeanClass(SpringDynamicConfigService.class); | ||
} else { | ||
dynamicConfigServiceFallbackBean.setInstanceSupplier(() -> DynamicConfigService.NOOP); | ||
} | ||
|
||
var fallbackBeanName = | ||
importBeanNameGenerator.generateBeanName(dynamicConfigServiceFallbackBean, registry); | ||
registry.registerBeanDefinition(fallbackBeanName, dynamicConfigServiceFallbackBean); | ||
log.debug("Registered bean '{}': {}", fallbackBeanName, dynamicConfigServiceFallbackBean); | ||
|
||
var dynamicConfigServiceBean = new GenericBeanDefinition(); | ||
dynamicConfigServiceBean.setPrimary(true); | ||
dynamicConfigServiceBean.setBeanClass(OpenFeatureDynamicConfigService.class); | ||
var args = dynamicConfigServiceBean.getConstructorArgumentValues(); | ||
args.addGenericArgumentValue(features); | ||
args.addGenericArgumentValue(new RuntimeBeanReference(fallbackBeanName)); | ||
var beanName = importBeanNameGenerator.generateBeanName(dynamicConfigServiceBean, registry); | ||
registry.registerBeanDefinition(beanName, dynamicConfigServiceBean); | ||
log.debug("Registered bean '{}': {}", beanName, dynamicConfigServiceBean); | ||
} | ||
} |
81 changes: 81 additions & 0 deletions
81
...st/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigServiceTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
/* | ||
* Copyright 2024 Apple, Inc. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
* | ||
*/ | ||
|
||
package com.netflix.spinnaker.kork.dynamicconfig; | ||
|
||
import static org.junit.jupiter.api.Assertions.*; | ||
|
||
import dev.openfeature.sdk.OpenFeatureAPI; | ||
import dev.openfeature.sdk.providers.memory.Flag; | ||
import dev.openfeature.sdk.providers.memory.InMemoryProvider; | ||
import java.util.HashMap; | ||
import java.util.Map; | ||
import org.junit.jupiter.api.BeforeEach; | ||
import org.junit.jupiter.api.Test; | ||
|
||
class OpenFeatureDynamicConfigServiceTest { | ||
OpenFeatureDynamicConfigService service; | ||
|
||
@BeforeEach | ||
void setUp() { | ||
Map<String, Flag<?>> flags = new HashMap<>(); | ||
flags.put( | ||
"flag.enabled", | ||
Flag.<Boolean>builder() | ||
.variant("on", true) | ||
.variant("off", false) | ||
.defaultVariant("on") | ||
.build()); | ||
// TODO(jvz): https://github.com/open-feature/java-sdk/issues/501 | ||
// this should be encoded as a Flag<Long>, but there is no Long-based API available yet | ||
flags.put( | ||
"some.number", | ||
Flag.builder() | ||
.variant("default", 10000) | ||
.variant("alternative", 20000) | ||
.defaultVariant("default") | ||
.build()); | ||
flags.put( | ||
"config", | ||
Flag.<String>builder() | ||
.variant("alpha", "1.0") | ||
.variant("beta", "2.0") | ||
.variant("gamma", "3.0") | ||
.defaultVariant("beta") | ||
.build()); | ||
OpenFeatureAPI api = OpenFeatureAPI.getInstance(); | ||
api.setProviderAndWait(new InMemoryProvider(flags)); | ||
service = new OpenFeatureDynamicConfigService(api.getClient(), DynamicConfigService.NOOP); | ||
} | ||
|
||
@Test | ||
void canResolveBooleanFlags() { | ||
assertTrue(service.isEnabled("flag", false)); | ||
assertTrue(service.isEnabled("flag.enabled", false)); | ||
assertFalse(service.isEnabled("flag.", false)); | ||
} | ||
|
||
@Test | ||
void canResolveLongData() { | ||
assertEquals(10000L, service.getConfig(Long.class, "some.number", 42L)); | ||
} | ||
|
||
@Test | ||
void canResolveStringData() { | ||
assertEquals("2.0", service.getConfig(String.class, "config", "0.0")); | ||
} | ||
} |
Oops, something went wrong.