From 71b678495496c1753f607d9ed4c0b609dd06614b 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 +--- .../OpenFeatureConfigConfiguration.java | 39 ++++++ .../OpenFeatureDynamicConfigService.java | 120 ++++++++++++++++++ ...OpenFeatureDynamicConfigServiceSpec.groovy | 63 +++++++++ kork-flags-flagd/README.md | 14 ++ kork-flags-flagd/kork-flags-flagd.gradle | 23 ++++ .../kork/flags/flagd/FlagSyncServer.kt | 55 ++++++++ .../kork/flags/flagd/FlagSyncService.kt | 91 +++++++++++++ .../kork/flags/flagd/FlagdImportSelector.kt | 41 ++++++ .../flagd/FlagdOpenFeatureConfiguration.kt | 27 ++++ .../kork/flags/flagd/FlagdProperties.kt | 43 +++++++ .../kork/flags/flagd/FlagdProviderFactory.kt | 51 ++++++++ .../kork/flags/flagd/SyncFlagsEvent.kt | 28 ++++ ...ork.flags.OpenFeatureConfiguration.imports | 1 + kork-flags-git/kork-flags-git.gradle | 24 ++++ .../git/GitFlagsOpenFeatureConfiguration.kt | 29 +++++ .../flags/flagd/git/GitFlagsProperties.kt | 44 +++++++ .../flags/flagd/git/GitRepositoryPoller.kt | 81 ++++++++++++ ...ork.flags.OpenFeatureConfiguration.imports | 1 + kork-flags/kork-flags.gradle | 23 ++++ .../spinnaker/kork/flags/EnableOpenFeature.kt | 27 ++++ .../kork/flags/OpenFeatureConfiguration.kt | 31 +++++ .../kork/flags/OpenFeatureFactory.kt | 46 +++++++ .../kork/flags/OpenFeatureImportSelector.kt | 31 +++++ .../config/PluginsAutoConfiguration.java | 22 +--- settings.gradle | 3 + .../spinnaker-dependencies.gradle | 6 + 29 files changed, 996 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/OpenFeatureConfigConfiguration.java create mode 100644 kork-core/src/main/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigService.java create mode 100644 kork-core/src/test/groovy/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigServiceSpec.groovy 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/FlagdImportSelector.kt create mode 100644 kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdOpenFeatureConfiguration.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/FlagdProviderFactory.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/com.netflix.spinnaker.kork.flags.OpenFeatureConfiguration.imports 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/GitFlagsOpenFeatureConfiguration.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/com.netflix.spinnaker.kork.flags.OpenFeatureConfiguration.imports create mode 100644 kork-flags/kork-flags.gradle create mode 100644 kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/EnableOpenFeature.kt create mode 100644 kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureConfiguration.kt create mode 100644 kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureFactory.kt create mode 100644 kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureImportSelector.kt 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..099fdc1ea --- /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.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 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/OpenFeatureConfigConfiguration.java b/kork-core/src/main/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureConfigConfiguration.java new file mode 100644 index 000000000..aeff89526 --- /dev/null +++ b/kork-core/src/main/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureConfigConfiguration.java @@ -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); + } +} 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..0aca22b48 --- /dev/null +++ b/kork-core/src/main/java/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigService.java @@ -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 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 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); + } +} diff --git a/kork-core/src/test/groovy/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigServiceSpec.groovy b/kork-core/src/test/groovy/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigServiceSpec.groovy new file mode 100644 index 000000000..eeec9670b --- /dev/null +++ b/kork-core/src/test/groovy/com/netflix/spinnaker/kork/dynamicconfig/OpenFeatureDynamicConfigServiceSpec.groovy @@ -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' + } +} 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..015b9c990 --- /dev/null +++ b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagSyncServer.kt @@ -0,0 +1,55 @@ +/* + * 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 org.springframework.stereotype.Component +import java.net.InetAddress +import java.net.InetSocketAddress + +/** + * Bean to handle the lifecycle of the flag sync service in a gRPC server. + */ +@Component +class FlagSyncServer(properties: FlagdProperties, service: FlagSyncService) : InitializingBean, DisposableBean { + private lateinit var 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..11d75e27e --- /dev/null +++ b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagSyncService.kt @@ -0,0 +1,91 @@ +/* + * 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.event.EventListener +import org.springframework.stereotype.Service +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. + */ +@Service +class FlagSyncService : FlagSyncServiceImplBase(), DisposableBean { + private val syncFlagsObservers = CopyOnWriteArrayList>() + private val currentFlagConfiguration = AtomicReference() + + @EventListener + fun syncFlags(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/FlagdImportSelector.kt b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdImportSelector.kt new file mode 100644 index 000000000..4482e19e2 --- /dev/null +++ b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdImportSelector.kt @@ -0,0 +1,41 @@ +/* + * 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.Binder +import org.springframework.context.annotation.ImportSelector +import org.springframework.core.env.Environment +import org.springframework.core.type.AnnotationMetadata + +class FlagdImportSelector(environment: Environment) : ImportSelector { + private val properties = Binder.get(environment) + .bindOrCreate("flags.providers.flagd", FlagdProperties::class.java) + + override fun selectImports(importingClassMetadata: AnnotationMetadata): Array { + if (!properties.enabled) { + return emptyArray() + } + val imports = arrayListOf>() + if (properties.sync.enabled) { + imports += FlagSyncService::class.java + imports += FlagSyncServer::class.java + } + imports += FlagdProviderFactory::class.java + return imports.map { it.canonicalName }.toTypedArray() + } +} diff --git a/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdOpenFeatureConfiguration.kt b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdOpenFeatureConfiguration.kt new file mode 100644 index 000000000..5afeeb45e --- /dev/null +++ b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdOpenFeatureConfiguration.kt @@ -0,0 +1,27 @@ +/* + * 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.OpenFeatureConfiguration +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Import + +@OpenFeatureConfiguration +@EnableConfigurationProperties(FlagdProperties::class) +@Import(FlagdImportSelector::class) +class FlagdOpenFeatureConfiguration 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..76bd0e0cd --- /dev/null +++ b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdProperties.kt @@ -0,0 +1,43 @@ +/* + * 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.ConfigurationProperties +import org.springframework.boot.context.properties.ConstructorBinding +import org.springframework.boot.context.properties.bind.DefaultValue +import java.nio.file.Path + +@ConfigurationProperties("flags.providers.flagd") +@ConstructorBinding +data class FlagdProperties( + @DefaultValue("false") val enabled: Boolean, + @DefaultValue val sync: Sync, + @DefaultValue val offline: Offline, +) { + @ConstructorBinding + data class Sync( + @DefaultValue("false") val enabled: Boolean, + val socketPath: Path?, + @DefaultValue("8015") val port: Int, + ) + @ConstructorBinding + 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/FlagdProviderFactory.kt b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdProviderFactory.kt new file mode 100644 index 000000000..fb527412a --- /dev/null +++ b/kork-flags-flagd/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/FlagdProviderFactory.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.flagd + +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.context.annotation.Bean +import org.springframework.stereotype.Component + +/** + * Handles creation of the [FlagdProvider]. Should be set up after [FlagSyncServer] if in use. + */ +@Component +class FlagdProviderFactory { + @Bean + fun flagdProvider(properties: FlagdProperties): FlagdProvider { + val builder = FlagdOptions.builder().resolverType(Resolver.IN_PROCESS) + if (properties.offline.enabled) { + val path = requireNotNull(properties.offline.flagSourcePath) { + "Offline flagd was enabled but no flag source path defined" + } + builder.offlineFlagSourcePath(path.toString()) + } else if (properties.sync.enabled) { + val socketPath = properties.sync.socketPath?.toString() + if (socketPath != null) { + builder.socketPath(socketPath) + } else { + builder.port(properties.sync.port) + } + } else { + throw UnsupportedOperationException("The flagd provider was enabled but not configured") + } + return FlagdProvider(builder.build()) + } +} 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/com.netflix.spinnaker.kork.flags.OpenFeatureConfiguration.imports b/kork-flags-flagd/src/main/resources/META-INF/spring/com.netflix.spinnaker.kork.flags.OpenFeatureConfiguration.imports new file mode 100644 index 000000000..cf2174a2a --- /dev/null +++ b/kork-flags-flagd/src/main/resources/META-INF/spring/com.netflix.spinnaker.kork.flags.OpenFeatureConfiguration.imports @@ -0,0 +1 @@ +com.netflix.spinnaker.kork.flags.flagd.FlagdOpenFeatureConfiguration diff --git a/kork-flags-git/kork-flags-git.gradle b/kork-flags-git/kork-flags-git.gradle new file mode 100644 index 000000000..adb8af834 --- /dev/null +++ b/kork-flags-git/kork-flags-git.gradle @@ -0,0 +1,24 @@ +/* + * 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/GitFlagsOpenFeatureConfiguration.kt b/kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitFlagsOpenFeatureConfiguration.kt new file mode 100644 index 000000000..e846abeef --- /dev/null +++ b/kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitFlagsOpenFeatureConfiguration.kt @@ -0,0 +1,29 @@ +/* + * 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.OpenFeatureConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Import + +@OpenFeatureConfiguration +@EnableConfigurationProperties(GitFlagsProperties::class) +@ConditionalOnProperty("flags.providers.flagd.git.enabled") +@Import(GitRepositoryPoller::class) +class GitFlagsOpenFeatureConfiguration 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..50e98a2c5 --- /dev/null +++ b/kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitFlagsProperties.kt @@ -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.flags.flagd.git + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConstructorBinding +import org.springframework.boot.context.properties.bind.DefaultValue +import java.time.Duration + +/** + * Configuration properties related to using git polling for a source of flagd data. + * + * @param enabled toggles this flag source backend + * @param repositoryUri git repository URI to clone from (required) + * @param branch branch to check out (default of HEAD) + * @param apiToken authentication token to use (required) + * @param pollingInterval how often to poll git for updates (default of every 5 minutes) + * @param flagsSource name of file in the git repository to load feature flags from (default is `spinnaker.json`) + */ +@ConfigurationProperties("flags.providers.flagd.git") +@ConstructorBinding +data class GitFlagsProperties( + @DefaultValue("false") val enabled: Boolean, + val repositoryUri: String, + @DefaultValue("HEAD") val branch: String, + val apiToken: String, + @DefaultValue("PT5M") val pollingInterval: Duration, + @DefaultValue("spinnaker.json") val flagsSource: String, +) 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..6a434ac7f --- /dev/null +++ b/kork-flags-git/src/main/kotlin/com/netflix/spinnaker/kork/flags/flagd/git/GitRepositoryPoller.kt @@ -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.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.context.ApplicationEventPublisher +import org.springframework.scheduling.annotation.Async +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.io.BufferedReader +import java.nio.file.Files +import kotlin.io.path.bufferedReader +import kotlin.io.path.div + +@Component +class GitRepositoryPoller( + private val properties: GitFlagsProperties, + private val applicationEventPublisher: ApplicationEventPublisher, +) : AutoCloseable { + private val directory = Files.createTempDirectory("GitRepositoryPoller") + private val git = Git.cloneRepository() + .setURI(properties.repositoryUri) + .setBranch(properties.branch) + .setDepth(1) + .setDirectory(directory.toFile()) + .setCredentialsProvider(UsernamePasswordCredentialsProvider("token", properties.apiToken)) + .call() + + init { + syncFlags() + } + + @Async + @Scheduled(fixedRateString = "\${flags.providers.flagd.git.polling-interval:PT5M}") + 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(jvz): 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 close() { + git.close() + } + + companion object : Logging +} diff --git a/kork-flags-git/src/main/resources/META-INF/spring/com.netflix.spinnaker.kork.flags.OpenFeatureConfiguration.imports b/kork-flags-git/src/main/resources/META-INF/spring/com.netflix.spinnaker.kork.flags.OpenFeatureConfiguration.imports new file mode 100644 index 000000000..21e514091 --- /dev/null +++ b/kork-flags-git/src/main/resources/META-INF/spring/com.netflix.spinnaker.kork.flags.OpenFeatureConfiguration.imports @@ -0,0 +1 @@ +com.netflix.spinnaker.kork.flags.flagd.git.GitFlagsOpenFeatureConfiguration 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/EnableOpenFeature.kt b/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/EnableOpenFeature.kt new file mode 100644 index 000000000..38001c5c0 --- /dev/null +++ b/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/EnableOpenFeature.kt @@ -0,0 +1,27 @@ +/* + * 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.context.annotation.Import + +/** + * Enables the configuration of [OpenFeatureAPI][dev.openfeature.sdk.OpenFeatureAPI] and creation of a bean. + */ +@Target(AnnotationTarget.CLASS) +@Import(OpenFeatureImportSelector::class, OpenFeatureFactory::class) +annotation class EnableOpenFeature diff --git a/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureConfiguration.kt b/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureConfiguration.kt new file mode 100644 index 000000000..6a73e4044 --- /dev/null +++ b/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureConfiguration.kt @@ -0,0 +1,31 @@ +/* + * 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.context.annotation.Configuration + +/** + * Annotations a [Configuration] class that sets up OpenFeature-related beans. This works similar to an + * autoconfiguration but using eager imports rather than deferred. + * + * OpenFeature configurations should conditionally create a [dev.openfeature.sdk.FeatureProvider] bean + * when enabled or participate with another configuration that does. + */ +@Configuration(proxyBeanMethods = false) +@Target(AnnotationTarget.CLASS) +annotation class OpenFeatureConfiguration diff --git a/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureFactory.kt b/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureFactory.kt new file mode 100644 index 000000000..35da47733 --- /dev/null +++ b/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureFactory.kt @@ -0,0 +1,46 @@ +/* + * 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.MutableContext +import dev.openfeature.sdk.OpenFeatureAPI +import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator +import dev.openfeature.sdk.providers.memory.InMemoryProvider +import org.springframework.beans.factory.ObjectProvider +import org.springframework.context.annotation.Bean +import org.springframework.core.env.Environment +import org.springframework.core.env.get +import org.springframework.stereotype.Component + +/** + * Handles configuration and creation of the [OpenFeatureAPI] singleton. This expects a [FeatureProvider] + * bean to be available; if one is not, then a no-op implementation is used. + */ +@Component +class OpenFeatureFactory { + @Bean + fun openFeatureAPI(provider: ObjectProvider, environment: Environment): OpenFeatureAPI { + val api = OpenFeatureAPI.getInstance() + val featureProvider = provider.ifAvailable ?: InMemoryProvider(emptyMap()) + api.setProviderAndWait(featureProvider) + api.evaluationContext = MutableContext().add("app", environment["spring.application.name"]) + api.transactionContextPropagator = ThreadLocalTransactionContextPropagator() + return api + } +} diff --git a/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureImportSelector.kt b/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureImportSelector.kt new file mode 100644 index 000000000..63420baa0 --- /dev/null +++ b/kork-flags/src/main/kotlin/com/netflix/spinnaker/kork/flags/OpenFeatureImportSelector.kt @@ -0,0 +1,31 @@ +/* + * 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.annotation.ImportCandidates +import org.springframework.context.annotation.ImportSelector +import org.springframework.core.type.AnnotationMetadata + +/** + * Handles importing of classes annotated with [OpenFeatureConfiguration]. These classes must be listed out in + * resource files named `META-INF/spring/com.netflix.spinnaker.kork.flags.OpenFeatureConfiguration.imports`. + */ +class OpenFeatureImportSelector(private val classLoader: ClassLoader) : ImportSelector { + override fun selectImports(importingClassMetadata: AnnotationMetadata): Array = + ImportCandidates.load(OpenFeatureConfiguration::class.java, classLoader).distinct().toTypedArray() +} 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}")