From 59afae96090c6686e729f996fbc2999eaae35271 Mon Sep 17 00:00:00 2001 From: Matt Sicker Date: Thu, 7 Nov 2024 14:31:10 -0600 Subject: [PATCH] feat(flags): Add OpenFeature integration This adds an OpenFeature-based feature flags module along with a built-in provider based on flagd. --- kork-core/kork-core.gradle | 2 + .../spinnaker/kork/BootstrapComponents.java | 44 +++++++ .../spinnaker/kork/PlatformComponents.java | 26 +--- .../OpenFeatureDynamicConfigService.java | 118 ++++++++++++++++++ ...nFeatureImportBeanDefinitionRegistrar.java | 84 +++++++++++++ .../OpenFeatureDynamicConfigServiceTest.java | 81 ++++++++++++ kork-flags-flagd/README.md | 14 +++ kork-flags-flagd/kork-flags-flagd.gradle | 23 ++++ .../kork/flags/flagd/FlagSyncServer.kt | 53 ++++++++ .../kork/flags/flagd/FlagSyncService.kt | 88 +++++++++++++ .../flagd/FlagdOpenFeatureInitializer.kt | 64 ++++++++++ .../kork/flags/flagd/FlagdProperties.kt | 37 ++++++ .../kork/flags/flagd/SyncFlagsEvent.kt | 28 +++++ .../main/resources/META-INF/spring.factories | 2 + kork-flags-git/kork-flags-git.gradle | 25 ++++ .../git/GitFlagsOpenFeatureInitializer.kt | 45 +++++++ .../flags/flagd/git/GitFlagsProperties.kt | 39 ++++++ .../flags/flagd/git/GitRepositoryPoller.kt | 82 ++++++++++++ .../main/resources/META-INF/spring.factories | 2 + kork-flags/kork-flags.gradle | 23 ++++ .../kork/flags/OpenFeatureCoreInitializer.kt | 51 ++++++++ .../spinnaker/kork/flags/extensions.kt | 23 ++++ .../main/resources/META-INF/spring.factories | 2 + .../config/PluginsAutoConfiguration.java | 22 +--- settings.gradle | 3 + .../spinnaker-dependencies.gradle | 6 + 26 files changed, 947 insertions(+), 40 deletions(-) create mode 100644 kork-core/src/main/java/com/netflix/spinnaker/kork/BootstrapComponents.java create mode 100644 kork-core/src/main/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigService.java create mode 100644 kork-core/src/main/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureImportBeanDefinitionRegistrar.java create mode 100644 kork-core/src/test/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigServiceTest.java create mode 100644 kork-flags-flagd/README.md create mode 100644 kork-flags-flagd/kork-flags-flagd.gradle create mode 100644 kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagSyncServer.kt create mode 100644 kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagSyncService.kt create mode 100644 kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdOpenFeatureInitializer.kt create mode 100644 kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdProperties.kt create mode 100644 kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/SyncFlagsEvent.kt create mode 100644 kork-flags-flagd/src/main/resources/META-INF/spring.factories create mode 100644 kork-flags-git/kork-flags-git.gradle create mode 100644 kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitFlagsOpenFeatureInitializer.kt create mode 100644 kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitFlagsProperties.kt create mode 100644 kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitRepositoryPoller.kt create mode 100644 kork-flags-git/src/main/resources/META-INF/spring.factories create mode 100644 kork-flags/kork-flags.gradle create mode 100644 kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureCoreInitializer.kt create mode 100644 kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/extensions.kt create mode 100644 kork-flags/src/main/resources/META-INF/spring.factories diff --git a/kork-core/kork-core.gradle b/kork-core/kork-core.gradle index 08bca1a4e..7e8620110 100644 --- a/kork-core/kork-core.gradle +++ b/kork-core/kork-core.gradle @@ -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" @@ -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" diff --git a/kork-core/src/main/java/com/netflix/spinnaker/kork/BootstrapComponents.java b/kork-core/src/main/java/com/netflix/spinnaker/kork/BootstrapComponents.java new file mode 100644 index 000000000..b764711f0 --- /dev/null +++ b/kork-core/src/main/java/com/netflix/spinnaker/kork/BootstrapComponents.java @@ -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 versionResolvers) { + return new ServiceVersion(applicationContext, versionResolvers); + } +} diff --git a/kork-core/src/main/java/com/netflix/spinnaker/kork/PlatformComponents.java b/kork-core/src/main/java/com/netflix/spinnaker/kork/PlatformComponents.java index 282cde377..1c036f795 100644 --- a/kork-core/src/main/java/com/netflix/spinnaker/kork/PlatformComponents.java +++ b/kork-core/src/main/java/com/netflix/spinnaker/kork/PlatformComponents.java @@ -16,24 +16,16 @@ 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( @@ -41,18 +33,4 @@ CircuitBreakersHealthIndicatorAutoConfiguration.class, RateLimitersHealthIndicatorAutoConfiguration.class }) -public class PlatformComponents { - - @Bean - @ConditionalOnMissingBean(ServiceVersion.class) - ServiceVersion serviceVersion( - ApplicationContext applicationContext, List versionResolvers) { - return new ServiceVersion(applicationContext, versionResolvers); - } - - @Bean - @ConditionalOnMissingBean(SpringPackageVersionResolver.class) - VersionResolver springPackageVersionResolver(ApplicationContext applicationContext) { - return new SpringPackageVersionResolver(applicationContext); - } -} +public class PlatformComponents {} diff --git a/kork-core/src/main/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigService.java b/kork-core/src/main/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigService.java new file mode 100644 index 000000000..58125d7c4 --- /dev/null +++ b/kork-core/src/main/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigService.java @@ -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 getConfig(Class 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); + } +} diff --git a/kork-core/src/main/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureImportBeanDefinitionRegistrar.java b/kork-core/src/main/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureImportBeanDefinitionRegistrar.java new file mode 100644 index 000000000..3425a1138 --- /dev/null +++ b/kork-core/src/main/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureImportBeanDefinitionRegistrar.java @@ -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); + } +} diff --git a/kork-core/src/test/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigServiceTest.java b/kork-core/src/test/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigServiceTest.java new file mode 100644 index 000000000..44306f324 --- /dev/null +++ b/kork-core/src/test/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigServiceTest.java @@ -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> flags = new HashMap<>(); + flags.put( + "flag.enabled", + Flag.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, 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.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")); + } +} diff --git a/kork-flags-flagd/README.md b/kork-flags-flagd/README.md new file mode 100644 index 000000000..4a511b39f --- /dev/null +++ b/kork-flags-flagd/README.md @@ -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. diff --git a/kork-flags-flagd/kork-flags-flagd.gradle b/kork-flags-flagd/kork-flags-flagd.gradle new file mode 100644 index 000000000..9f0680e0a --- /dev/null +++ b/kork-flags-flagd/kork-flags-flagd.gradle @@ -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' +} diff --git a/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagSyncServer.kt b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagSyncServer.kt new file mode 100644 index 000000000..110d8bf7a --- /dev/null +++ b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagSyncServer.kt @@ -0,0 +1,53 @@ +/* + * 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.flags.flagd + +import io.grpc.Server +import io.grpc.netty.NettyServerBuilder +import io.netty.channel.epoll.EpollEventLoopGroup +import io.netty.channel.epoll.EpollServerDomainSocketChannel +import io.netty.channel.unix.DomainSocketAddress +import org.springframework.beans.factory.DisposableBean +import org.springframework.beans.factory.InitializingBean +import java.net.InetAddress +import java.net.InetSocketAddress + +/** + * Bean to handle the lifecycle of the flag sync service in a gRPC server. + */ +class FlagSyncServer(properties: FlagdProperties, service: FlagSyncService) : InitializingBean, DisposableBean { + private val server: Server + + init { + val builder = properties.sync.socketPath?.let { + NettyServerBuilder.forAddress(DomainSocketAddress(it.toString())) + .channelType(EpollServerDomainSocketChannel::class.java) + .bossEventLoopGroup(EpollEventLoopGroup()) + .workerEventLoopGroup(EpollEventLoopGroup()) + } ?: NettyServerBuilder.forAddress(InetSocketAddress(InetAddress.getLoopbackAddress(), properties.sync.port)) + server = builder.addService(service).build() + } + + override fun afterPropertiesSet() { + server.start() + } + + override fun destroy() { + server.shutdown() + } +} diff --git a/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagSyncService.kt b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagSyncService.kt new file mode 100644 index 000000000..b35de6d8a --- /dev/null +++ b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagSyncService.kt @@ -0,0 +1,88 @@ +/* + * 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.flags.flagd + +import com.google.protobuf.Struct +import dev.openfeature.flagd.grpc.sync.FlagSyncServiceGrpc.FlagSyncServiceImplBase +import dev.openfeature.flagd.grpc.sync.Sync.FetchAllFlagsRequest +import dev.openfeature.flagd.grpc.sync.Sync.FetchAllFlagsResponse +import dev.openfeature.flagd.grpc.sync.Sync.GetMetadataRequest +import dev.openfeature.flagd.grpc.sync.Sync.GetMetadataResponse +import dev.openfeature.flagd.grpc.sync.Sync.SyncFlagsRequest +import dev.openfeature.flagd.grpc.sync.Sync.SyncFlagsResponse +import io.grpc.stub.StreamObserver +import org.springframework.beans.factory.DisposableBean +import org.springframework.context.ApplicationListener +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference + +/** + * In-process implementation of the flag sync service. Flagd clients connect to this to keep track of + * flag data updates. Flag synchronization modules should publish a [SyncFlagsEvent] to notify observers + * of a flag configuration sync. + */ +class FlagSyncService : FlagSyncServiceImplBase(), DisposableBean, ApplicationListener { + private val syncFlagsObservers = CopyOnWriteArrayList>() + private val currentFlagConfiguration = AtomicReference() + + override fun onApplicationEvent(event: SyncFlagsEvent) { + val flagConfiguration = event.flagConfiguration + currentFlagConfiguration.set(flagConfiguration) + val response = SyncFlagsResponse.newBuilder().setFlagConfiguration(flagConfiguration).build() + syncFlagsObservers.forEach { it.onNext(response) } + } + + override fun syncFlags(request: SyncFlagsRequest, responseObserver: StreamObserver) { + val flagConfiguration = currentFlagConfiguration.get() + if (flagConfiguration != null) { + // start with cached config + val response = SyncFlagsResponse.newBuilder().setFlagConfiguration(flagConfiguration).build() + responseObserver.onNext(response) + } + syncFlagsObservers += responseObserver + } + + override fun fetchAllFlags( + request: FetchAllFlagsRequest, + responseObserver: StreamObserver + ) { + val response = FetchAllFlagsResponse.newBuilder() + .setFlagConfiguration(currentFlagConfiguration.get()) + .build() + responseObserver.onNext(response) + responseObserver.onCompleted() + } + + override fun getMetadata( + request: GetMetadataRequest, + responseObserver: StreamObserver + ) { + // no relevant metadata at the moment + val metadata = Struct.getDefaultInstance() + val response = GetMetadataResponse.newBuilder() + .setMetadata(metadata) + .build() + responseObserver.onNext(response) + responseObserver.onCompleted() + } + + override fun destroy() { + syncFlagsObservers.forEach { it.onCompleted() } + syncFlagsObservers.clear() + } +} diff --git a/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdOpenFeatureInitializer.kt b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdOpenFeatureInitializer.kt new file mode 100644 index 000000000..2ef99352a --- /dev/null +++ b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdOpenFeatureInitializer.kt @@ -0,0 +1,64 @@ +/* + * 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.flags.flagd + +import com.netflix.spinnaker.kork.flags.bindOrCreate +import dev.openfeature.contrib.providers.flagd.Config.Resolver +import dev.openfeature.contrib.providers.flagd.FlagdOptions +import dev.openfeature.contrib.providers.flagd.FlagdProvider +import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException +import org.springframework.context.ApplicationContextInitializer +import org.springframework.context.support.GenericApplicationContext +import org.springframework.context.support.beans + +class FlagdOpenFeatureInitializer : ApplicationContextInitializer { + override fun initialize(context: GenericApplicationContext) { + beans { + val properties = env.bindOrCreate("flags.providers.flagd") + if (properties.enabled) { + bean { properties } + + val builder = FlagdOptions.builder().resolverType(Resolver.IN_PROCESS) + if (properties.sync.enabled) { + val socketPath = properties.sync.socketPath?.toString() + if (socketPath != null) { + builder.socketPath(socketPath) + } else { + builder.port(properties.sync.port) + } + bean() + bean() + } else if (properties.offline.enabled) { + val flagSourcePath = requireNotNull(properties.offline.flagSourcePath) { + "The property 'flags.provider.flagd.offline.enable' is true, but no value defined for 'flags.provider.flagd.offline.flag-source-path'" + } + builder.offlineFlagSourcePath(flagSourcePath.toString()) + } else { + throw InvalidConfigurationPropertyValueException( + "flags.provider.flagd.enabled", + true, + "No flagd source configured" + ) + } + bean { + FlagdProvider(builder.build()) + } + } + }.initialize(context) + } +} diff --git a/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdProperties.kt b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdProperties.kt new file mode 100644 index 000000000..0b4e9b608 --- /dev/null +++ b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdProperties.kt @@ -0,0 +1,37 @@ +/* + * 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.flags.flagd + +import org.springframework.boot.context.properties.bind.DefaultValue +import java.nio.file.Path + +data class FlagdProperties( + @DefaultValue("false") val enabled: Boolean, + @DefaultValue val sync: Sync, + @DefaultValue val offline: Offline, +) { + data class Sync( + @DefaultValue("false") val enabled: Boolean, + val socketPath: Path?, + @DefaultValue("8015") val port: Int, + ) + data class Offline( + @DefaultValue("false") val enabled: Boolean, + val flagSourcePath: Path?, + ) +} diff --git a/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/SyncFlagsEvent.kt b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/SyncFlagsEvent.kt new file mode 100644 index 000000000..c944cbcbf --- /dev/null +++ b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/SyncFlagsEvent.kt @@ -0,0 +1,28 @@ +/* + * 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.flags.flagd + +import org.springframework.context.ApplicationEvent + +/** + * Event emitted by flagd sync providers when a flag configuration is loaded. + * + * @param source where the flag configuration was loaded + * @param flagConfiguration the contents of the flag configuration + */ +class SyncFlagsEvent(source: Any, val flagConfiguration: String) : ApplicationEvent(source) diff --git a/kork-flags-flagd/src/main/resources/META-INF/spring.factories b/kork-flags-flagd/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..5c8def81c --- /dev/null +++ b/kork-flags-flagd/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.context.ApplicationContextInitializer=\ + com.netflix.spinnaker.kork.flags.flagd.FlagdOpenFeatureInitializer diff --git a/kork-flags-git/kork-flags-git.gradle b/kork-flags-git/kork-flags-git.gradle new file mode 100644 index 000000000..1320187bc --- /dev/null +++ b/kork-flags-git/kork-flags-git.gradle @@ -0,0 +1,25 @@ +/* + * 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')) + + implementation project(':kork-flags-flagd') + implementation 'org.apache.logging.log4j:log4j-api-kotlin' + implementation 'org.eclipse.jgit:org.eclipse.jgit' +} diff --git a/kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitFlagsOpenFeatureInitializer.kt b/kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitFlagsOpenFeatureInitializer.kt new file mode 100644 index 000000000..ec1c1cf5e --- /dev/null +++ b/kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitFlagsOpenFeatureInitializer.kt @@ -0,0 +1,45 @@ +/* + * 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.flags.flagd.git + +import com.netflix.spinnaker.kork.flags.bindOrCreate +import org.springframework.context.ApplicationContextInitializer +import org.springframework.context.support.GenericApplicationContext +import org.springframework.context.support.beans + +/** + * Handles initialization of a JGit-based feature flag configuration source. Requires that the property + * `flags.providers.flagd.git.enabled` be set to `true`. + */ +class GitFlagsOpenFeatureInitializer : ApplicationContextInitializer { + override fun initialize(context: GenericApplicationContext) { + beans { + val properties = env.bindOrCreate("flags.providers.flagd.git") + if (properties.enabled) { + requireNotNull(properties.repositoryUri) { + "The property 'flags.providers.flagd.git.enabled' is true but no repository uri is set" + } + requireNotNull(properties.apiToken) { + "The property 'flags.providers.flagd.git.enabled' is true but no api token is set" + } + bean { properties } + bean() + } + }.initialize(context) + } +} diff --git a/kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitFlagsProperties.kt b/kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitFlagsProperties.kt new file mode 100644 index 000000000..16ef57c88 --- /dev/null +++ b/kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitFlagsProperties.kt @@ -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.flags.flagd.git + +import java.time.Duration + +/** + * Configuration properties related to using git polling for a source of flagd data. + * + * @property enabled toggles this flag source backend + * @property repositoryUri git repository URI to clone from (required) + * @property branch branch to check out (default of HEAD) + * @property apiToken authentication token to use (required) + * @property pollingInterval how often to poll git for updates (default of every 5 minutes) + * @property flagsSource name of file in the git repository to load feature flags from (default is `spinnaker.json`) + */ +class GitFlagsProperties { + var enabled: Boolean = false + var repositoryUri: String? = null + var branch: String = "HEAD" + var apiToken: String? = null + var pollingInterval: Duration = Duration.ofMinutes(5) + var flagsSource: String = "spinnaker.json" +} diff --git a/kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitRepositoryPoller.kt b/kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitRepositoryPoller.kt new file mode 100644 index 000000000..aaf99c9f6 --- /dev/null +++ b/kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitRepositoryPoller.kt @@ -0,0 +1,82 @@ +/* + * 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.flags.flagd.git + +import com.netflix.spinnaker.kork.flags.flagd.SyncFlagsEvent +import org.apache.logging.log4j.kotlin.Logging +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.MergeCommand +import org.eclipse.jgit.api.MergeResult +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider +import org.springframework.beans.factory.DisposableBean +import org.springframework.beans.factory.InitializingBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.scheduling.annotation.Async +import org.springframework.scheduling.annotation.Scheduled +import java.io.BufferedReader +import java.nio.file.Files +import kotlin.io.path.bufferedReader +import kotlin.io.path.div + +open class GitRepositoryPoller( + private val properties: GitFlagsProperties, + private val applicationEventPublisher: ApplicationEventPublisher, +) : InitializingBean, DisposableBean { + private val directory = Files.createTempDirectory("GitRepositoryPoller") + private lateinit var git: Git + + override fun afterPropertiesSet() { + git = Git.cloneRepository() + .setURI(properties.repositoryUri) + .setBranch(properties.branch) + .setDepth(1) + .setDirectory(directory.toFile()) + .setCredentialsProvider(UsernamePasswordCredentialsProvider("token", properties.apiToken)) + .call() + syncFlags() + } + + @Async + @Scheduled(fixedRateString = "\${flags.providers.flagd.git.polling-interval:PT5M}") + open fun checkForUpdates() { + val result = git.pull().setFastForward(MergeCommand.FastForwardMode.FF_ONLY).call() + if (result.isSuccessful) { + when (result.mergeResult.mergeStatus) { + MergeResult.MergeStatus.ALREADY_UP_TO_DATE -> return + MergeResult.MergeStatus.FAST_FORWARD -> syncFlags() + else -> logger.warn { "Unsuspected merge status: ${result.mergeResult.mergeStatus}" } + } + } else { + // TODO: may want an option to re-clone on merge errors? + logger.warn { "Unsuccessful attempt to pull from git: $result" } + } + } + + private fun syncFlags() { + val file = directory / properties.flagsSource + val flagConfiguration = file.bufferedReader().use(BufferedReader::readText) + val event = SyncFlagsEvent(this, flagConfiguration) + applicationEventPublisher.publishEvent(event) + } + + override fun destroy() { + git.close() + } + + companion object : Logging +} diff --git a/kork-flags-git/src/main/resources/META-INF/spring.factories b/kork-flags-git/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..d62c8f4b9 --- /dev/null +++ b/kork-flags-git/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.context.ApplicationContextInitializer=\ + com.netflix.spinnaker.kork.flags.flagd.git.GitFlagsOpenFeatureInitializer diff --git a/kork-flags/kork-flags.gradle b/kork-flags/kork-flags.gradle new file mode 100644 index 000000000..070722f67 --- /dev/null +++ b/kork-flags/kork-flags.gradle @@ -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 'dev.openfeature:sdk' + api 'org.springframework.boot:spring-boot-autoconfigure' +} diff --git a/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureCoreInitializer.kt b/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureCoreInitializer.kt new file mode 100644 index 000000000..b9d2d669f --- /dev/null +++ b/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureCoreInitializer.kt @@ -0,0 +1,51 @@ +/* + * 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.flags + +import dev.openfeature.sdk.FeatureProvider +import dev.openfeature.sdk.Features +import dev.openfeature.sdk.MutableContext +import dev.openfeature.sdk.OpenFeatureAPI +import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator +import dev.openfeature.sdk.providers.memory.InMemoryProvider +import org.springframework.context.ApplicationContextInitializer +import org.springframework.context.support.GenericApplicationContext +import org.springframework.context.support.beans +import org.springframework.core.env.get + +/** + * Handles initialization of an [OpenFeatureAPI] and [Features] bean using a configured [FeatureProvider] + * bean if available. Other flags modules should register their beans via an [ApplicationContextInitializer]. + */ +class OpenFeatureCoreInitializer : ApplicationContextInitializer { + override fun initialize(context: GenericApplicationContext) { + beans { + bean { + val provider = provider().ifUnique ?: InMemoryProvider(emptyMap()) + OpenFeatureAPI.getInstance().also { api -> + api.setProviderAndWait(provider) + api.evaluationContext = MutableContext().add("app", env["spring.application.name"]) + api.transactionContextPropagator = ThreadLocalTransactionContextPropagator() + } + } + bean { + ref().client + } + }.initialize(context) + } +} diff --git a/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/extensions.kt b/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/extensions.kt new file mode 100644 index 000000000..452965e09 --- /dev/null +++ b/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/extensions.kt @@ -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. + * + */ + +package com.netflix.spinnaker.kork.flags + +import org.springframework.boot.context.properties.bind.Binder +import org.springframework.core.env.Environment + +inline fun Environment.bindOrCreate(name: String): T = Binder.get(this).bindOrCreate(name, T::class.java) diff --git a/kork-flags/src/main/resources/META-INF/spring.factories b/kork-flags/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..fc8f0e779 --- /dev/null +++ b/kork-flags/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.context.ApplicationContextInitializer=\ + com.netflix.spinnaker.kork.flags.OpenFeatureCoreInitializer diff --git a/kork-plugins/src/main/java/com/netflix/spinnaker/config/PluginsAutoConfiguration.java b/kork-plugins/src/main/java/com/netflix/spinnaker/config/PluginsAutoConfiguration.java index a43e40ac1..b68ef2835 100644 --- a/kork-plugins/src/main/java/com/netflix/spinnaker/config/PluginsAutoConfiguration.java +++ b/kork-plugins/src/main/java/com/netflix/spinnaker/config/PluginsAutoConfiguration.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.netflix.spectator.api.Registry; import com.netflix.spinnaker.config.PluginsConfigurationProperties.PluginRepositoryProperties; +import com.netflix.spinnaker.kork.BootstrapComponents; import com.netflix.spinnaker.kork.annotations.Beta; import com.netflix.spinnaker.kork.dynamicconfig.DynamicConfigService; import com.netflix.spinnaker.kork.dynamicconfig.SpringDynamicConfigService; @@ -50,8 +51,6 @@ import com.netflix.spinnaker.kork.plugins.v2.SpinnakerPluginService; import com.netflix.spinnaker.kork.plugins.v2.SpringPluginFactory; import com.netflix.spinnaker.kork.version.ServiceVersion; -import com.netflix.spinnaker.kork.version.SpringPackageVersionResolver; -import com.netflix.spinnaker.kork.version.VersionResolver; import java.net.URL; import java.nio.file.Path; import java.nio.file.Paths; @@ -77,7 +76,11 @@ import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.Environment; -@Import({Front50PluginsConfiguration.class, RemotePluginsConfiguration.class}) +@Import({ + Front50PluginsConfiguration.class, + RemotePluginsConfiguration.class, + BootstrapComponents.class +}) public class PluginsAutoConfiguration { private static final Logger log = LoggerFactory.getLogger(PluginsAutoConfiguration.class); @@ -97,19 +100,6 @@ public static SpringPluginStatusProvider pluginStatusProvider( dynamicConfigService, configNamespace + "." + defaultRootPath); } - @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 versionResolvers) { - return new ServiceVersion(applicationContext, versionResolvers); - } - @Bean public static VersionManager versionManager(ApplicationContext applicationContext) { return new SpinnakerServiceVersionManager( diff --git a/settings.gradle b/settings.gradle index 336240560..ec9b293fa 100644 --- a/settings.gradle +++ b/settings.gradle @@ -54,6 +54,9 @@ include( "kork-eureka", "kork-exceptions", "kork-expressions", + "kork-flags", + "kork-flags-flagd", + "kork-flags-git", "kork-jedis", "kork-jedis-test", "kork-moniker", diff --git a/spinnaker-dependencies/spinnaker-dependencies.gradle b/spinnaker-dependencies/spinnaker-dependencies.gradle index be1c46b2d..27b34fdc5 100644 --- a/spinnaker-dependencies/spinnaker-dependencies.gradle +++ b/spinnaker-dependencies/spinnaker-dependencies.gradle @@ -13,8 +13,10 @@ ext { brave : "5.12.3", gcp : "26.34.0", groovy : "4.0.15", + jgit : "7.0.0.202409031743-r", jsch : "0.1.54", jschAgentProxy : "0.0.9", + log4jKotlin : "1.5.0", // spring boot 2.7.18 specifies logback 1.2.12. Pin to 1.2.13 to resolve // CVE-2023-6378 and CVE-2023-6481 until spring boot 3.1.7 which brings in // 1.4.14. See https://logback.qos.ch/news.html#1.3.12. @@ -161,6 +163,8 @@ dependencies { api("de.danielbechler:java-object-diff:0.95") api("de.huxhorn.sulky:de.huxhorn.sulky.ulid:8.2.0") api("dev.minutest:minutest:1.13.0") + api("dev.openfeature:sdk:1.12.1") + api("dev.openfeature.contrib.providers:flagd:0.9.0") api("io.mockk:mockk:1.10.5") api("io.springfox:springfox-boot-starter:${versions.springfoxSwagger}") api("io.springfox:springfox-swagger2:${versions.springfoxSwagger}") @@ -169,6 +173,7 @@ dependencies { api("javax.xml.bind:jaxb-api:2.3.1") api("net.logstash.logback:logstash-logback-encoder:4.11") api("org.apache.commons:commons-exec:1.3") + api("org.apache.logging.log4j:log4j-api-kotlin:${versions.log4jKotlin}") api("org.bitbucket.b_c:jose4j:0.9.4") // from BC 1.71, module names changed from *-jdk15on to *-jdk18on // due to this change, some of the modules in downstream services like clouddriver, gate would fall back to @@ -178,6 +183,7 @@ dependencies { // some of the modules would still use <1.70 as they can't be upgraded without upgrading spring boot api("org.bouncycastle:bcpkix-jdk18on:${versions.bouncycastle}") api("org.bouncycastle:bcprov-jdk18on:${versions.bouncycastle}") + api("org.eclipse.jgit:org.eclipse.jgit:${versions.jgit}") api("org.jetbrains:annotations:19.0.0") api("org.spekframework.spek2:spek-dsl-jvm:${versions.spek2}") api("org.spekframework.spek2:spek-runner-junit5:${versions.spek2}")