Skip to content

Commit

Permalink
feat(flags): Add OpenFeature integration
Browse files Browse the repository at this point in the history
This adds an OpenFeature-based feature flags module along with a built-in provider based on flagd.
  • Loading branch information
jvz committed Nov 7, 2024
1 parent adced8b commit 71b6784
Show file tree
Hide file tree
Showing 29 changed files with 996 additions and 40 deletions.
2 changes: 2 additions & 0 deletions kork-core/kork-core.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies {
api project(":kork-api")
api project(":kork-annotations")
api project(":kork-exceptions")
api project(":kork-flags")
api "org.springframework.boot:spring-boot-autoconfigure"
api "org.springframework.boot:spring-boot-starter-aop"
api "org.springframework.boot:spring-boot-starter-actuator"
Expand All @@ -30,6 +31,7 @@ dependencies {
testImplementation "org.junit.jupiter:junit-jupiter-api"
testImplementation("org.mockito:mockito-core")
testImplementation "org.spockframework:spock-core"
testImplementation "org.springframework:spring-test"
testRuntimeOnly "cglib:cglib-nodep"
testRuntimeOnly "org.objenesis:objenesis"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine"
Expand Down
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.OpenFeatureConfigConfiguration;
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({OpenFeatureConfigConfiguration.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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,43 +16,21 @@

package com.netflix.spinnaker.kork;

import com.netflix.spinnaker.kork.dynamicconfig.TransientConfigConfiguration;
import com.netflix.spinnaker.kork.metrics.SpectatorConfiguration;
import com.netflix.spinnaker.kork.version.ServiceVersion;
import com.netflix.spinnaker.kork.version.SpringPackageVersionResolver;
import com.netflix.spinnaker.kork.version.VersionResolver;
import io.github.resilience4j.circuitbreaker.autoconfigure.CircuitBreakersHealthIndicatorAutoConfiguration;
import io.github.resilience4j.ratelimiter.autoconfigure.RateLimitersHealthIndicatorAutoConfiguration;
import java.util.List;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

@Configuration
@Import({
TransientConfigConfiguration.class,
BootstrapComponents.class,
SpectatorConfiguration.class,
})
@ImportAutoConfiguration(
exclude = {
CircuitBreakersHealthIndicatorAutoConfiguration.class,
RateLimitersHealthIndicatorAutoConfiguration.class
})
public class PlatformComponents {

@Bean
@ConditionalOnMissingBean(ServiceVersion.class)
ServiceVersion serviceVersion(
ApplicationContext applicationContext, List<VersionResolver> versionResolvers) {
return new ServiceVersion(applicationContext, versionResolvers);
}

@Bean
@ConditionalOnMissingBean(SpringPackageVersionResolver.class)
VersionResolver springPackageVersionResolver(ApplicationContext applicationContext) {
return new SpringPackageVersionResolver(applicationContext);
}
}
public class PlatformComponents {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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.flags.EnableOpenFeature;
import dev.openfeature.sdk.OpenFeatureAPI;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

/**
* Configuration to enable OpenFeature and set up a {@link DynamicConfigService} bean based on it.
*/
@Component
@EnableOpenFeature
public class OpenFeatureConfigConfiguration {
@Bean
public OpenFeatureDynamicConfigService openFeatureDynamicConfigService(
OpenFeatureAPI api, Environment environment) {
var fallback = new SpringDynamicConfigService();
fallback.setEnvironment(environment);
return new OpenFeatureDynamicConfigService(api.getClient(), fallback);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* 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 doubleDetails =
features.getDoubleDetails(configName, ((Long) defaultValue).doubleValue());
details = doubleDetails;
var longValue = Long.valueOf(doubleDetails.getValue().longValue());
value = (T) 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* 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 dev.openfeature.sdk.OpenFeatureAPI
import dev.openfeature.sdk.providers.memory.Flag
import dev.openfeature.sdk.providers.memory.InMemoryProvider
import org.springframework.mock.env.MockEnvironment
import spock.lang.Specification
import spock.lang.Subject

class OpenFeatureDynamicConfigServiceSpec extends Specification {
def environment = new MockEnvironment()
.withProperty('flag.enabled', 'true')
.withProperty('some.string', 'hello')
.withProperty('some.int', '42')
def fallbackDynamicConfigService = new SpringDynamicConfigService(environment: environment)
def flags = [
'bool.enabled': Flag.builder()
.variant('on', true)
.variant('off', false)
.defaultVariant('on')
.build(),
'color' : Flag.builder()
.variant('red', 0xff0000)
.variant('green', 0x00ff00)
.variant('blue', 0x0000ff)
.defaultVariant('blue')
.build()
]
def api = OpenFeatureAPI.instance.tap { setProviderAndWait(new InMemoryProvider(flags)) }

@Subject
def service = new OpenFeatureDynamicConfigService(api.client, fallbackDynamicConfigService)

def "can resolve basic flags"() {
expect:
service.isEnabled('flag', false)
service.isEnabled('bool', false)
}

def "can resolve basic configs"() {
expect:
service.getConfig(Integer, 'color', 0) == 0xff
service.getConfig(Integer, 'some.int', 0) == 42
service.getConfig(String, 'some.string', '') == 'hello'
}
}
14 changes: 14 additions & 0 deletions kork-flags-flagd/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# flagd OpenFeature Provider

Spinnaker provides integration with [flagd](https://flagd.dev), an open source feature flag daemon that integrates with [OpenFeature](https://openfeature.dev).
This module sets up a flagd `FeatureProvider` bean as required by `kork-flags` to be used in `OpenFeatureAPI`.
This module supports extensions which can poll for or otherwise handle feature flag configuration update notifications to inform flagd to load the updated flags.

## Configuration

* `flags.providers.flagd.enabled`: toggles whether this provider is enabled.
* `flags.providers.flagd.offline.enabled`: toggles offline mode; if enabled, requires a flag source path to be defined.
* `flags.providers.flagd.offline.flag-source-path`: path to source of feature flags to periodically reload.
* `flags.providers.flagd.sync.enabled`: toggles sync mode; if enabled, sets up an in-process gRPC provider for the flagd `FlagSyncService`. Extensions should emit a `SyncFlagsEvent` event on startup and as needed to inform flagd of the current flags configuration.
* `flags.providers.flagd.sync.socket-path`: if defined, uses a unix socket for gRPC communication (requires platform support for Netty's epoll API)
* `flags.providers.flagd.sync.port`: specifies the local port to use for the gRPC service (default is 8015). Note that this port binding is made to a loopback interface.
23 changes: 23 additions & 0 deletions kork-flags-flagd/kork-flags-flagd.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* 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.
*/

apply from: "$rootDir/gradle/kotlin.gradle"

dependencies {
api platform(project(':spinnaker-dependencies'))
api project(':kork-flags')
implementation 'dev.openfeature.contrib.providers:flagd'
}
Loading

0 comments on commit 71b6784

Please sign in to comment.