From 4731a9cb0f6dd421aec39d9168502ff6101403c2 Mon Sep 17 00:00:00 2001 From: Cole Snodgrass Date: Tue, 17 Jan 2023 09:40:32 -0800 Subject: [PATCH] add feature-flag client (#21091) * feature-flag client; wip * feature-flag; wip * updated wip/poc * make account truly optional * add temp flag example * wip of feature-flag client; upgrade to kotlin 1.8 * inject LDClient * feature-flag client wip; add tests; finalize classes * add cloud test with mockk * add EnvVar tests * remove account from workspace, add user * comment out test * doc typo * move flags to top of file * change LogConnectorMessages to EnvVar * add @JvmOverloads to Workspace context * rename clients; add TestClient (with tests) * rename teams * rename Client -> FeatureFlagClient * doc typo * change parameter ordering in flags * rename files * typo * update docs to match re-org'd params * rename PlatformClient -> ConfigFileClient; CloudClient -> LaunchDarklyClient * pr feedback; comment updates * pr feedback - remove star imports * pr feedback; update to use version catalog * pr feedback; add Connection, Source, Destination contexts * pr feedback - add multicontext support * replace Team idea with attributes map * pr feedback * explicit types * rename data classes to match client * pass context to envvar flags; make enabled open * change fetcher to an internal property * add UUID secondary constructors to context classes * make values optional for TestClient * add anonymous support * pr-feedback; add newlines to each file --- airbyte-featureflag/build.gradle.kts | 39 +++ airbyte-featureflag/src/main/kotlin/Client.kt | 211 ++++++++++++++ .../src/main/kotlin/Context.kt | 147 ++++++++++ airbyte-featureflag/src/main/kotlin/Flags.kt | 102 +++++++ .../src/main/kotlin/config/Factory.kt | 41 +++ .../src/test/kotlin/ClientTest.kt | 271 ++++++++++++++++++ .../src/test/kotlin/ContextTest.kt | 75 +++++ .../src/test/kotlin/EnvVarTest.kt | 61 ++++ .../src/test/kotlin/FlagsTest.kt | 59 ++++ .../src/test/kotlin/config/FactoryTest.kt | 40 +++ .../src/test/resources/app-cloud.yml | 3 + .../src/test/resources/app-platform.yml | 3 + .../src/test/resources/feature-flags.yml | 5 + deps.toml | 3 + settings.gradle | 1 + 15 files changed, 1061 insertions(+) create mode 100644 airbyte-featureflag/build.gradle.kts create mode 100644 airbyte-featureflag/src/main/kotlin/Client.kt create mode 100644 airbyte-featureflag/src/main/kotlin/Context.kt create mode 100644 airbyte-featureflag/src/main/kotlin/Flags.kt create mode 100644 airbyte-featureflag/src/main/kotlin/config/Factory.kt create mode 100644 airbyte-featureflag/src/test/kotlin/ClientTest.kt create mode 100644 airbyte-featureflag/src/test/kotlin/ContextTest.kt create mode 100644 airbyte-featureflag/src/test/kotlin/EnvVarTest.kt create mode 100644 airbyte-featureflag/src/test/kotlin/FlagsTest.kt create mode 100644 airbyte-featureflag/src/test/kotlin/config/FactoryTest.kt create mode 100644 airbyte-featureflag/src/test/resources/app-cloud.yml create mode 100644 airbyte-featureflag/src/test/resources/app-platform.yml create mode 100644 airbyte-featureflag/src/test/resources/feature-flags.yml diff --git a/airbyte-featureflag/build.gradle.kts b/airbyte-featureflag/build.gradle.kts new file mode 100644 index 000000000000..546e86898151 --- /dev/null +++ b/airbyte-featureflag/build.gradle.kts @@ -0,0 +1,39 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + kotlin("jvm") version "1.8.0" +} + +dependencies { + annotationProcessor(platform(libs.micronaut.bom)) + annotationProcessor(libs.bundles.micronaut.annotation.processor) + + implementation(platform(libs.micronaut.bom)) + implementation(libs.micronaut.inject) + implementation(libs.launchdarkly) + implementation(libs.jackson.databind) + implementation(libs.jackson.dataformat) + implementation(libs.jackson.kotlin) + + testAnnotationProcessor(platform(libs.micronaut.bom)) + testAnnotationProcessor(libs.micronaut.inject) + testAnnotationProcessor(libs.bundles.micronaut.test.annotation.processor) + + testImplementation(kotlin("test")) + testImplementation(kotlin("test-junit5")) + testImplementation(libs.bundles.micronaut.test) + testImplementation(libs.mockk) + testImplementation(libs.bundles.junit) +} + +tasks.withType { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +tasks.test { + useJUnitPlatform() +} diff --git a/airbyte-featureflag/src/main/kotlin/Client.kt b/airbyte-featureflag/src/main/kotlin/Client.kt new file mode 100644 index 000000000000..ddf92f665fc3 --- /dev/null +++ b/airbyte-featureflag/src/main/kotlin/Client.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2022 Airbyte, Inc., all rights reserved. + */ + +package io.airbyte.featureflag + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.module.kotlin.readValue +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import com.launchdarkly.sdk.ContextKind +import com.launchdarkly.sdk.LDContext +import com.launchdarkly.sdk.LDUser +import com.launchdarkly.sdk.server.LDClient +import java.lang.Thread.MIN_PRIORITY +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds +import java.nio.file.WatchService +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.thread +import kotlin.concurrent.write +import kotlin.io.path.isRegularFile + +/** + * Feature-Flag Client interface. + */ +sealed interface FeatureFlagClient { + /** + * Returns true if the flag with the provided context should be enabled. Returns false otherwise. + */ + fun enabled(flag: Flag, ctx: Context): Boolean +} + +/** + * Config file based feature-flag client. Feature-flag are derived from a yaml config file. + * Also supports flags defined via environment-variables via the [EnvVar] class. + * + * @param [config] the location of the yaml config file that contains the feature-flag definitions. + * The [config] will be watched for changes and the internal representation of the [config] will be updated to match. + */ +class ConfigFileClient(config: Path) : FeatureFlagClient { + /** [flags] holds the mappings of the flag-name to the flag properties */ + private var flags: Map = readConfig(config) + + /** lock is used for ensuring access to the flags map is handled correctly when the map is being updated. */ + private val lock = ReentrantReadWriteLock() + + init { + if (!config.isRegularFile()) { + throw IllegalArgumentException("config must reference a file") + } + + config.onChange { + lock.write { flags = readConfig(config) } + } + } + + override fun enabled(flag: Flag, ctx: Context): Boolean { + return when (flag) { + is EnvVar -> flag.enabled(ctx) + else -> lock.read { flags[flag.key]?.enabled ?: flag.default } + } + } +} + +/** + * LaunchDarkly based feature-flag client. Feature-flags are derived from an external source (the LDClient). + * Also supports flags defined via environment-variables via the [EnvVar] class. + * + * @param [client] the Launch-Darkly client for interfacing with Launch-Darkly. + */ +class LaunchDarklyClient(private val client: LDClient) : FeatureFlagClient { + override fun enabled(flag: Flag, ctx: Context): Boolean { + return when (flag) { + is EnvVar -> flag.enabled(ctx) + else -> client.boolVariation(flag.key, ctx.toLDUser(), flag.default) + } + } +} + +/** + * Test feature-flag client. Intended only for usage in testing scenarios. + * + * All [Flag] instances will use the provided [values] map as their source of truth, including [EnvVar] flags. + * + * @param [values] is a map of [Flag.key] to enabled/disabled status. + */ +class TestClient @JvmOverloads constructor(val values: Map = mapOf()) : FeatureFlagClient { + override fun enabled(flag: Flag, ctx: Context): Boolean { + return when (flag) { + is EnvVar -> { + // convert to a EnvVar flag with a custom fetcher that uses the [values] of this Test class + // instead of fetching from the environment variables + EnvVar(envVar = flag.key, default = flag.default, attrs = flag.attrs).apply { + fetcher = { values[flag.key]?.toString() ?: flag.default.toString() } + }.enabled(ctx) + } + + else -> values[flag.key] ?: flag.default + } + } +} + + +/** + * Data wrapper around OSS feature-flag configuration file. + * + * The file has the format of: + * flags: + * - name: feature-one + * enabled: true + * - name: feature-two + * enabled: false + */ +private data class ConfigFileFlags(val flags: List) + +/** + * Data wrapper around an individual flag read from the configuration file. + */ +private data class ConfigFileFlag(val name: String, val enabled: Boolean) + + +/** The yaml mapper is used for reading the feature-flag configuration file. */ +private val yamlMapper = ObjectMapper(YAMLFactory()).registerKotlinModule() + +/** + * Reads a yaml configuration file, converting it into a map of flag name to flag configuration. + * + * @param [path] to yaml config file + * @return map of feature-flag name to feature-flag config + */ +private fun readConfig(path: Path): Map = yamlMapper.readValue(path.toFile()).flags + .associateBy { it.name } + +/** + * Monitors a [Path] for changes, calling [block] when a change is detected. + * + * @receiver Path + * @param [block] function called anytime a change is detected on this [Path] + */ +private fun Path.onChange(block: () -> Unit) { + val watcher: WatchService = fileSystem.newWatchService() + // The watcher service requires a directory to be registered and not an individual file. This Path is an individual file, + // hence the `parent` reference to register the parent of this file (which is the directory that contains this file). + // As all files within this directory could send events, any file that doesn't match this Path will need to be filtered out. + parent.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_CREATE) + + thread(isDaemon = true, name = "feature-flag-watcher", priority = MIN_PRIORITY) { + val key = watcher.take() + // The context on the poll-events for ENTRY_MODIFY and ENTRY_CREATE events should return a Path, + // however officially `Returns: the event context; may be null`, so there is a null check here + key.pollEvents().mapNotNull { it.context() as? Path } + // As events are generated at the directory level and not the file level, any files that do not match the specific file + // this Path represents must be filtered out. + // E.g. + // If this path is "/tmp/dir/flags.yml", + // the directory registered with the WatchService was "/tmp/dir", + // and the event's path would be "flags.yml". + // + // This filter verifies that "/tmp/dir/flags.yml" ends with "flags.yml" before calling the block method. + .filter { this.endsWith(it) } + .forEach { _ -> block() } + + key.reset() + } +} + +/** + * LaunchDarkly v5 version + * + * LaunchDarkly v6 introduces the LDContext class which replaces the LDUser class, + * however this is only available to early-access users accounts currently. + * + * Once v6 is GA, this method would be removed and replaced with toLDContext. + */ +private fun Context.toLDUser(): LDUser = when (this) { + is Multi -> throw IllegalArgumentException("LDv5 does not support multiple contexts") + else -> { + val builder = LDUser.Builder(key) + if (this.key == ANONYMOUS.toString()) { + builder.anonymous(true) + } + builder.build() + } +} + +/** + * LaunchDarkly v6 version + * + * Replaces toLDUser once LaunchDarkly v6 is GA. + */ +private fun Context.toLDContext(): LDContext { + if (this is Multi) { + val builder = LDContext.multiBuilder() + contexts.forEach { builder.add(it.toLDContext()) } + return builder.build() + } + + val builder = LDContext.builder(ContextKind.of(kind), key) + if (key == ANONYMOUS.toString()) { + builder.anonymous(true) + } + + when (this) { + is Workspace -> user?.let { builder.set("user", it) } + else -> Unit + } + + return builder.build() +} diff --git a/airbyte-featureflag/src/main/kotlin/Context.kt b/airbyte-featureflag/src/main/kotlin/Context.kt new file mode 100644 index 000000000000..490e32580422 --- /dev/null +++ b/airbyte-featureflag/src/main/kotlin/Context.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2023 Airbyte, Inc., all rights reserved. + */ + +package io.airbyte.featureflag + +import java.util.* + +/** + * Anonymous UUID to be used with anonymous contexts. + * + * Annotated with @JvmField for java interop. + */ +@JvmField +val ANONYMOUS = UUID(0, 0) + +/** + * Context abstraction around LaunchDarkly v6 context idea + * + * I'm still playing around with this. Basically the idea is to define our own custom context types + * (by implementing this sealed interface) to ensure that we are consistently using the same identifiers + * throughout the code. + * + * @property [kind] determines the kind of context the implementation is, + * must be consistent for each type and should not be set by the caller of a context + * @property [key] is the unique identifier for the specific context, e.g. a user-id or workspace-id + */ +sealed interface Context { + val kind: String + val key: String +} + +/** + * Context for representing multiple contexts concurrently. Only supported for LaunchDarkly v6! + * + * @param [contexts] list of contexts, must not contain another Multi context + */ +data class Multi(val contexts: List) : Context { + /** This value MUST be "multi" to properly sync with the LaunchDarkly client. */ + override val kind = "multi" + + /** Multi contexts don't have a key */ + override val key = "" + + init { + // ensure there are no nested contexts (i.e. this Multi does not contain another Multi) + if (fetchContexts().isNotEmpty()) { + throw IllegalArgumentException("Multi contexts cannot be nested") + } + } + + /** + * Returns all the [Context] types contained within this [Multi] matching type [T]. + * + * @param [T] the [Context] type to fetch. + * @return all [Context] of [T] within this [Multi], or an empty list if none match. + */ + internal inline fun fetchContexts(): List { + return contexts.filterIsInstance() + } +} + +/** + * Context for representing a workspace. + * + * @param [key] the unique identifying value of this workspace + * @param [user] an optional user identifier + */ +data class Workspace @JvmOverloads constructor( + override val key: String, + val user: String? = null +) : Context { + override val kind = "workspace" + + /** + * Secondary constructor + * + * @param [key] workspace UUID + * @param [user] an optional user identifier + */ + @JvmOverloads + constructor(key: UUID, user: String? = null) : this(key = key.toString(), user = user) +} + +/** + * Context for representing a user. + * + * @param [key] the unique identifying value of this user + */ +data class User(override val key: String) : Context { + override val kind = "user" + + /** + * Secondary constructor + * + * @param [key] user UUID + */ + constructor(key: UUID) : this(key = key.toString()) +} + +/** + * Context for representing a connection. + * + * @param [key] the unique identifying value of this connection + */ +data class Connection(override val key: String) : Context { + override val kind = "connection" + + /** + * Secondary constructor + * + * @param [key] connection UUID + */ + constructor(key: UUID) : this(key = key.toString()) +} + +/** + * Context for representing a source. + * + * @param [key] the unique identifying value of this source + */ +data class Source(override val key: String) : Context { + override val kind = "source" + + /** + * Secondary constructor + * + * @param [key] Source UUID + */ + constructor(key: UUID) : this(key = key.toString()) +} + +/** + * Context for representing a destination. + * + * @param [key] the unique identifying value of this destination + */ +data class Destination(override val key: String) : Context { + override val kind = "destination" + + /** + * Secondary constructor + * + * @param [key] Destination UUID + */ + constructor(key: UUID) : this(key = key.toString()) +} diff --git a/airbyte-featureflag/src/main/kotlin/Flags.kt b/airbyte-featureflag/src/main/kotlin/Flags.kt new file mode 100644 index 000000000000..bfc1dd0a6a82 --- /dev/null +++ b/airbyte-featureflag/src/main/kotlin/Flags.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2022 Airbyte, Inc., all rights reserved. + */ + +package io.airbyte.featureflag + +/** + * If enabled, all messages from the source to the destination will be logged in 1 second intervals. + * + * This is a permanent flag and would implement the [Flag] type once converted from an environment-variable. + */ +object LogConnectorMessages : EnvVar(envVar = "LOG_CONNECTOR_MESSAGES") + +object StreamCapableState : EnvVar(envVar = "USE_STREAM_CAPABLE_STATE") +object AutoDetectSchema : EnvVar(envVar = "AUTO_DETECT_SCHEMA") +object NeedStateValidation : EnvVar(envVar = "NEED_STATE_VALIDATION") +object ApplyFieldSelection : EnvVar(envVar = "APPLY_FIELD_SELECTION") + +object FieldSelectionWorkspaces : EnvVar(envVar = "FIELD_SELECTION_WORKSPACES") { + override fun enabled(ctx: Context): Boolean { + val enabledWorkspaceIds: List = fetcher(key) + ?.takeIf { it.isNotEmpty() } + ?.split(",") + ?: listOf() + + val contextWorkspaceIds: List = when (ctx) { + is Multi -> ctx.fetchContexts().map { it.key } + is Workspace -> listOf(ctx.key) + else -> listOf() + } + + return when (contextWorkspaceIds.any { it in enabledWorkspaceIds }) { + true -> true + else -> default + } + } +} + +/** + * Flag is a sealed class that all feature-flags must inherit from. + * + * There are two types of feature-flags; permanent and temporary. Permanent flags should inherit from the Flag class directly + * while temporary flags should inherit from the Temporary class (which it itself inherits from the Flag class). + * + * @param [key] is the globally unique identifier for identifying this specific feature-flag. + * @param [default] is the default value of the flag. + * @param [attrs] optional attributes associated with this flag + */ +sealed class Flag( + internal val key: String, + internal val default: Boolean = false, + internal val attrs: Map = mapOf(), +) + +/** + * Temporary is an open class (non-final) that all temporary feature-flags should inherit from. + * + * A Temporary feature-flag is any feature-flag that is not intended to exist forever. + * Most feature-flags should be considered temporary. + * + * @param [key] is the globally unique identifier for identifying this specific feature-flag. + * @param [default] is the default value of the flag. + * @param [attrs] attributes associated with this flag + */ +open class Temporary @JvmOverloads constructor( + key: String, + default: Boolean = false, + attrs: Map = mapOf(), +) : Flag(key = key, default = default, attrs = attrs) + +/** + * Environment-Variable based feature-flag. + * + * Intended only to be used in a transitory manner as the platform migrates to an official feature-flag solution. + * Every instance of this class should be migrated over to the Temporary class. + * + * @param [envVar] the environment variable to check for the status of this flag + * @param [default] the default value of this flag, if the environment variable is not defined + * @param [attrs] attributes associated with this flag + */ +open class EnvVar @JvmOverloads constructor( + envVar: String, + default: Boolean = false, + attrs: Map = mapOf(), +) : Flag(key = envVar, default = default, attrs = attrs) { + /** + * Function used to retrieve the environment-variable, overrideable for testing purposes only. + * + * This is internal so that it can be modified for unit-testing purposes only! + */ + internal var fetcher: (String) -> String? = { s -> System.getenv(s) } + + /** + * Returns true if, and only if, the environment-variable is defined and evaluates to "true". Otherwise, returns false. + */ + internal open fun enabled(ctx: Context): Boolean { + return fetcher(key) + ?.takeIf { it.isNotEmpty() } + ?.let { it.toBoolean() } + ?: default + } +} diff --git a/airbyte-featureflag/src/main/kotlin/config/Factory.kt b/airbyte-featureflag/src/main/kotlin/config/Factory.kt new file mode 100644 index 000000000000..d80a4673a13e --- /dev/null +++ b/airbyte-featureflag/src/main/kotlin/config/Factory.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 Airbyte, Inc., all rights reserved. + */ + +package io.airbyte.featureflag.config + +import com.launchdarkly.sdk.server.LDClient +import io.airbyte.featureflag.ConfigFileClient +import io.airbyte.featureflag.FeatureFlagClient +import io.airbyte.featureflag.LaunchDarklyClient +import io.micronaut.context.annotation.Factory +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Requirements +import io.micronaut.context.annotation.Requires +import jakarta.inject.Singleton +import java.nio.file.Path + +internal const val CONFIG_LD_KEY = "airbyte.feature-flag.api-key" +internal const val CONFIG_OSS_KEY = "airbyte.feature-flag.path" + +@Factory +class Factory { + @Requirements( + Requires(property = CONFIG_LD_KEY), + Requires(missingProperty = CONFIG_OSS_KEY), + ) + @Singleton + fun Cloud(@Property(name = CONFIG_LD_KEY) apiKey: String): FeatureFlagClient { + val client = LDClient(apiKey) + return LaunchDarklyClient(client) + } + + @Requirements( + Requires(property = CONFIG_OSS_KEY), + Requires(missingProperty = CONFIG_LD_KEY), + ) + fun Platform(@Property(name = CONFIG_OSS_KEY) configPath: String): FeatureFlagClient { + val path = Path.of(configPath) + return ConfigFileClient(path) + } +} diff --git a/airbyte-featureflag/src/test/kotlin/ClientTest.kt b/airbyte-featureflag/src/test/kotlin/ClientTest.kt new file mode 100644 index 000000000000..5adf6f562f9c --- /dev/null +++ b/airbyte-featureflag/src/test/kotlin/ClientTest.kt @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2023 Airbyte, Inc., all rights reserved. + */ + +import com.launchdarkly.sdk.LDUser +import com.launchdarkly.sdk.server.LDClient +import io.airbyte.featureflag.ANONYMOUS +import io.airbyte.featureflag.ConfigFileClient +import io.airbyte.featureflag.EnvVar +import io.airbyte.featureflag.FeatureFlagClient +import io.airbyte.featureflag.Flag +import io.airbyte.featureflag.LaunchDarklyClient +import io.airbyte.featureflag.Multi +import io.airbyte.featureflag.Temporary +import io.airbyte.featureflag.TestClient +import io.airbyte.featureflag.User +import io.airbyte.featureflag.Workspace +import io.mockk.called +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Test +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlin.io.path.createTempFile +import kotlin.io.path.writeText +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConfigFileClient { + @Test + fun `verify platform functionality`() { + val cfg = Path.of("src", "test", "resources", "feature-flags.yml") + val client: FeatureFlagClient = ConfigFileClient(cfg) + + val testTrue = Temporary(key = "test-true") + val testFalse = Temporary(key = "test-false", default = true) + val testDne = Temporary(key = "test-dne") + + val ctx = User("test") + + with(client) { + assertTrue { enabled(testTrue, ctx) } + assertFalse { enabled(testFalse, ctx) } + assertFalse { enabled(testDne, ctx) } + } + } + + @Test + fun `verify platform reload capabilities`() { + val contents0 = """flags: + | - name: reload-test-true + | enabled: true + | - name: reload-test-false + | enabled: false + | """.trimMargin() + val contents1 = """flags: + | - name: reload-test-true + | enabled: false + | - name: reload-test-false + | enabled: true + | """.trimMargin() + + // write to a temp config + val tmpConfig = createTempFile(prefix = "reload-config", suffix = "yml").apply { + writeText(contents0) + } + + val client: FeatureFlagClient = ConfigFileClient(tmpConfig) + + // define the feature-flags + val testTrue = Temporary(key = "reload-test-true") + val testFalse = Temporary(key = "reload-test-false", default = true) + val testDne = Temporary(key = "reload-test-dne") + // and the context + val ctx = User("test") + + // verify pre-updated values + with(client) { + assertTrue { enabled(testTrue, ctx) } + assertFalse { enabled(testFalse, ctx) } + assertFalse { enabled(testDne, ctx) } + } + // update the config and wait a few seconds (enough time for the file-watcher to pick up the change) + tmpConfig.writeText(contents1) + TimeUnit.SECONDS.sleep(2) + + // verify post-updated values + with(client) { + assertFalse { enabled(testTrue, ctx) } + assertTrue { enabled(testFalse, ctx) } + assertFalse("undefined flag should still be false") { enabled(testDne, ctx) } + } + } + + @Test + fun `verify env-var flag support`() { + val cfg = Path.of("src", "test", "resources", "feature-flags.yml") + val client: FeatureFlagClient = ConfigFileClient(cfg) + + val evTrue = EnvVar(envVar = "env-true").apply { fetcher = { _ -> "true" } } + val evFalse = EnvVar(envVar = "env-true").apply { fetcher = { _ -> "false" } } + val evEmpty = EnvVar(envVar = "env-true").apply { fetcher = { _ -> "" } } + val evNull = EnvVar(envVar = "env-true").apply { fetcher = { _ -> null } } + + val ctx = User("test") + + with(client) { + assertTrue { enabled(evTrue, ctx) } + assertFalse { enabled(evFalse, ctx) } + assertFalse { enabled(evEmpty, ctx) } + assertFalse { enabled(evNull, ctx) } + } + } +} + +class LaunchDarklyClient { + @Test + fun `verify cloud functionality`() { + val testTrue = Temporary(key = "test-true") + val testFalse = Temporary(key = "test-false", default = true) + val testDne = Temporary(key = "test-dne") + + val ctx = User("test") + + val ldClient: LDClient = mockk() + val flag = slot() + every { + ldClient.boolVariation(capture(flag), any(), any()) + } answers { + when (flag.captured) { + testTrue.key -> true + testFalse.key, testDne.key -> false + else -> throw IllegalArgumentException("${flag.captured} was unexpected") + } + } + + val client: FeatureFlagClient = LaunchDarklyClient(ldClient) + with(client) { + assertTrue { enabled(testTrue, ctx) } + assertFalse { enabled(testFalse, ctx) } + assertFalse { enabled(testDne, ctx) } + } + + verify { + ldClient.boolVariation(testTrue.key, any(), testTrue.default) + ldClient.boolVariation(testFalse.key, any(), testFalse.default) + ldClient.boolVariation(testDne.key, any(), testDne.default) + } + } + + @Test + fun `verify multi-context is not supported`() { + /** + * TODO replace this test once LDv6 is being used and Context.toLDUser no longer exists, to verify Multi support + */ + val ldClient: LDClient = mockk() + every { ldClient.boolVariation(any(), any(), any()) } returns false + + val client: FeatureFlagClient = LaunchDarklyClient(ldClient) + val multiCtx = Multi(listOf(User("test"))) + + assertFailsWith { + client.enabled(Temporary(key = "test"), multiCtx) + } + } + + @Test + fun `verify env-var flag support`() { + val ldClient: LDClient = mockk() + val client: FeatureFlagClient = LaunchDarklyClient(ldClient) + + val evTrue = EnvVar(envVar = "env-true").apply { fetcher = { _ -> "true" } } + val evFalse = EnvVar(envVar = "env-false").apply { fetcher = { _ -> "false" } } + val evEmpty = EnvVar(envVar = "env-empty").apply { fetcher = { _ -> "" } } + val evNull = EnvVar(envVar = "env-null").apply { fetcher = { _ -> null } } + + val ctx = User("test") + + with(client) { + assertTrue { enabled(evTrue, ctx) } + assertFalse { enabled(evFalse, ctx) } + assertFalse { enabled(evEmpty, ctx) } + assertFalse { enabled(evNull, ctx) } + } + + // EnvVar flags should not interact with the LDClient + verify { ldClient wasNot called } + } + + @Test + fun `verify ANONYMOUS context support`() { + val testFlag = Temporary(key = "test-true") + val ctxAnon = Workspace(ANONYMOUS) + + val ldClient: LDClient = mockk() + val context = slot() + every { + ldClient.boolVariation(testFlag.key, capture(context), any()) + } answers { + true + } + + LaunchDarklyClient(ldClient).enabled(testFlag, ctxAnon) + assertTrue(context.captured.isAnonymous) + } +} + +class TestClient { + @Test + fun `verify functionality`() { + val testTrue = Pair(Temporary(key = "test-true"), true) + val testFalse = Pair(Temporary(key = "test-false", default = true), false) + val testDne = Temporary(key = "test-dne") + + val ctx = User("test") + val values: MutableMap = mutableMapOf(testTrue, testFalse) + .mapKeys { it.key.key } + .toMutableMap() + + val client: FeatureFlagClient = TestClient(values) + with(client) { + assertTrue { enabled(testTrue.first, ctx) } + assertFalse { enabled(testFalse.first, ctx) } + assertFalse { enabled(testDne, ctx) } + } + + // modify the value, ensure the client reports the new modified value + values[testTrue.first.key] = false + values[testFalse.first.key] = true + + with(client) { + assertFalse { enabled(testTrue.first, ctx) } + assertTrue { enabled(testFalse.first, ctx) } + assertFalse("undefined flags should always return false") { enabled(testDne, ctx) } + } + } + + @Test + fun `verify env-var flag support`() { + val evTrue = EnvVar(envVar = "env-true") + val evFalse = EnvVar(envVar = "env-false") + val evEmpty = EnvVar(envVar = "env-empty") + + val ctx = User("test") + + val values = mutableMapOf( + evTrue.key to true, + evFalse.key to false, + ) + val client: FeatureFlagClient = TestClient(values) + + with(client) { + assertTrue { enabled(evTrue, ctx) } + assertFalse { enabled(evFalse, ctx) } + assertFalse { enabled(evEmpty, ctx) } + } + + // modify the value, ensure the client reports the new modified value + values[evTrue.key] = false + values[evFalse.key] = true + + with(client) { + assertFalse { enabled(evTrue, ctx) } + assertTrue { enabled(evFalse, ctx) } + assertFalse("undefined flags should always return false") { enabled(evEmpty, ctx) } + } + } +} diff --git a/airbyte-featureflag/src/test/kotlin/ContextTest.kt b/airbyte-featureflag/src/test/kotlin/ContextTest.kt new file mode 100644 index 000000000000..042104d9d220 --- /dev/null +++ b/airbyte-featureflag/src/test/kotlin/ContextTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023 Airbyte, Inc., all rights reserved. + */ + +import io.airbyte.featureflag.Connection +import io.airbyte.featureflag.Destination +import io.airbyte.featureflag.Multi +import io.airbyte.featureflag.Source +import io.airbyte.featureflag.User +import io.airbyte.featureflag.Workspace +import org.junit.jupiter.api.Test + +class MultiTest { + @Test + fun `verify data`() { + val user = User("user-id") + val workspace = Workspace("workspace") + + Multi(listOf(user, workspace)).also { + assert(it.kind == "multi") + assert(it.key == "") + } + } +} + +class WorkspaceTest { + @Test + fun `verify data`() { + Workspace("workspace key", "user").also { + assert(it.kind == "workspace") + assert(it.key == "workspace key") + assert(it.user == "user") + } + } +} + +class UserTest { + @Test + fun `verify data`() { + User("user key").also { + assert(it.kind == "user") + assert(it.key == "user key") + } + } +} + +class ConnectionTest { + @Test + fun `verify data`() { + Connection("connection key").also { + assert(it.kind == "connection") + assert(it.key == "connection key") + } + } +} + +class SourceTest { + @Test + fun `verify data`() { + Source("source key").also { + assert(it.kind == "source") + assert(it.key == "source key") + } + } +} + +class DestinationTest { + @Test + fun `verify data`() { + Destination("destination key").also { + assert(it.kind == "destination") + assert(it.key == "destination key") + } + } +} diff --git a/airbyte-featureflag/src/test/kotlin/EnvVarTest.kt b/airbyte-featureflag/src/test/kotlin/EnvVarTest.kt new file mode 100644 index 000000000000..c4a8b0a12b39 --- /dev/null +++ b/airbyte-featureflag/src/test/kotlin/EnvVarTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 Airbyte, Inc., all rights reserved. + */ + +import io.airbyte.featureflag.EnvVar +import io.airbyte.featureflag.User +import org.junit.jupiter.api.Test + +class EnvVarTest { + @Test + fun `verify undefined flag returns default`() { + val ctx = User("") + // defaults to false, undefined in env-var + EnvVar(envVar = "undefined").apply { fetcher = { null } }.also { + assert(!it.enabled(ctx)) + } + // defaults to true, undefined in env-var + EnvVar(envVar = "undefined", default = true).apply { fetcher = { null } }.also { + assert(it.enabled(ctx)) + } + } + + @Test + fun `verify defined flag variable returns defined value`() { + val envTrue = Pair("TEST_ENV_000", "true") + val envFalse = Pair("TEST_ENV_001", "false") + val envX = Pair("TEST_ENV_001", "x") + + val envVars = mapOf(envTrue, envFalse, envX) + val testFetcher = { s: String -> envVars[s] } + val ctx = User("") + + // defaults to false, but defined as true in the env-var + EnvVar(envVar = envTrue.first) + .apply { fetcher = testFetcher } + .also { assert(it.enabled(ctx)) } + + // defaults to false, also defined as false in the env-var + EnvVar(envVar = envFalse.first) + .apply { fetcher = testFetcher } + .also { assert(!it.enabled(ctx)) } + + // defaults to true, but defined as false in the env-var + EnvVar(envVar = envFalse.first, default = true) + .apply { fetcher = testFetcher } + .also { assert(!it.enabled(ctx)) } + + // defaults to false, but defined incorrectly in env-var + EnvVar(envVar = envX.first) + .apply { fetcher = testFetcher } + .also { assert(!it.enabled(ctx)) } + + // defaults to true, but defined incorrectly in env-var + EnvVar(envVar = envX.first, default = true) + .apply { fetcher = testFetcher } + .also { + // any value that is defined but not defined explicitly as "true" will return false + assert(!it.enabled(ctx)) + } + } +} diff --git a/airbyte-featureflag/src/test/kotlin/FlagsTest.kt b/airbyte-featureflag/src/test/kotlin/FlagsTest.kt new file mode 100644 index 000000000000..a66b3485014b --- /dev/null +++ b/airbyte-featureflag/src/test/kotlin/FlagsTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 Airbyte, Inc., all rights reserved. + */ + +import io.airbyte.featureflag.FieldSelectionWorkspaces +import io.airbyte.featureflag.Multi +import io.airbyte.featureflag.User +import io.airbyte.featureflag.Workspace +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FieldSelectionWorkspacesTest { + /** + * Used for tracking the default fetcher on the [FieldSelectionWorkspaces]. + * As this test modifies the [FieldSelectionWorkspaces] fetcher object, it also needs + * to ensure that it resets it back to its default state. + */ + private lateinit var fetcher: (String) -> String? + + @BeforeEach + fun setup() { + fetcher = FieldSelectionWorkspaces.fetcher + } + + @AfterEach + fun teardown() { + FieldSelectionWorkspaces.fetcher = fetcher + } + + @Test + fun `happy path`() { + val workspaceIds = listOf("0000", "0001", "0002") + FieldSelectionWorkspaces.fetcher = { workspaceIds.joinToString() } + + // true if matching workspace + FieldSelectionWorkspaces.enabled(Workspace("0000")) + .also { assertTrue(it) } + + // true if matching workspace in multi + FieldSelectionWorkspaces.enabled(Multi(listOf(User("0000"), Workspace("0000")))) + .also { assertTrue(it) } + + // false if no matching workspace + FieldSelectionWorkspaces.enabled(Workspace("1111")) + .also { assertFalse(it) } + + // false if incorrect type + FieldSelectionWorkspaces.enabled(User("0000")) + .also { assertFalse(it) } + + // false if matching workspace in multi + FieldSelectionWorkspaces.enabled(Multi(listOf(User("0000"), Workspace("1111")))) + .also { assertFalse(it) } + + } +} diff --git a/airbyte-featureflag/src/test/kotlin/config/FactoryTest.kt b/airbyte-featureflag/src/test/kotlin/config/FactoryTest.kt new file mode 100644 index 000000000000..9124d7e389ab --- /dev/null +++ b/airbyte-featureflag/src/test/kotlin/config/FactoryTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 Airbyte, Inc., all rights reserved. + */ + +package config + +/** + * TODO: this currently fails with the following error: + * @MicronautTest used on test but no bean definition for the test present. This error indicates a misconfigured build or IDE. + * Please add the 'micronaut-inject-java' annotation processor to your test processor path + * + * I have verified that everything _should_ be configured as expected, but I cannot get around this issue, so punting on it for now. + */ +//@MicronautTest(propertySources = ["classpath:app-cloud.yml"]) +//class FactoryTest { +// @Inject +// @Property(name = CONFIG_LD_KEY) +// lateinit var apiKey: String +// +// @Inject +// lateinit var client: Client +// +// @Test +// fun `verify correct api is provided`() { +// assert(apiKey == "example-api-key") +// } +// +// @Test +// fun `verify cloud client is returned`() { +// assert(client != null) +// } +// +// @MockBean(LDClient::class) +// fun ldClient(): LDClient { +// val client = mockk() +// every { client.boolVariation(any(), any(), any()) } returns true +// every { client.boolVariation(any(), any(), any()) } returns true +// return client +// } +//} \ No newline at end of file diff --git a/airbyte-featureflag/src/test/resources/app-cloud.yml b/airbyte-featureflag/src/test/resources/app-cloud.yml new file mode 100644 index 000000000000..d7b8d6291ab6 --- /dev/null +++ b/airbyte-featureflag/src/test/resources/app-cloud.yml @@ -0,0 +1,3 @@ +airbyte: + feature-flag: + api-key: "example-api-key" diff --git a/airbyte-featureflag/src/test/resources/app-platform.yml b/airbyte-featureflag/src/test/resources/app-platform.yml new file mode 100644 index 000000000000..e213cbe125a3 --- /dev/null +++ b/airbyte-featureflag/src/test/resources/app-platform.yml @@ -0,0 +1,3 @@ +airbyte: + feature-flag: + path: /test.yaml diff --git a/airbyte-featureflag/src/test/resources/feature-flags.yml b/airbyte-featureflag/src/test/resources/feature-flags.yml new file mode 100644 index 000000000000..8fd4d45d6372 --- /dev/null +++ b/airbyte-featureflag/src/test/resources/feature-flags.yml @@ -0,0 +1,5 @@ +flags: + - name: test-true + enabled: true + - name: test-false + enabled: false diff --git a/deps.toml b/deps.toml index 8d7a984d9a20..f3a642d9bab4 100644 --- a/deps.toml +++ b/deps.toml @@ -66,6 +66,7 @@ jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "fasterxml_version" } jackson-dataformat = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "fasterxml_version" } jackson-datatype = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "fasterxml_version" } +jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "fasterxml_version" } java-dogstatsd-client = { module = "com.datadoghq:java-dogstatsd-client", version = "4.1.0" } javax-databind = { module = "javax.xml.bind:jaxb-api", version = "2.4.0-b180830.0359" } jcl-over-slf4j = { module = "org.slf4j:jcl-over-slf4j", version.ref = "slf4j" } @@ -80,6 +81,7 @@ junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", vers junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit-jupiter" } junit-jupiter-system-stubs = { module = "uk.org.webcompere:system-stubs-jupiter", version = "2.0.1" } junit-pioneer = { module = "org.junit-pioneer:junit-pioneer", version = "1.7.1" } +launchdarkly = { module = "com.launchdarkly:launchdarkly-java-server-sdk", version = "6.0.1" } log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j" } log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j" } log4j-impl = { module = "org.apache.logging.log4j:log4j-slf4j-impl", version.ref = "log4j" } @@ -88,6 +90,7 @@ log4j-web = { module = "org.apache.logging.log4j:log4j-web", version.ref = "log4 lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } micrometer-statsd = { module = "io.micrometer:micrometer-registry-statsd", version = "1.9.3" } mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version = "4.6.1" } +mockk = { module = "io.mockk:mockk", version = "1.13.3" } otel-bom = { module = "io.opentelemetry:opentelemetry-bom", version = "1.14.0" } otel-sdk = { module = "io.opentelemetry:opentelemetry-sdk-metrics", version = "1.14.0" } otel-sdk-testing = { module = "io.opentelemetry:opentelemetry-sdk-metrics-testing", version = "1.13.0-alpha" } diff --git a/settings.gradle b/settings.gradle index ba9e30251976..4b698a1bafef 100644 --- a/settings.gradle +++ b/settings.gradle @@ -110,6 +110,7 @@ if (!System.getenv().containsKey("SUB_BUILD") || System.getenv().get("SUB_BUILD" include ':airbyte-connector-builder-server' include ':airbyte-container-orchestrator' include ':airbyte-cron' + include ':airbyte-featureflag' include ':airbyte-metrics:reporter' include ':airbyte-proxy' include ':airbyte-server'