diff --git a/.github/actions/contract-tests/action.yml b/.github/actions/contract-tests/action.yml
index 468cd951..35431398 100644
--- a/.github/actions/contract-tests/action.yml
+++ b/.github/actions/contract-tests/action.yml
@@ -11,6 +11,10 @@ inputs:
description: 'Github token, used for contract tests'
required: false
default: ''
+ run_fdv2_tests:
+ description: 'Whether to run contract tests from the feat/fdv2 branch'
+ required: false
+ default: 'false'
runs:
using: composite
@@ -47,3 +51,29 @@ runs:
test_service_port: 8000
extra_params: '-status-timeout=360 -skip-from=${{ env.SUPPRESSION_FILE }}'
token: ${{ inputs.token }}
+
+ - name: Setup Go
+ if: inputs.run_fdv2_tests == 'true'
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.21'
+
+ - name: Launch Contract Tests FDv2 Flavor
+ if: inputs.run_fdv2_tests == 'true'
+ id: launch-contract-tests-fdv2
+ shell: bash
+ run: dotnet ${{ inputs.service_dll_file }} > test-service.log 2>&1 & disown
+
+ - name: Clone and run contract tests from feat/fdv2 branch
+ if: inputs.run_fdv2_tests == 'true'
+ shell: bash
+ run: |
+ mkdir -p /tmp/sdk-test-harness
+ git clone https://github.com/launchdarkly/sdk-test-harness.git /tmp/sdk-test-harness
+ cp $(dirname ./${{ inputs.service_project_file }})/test-supressions-fdv2.txt /tmp/sdk-test-harness/testharness-suppressions-fdv2.txt
+ cd /tmp/sdk-test-harness
+ git checkout feat/fdv2
+ go build -o test-harness .
+ ./test-harness -url http://localhost:8000 -debug -status-timeout=360 --skip-from=testharness-suppressions-fdv2.txt --stop-service-at-end
+ env:
+ GITHUB_TOKEN: ${{ inputs.token }}
diff --git a/.github/workflows/sdk-server-ci.yml b/.github/workflows/sdk-server-ci.yml
index 8a0a78d4..d43b4dc8 100644
--- a/.github/workflows/sdk-server-ci.yml
+++ b/.github/workflows/sdk-server-ci.yml
@@ -36,6 +36,7 @@ jobs:
service_project_file: ${{ env.CONTRACT_TEST_PROJECT_FILE}}
service_dll_file: ${{ env.CONTRACT_TEST_DLL_FILE}}
token: ${{ secrets.GITHUB_TOKEN }}
+ run_fdv2_tests: 'true'
- uses: ./.github/actions/build-docs
with:
diff --git a/pkgs/sdk/server/contract-tests/Representations.cs b/pkgs/sdk/server/contract-tests/Representations.cs
index 8e775bf6..43b3fdf6 100644
--- a/pkgs/sdk/server/contract-tests/Representations.cs
+++ b/pkgs/sdk/server/contract-tests/Representations.cs
@@ -31,6 +31,7 @@ public class SdkConfigParams
public SdkConfigBigSegmentsParams BigSegments { get; set; }
public SdkTagParams Tags { get; set; }
public SdkHookParams Hooks { get; set; }
+ public SdkConfigDataSystemParams DataSystem { get; set; }
}
public class SdkTagParams
@@ -88,6 +89,126 @@ public class SdkConfigBigSegmentsParams
public long? UserCacheTimeMs { get; set; }
}
+ ///
+ /// Constants for store mode values.
+ ///
+ public static class StoreMode
+ {
+ ///
+ /// Read-only mode - the data system will only read from the persistent store.
+ ///
+ public const int Read = 0;
+
+ ///
+ /// Read-write mode - the data system can read from, and write to, the persistent store.
+ ///
+ public const int ReadWrite = 1;
+ }
+
+ ///
+ /// Constants for persistent store type values.
+ ///
+ public static class PersistentStoreType
+ {
+ ///
+ /// Redis persistent store type.
+ ///
+ public const string Redis = "redis";
+
+ ///
+ /// DynamoDB persistent store type.
+ ///
+ public const string DynamoDB = "dynamodb";
+
+ ///
+ /// Consul persistent store type.
+ ///
+ public const string Consul = "consul";
+ }
+
+ ///
+ /// Constants for persistent cache mode values.
+ ///
+ public static class PersistentCacheMode
+ {
+ ///
+ /// Cache disabled mode.
+ ///
+ public const string Off = "off";
+
+ ///
+ /// Time-to-live cache mode with a specified TTL.
+ ///
+ public const string TTL = "ttl";
+
+ ///
+ /// Infinite cache mode - cache forever.
+ ///
+ public const string Infinite = "infinite";
+ }
+
+ public class SdkConfigDataSystemParams
+ {
+ public SdkConfigDataStoreParams Store { get; set; }
+ public int? StoreMode { get; set; }
+ public SdkConfigDataInitializerParams[] Initializers { get; set; }
+ public SdkConfigSynchronizersParams Synchronizers { get; set; }
+ public string PayloadFilter { get; set; }
+ }
+
+ public class SdkConfigDataStoreParams
+ {
+ public SdkConfigPersistentDataStoreParams PersistentDataStore { get; set; }
+ }
+
+ public class SdkConfigPersistentDataStoreParams
+ {
+ public SdkConfigPersistentStoreParams Store { get; set; }
+ public SdkConfigPersistentCacheParams Cache { get; set; }
+ }
+
+ public class SdkConfigPersistentStoreParams
+ {
+ public string Type { get; set; }
+ public string Prefix { get; set; }
+ public string DSN { get; set; }
+ }
+
+ public class SdkConfigPersistentCacheParams
+ {
+ public string Mode { get; set; }
+ public int? TTL { get; set; }
+ }
+
+ public class SdkConfigDataInitializerParams
+ {
+ public SdkConfigPollingParams Polling { get; set; }
+ }
+
+ public class SdkConfigSynchronizersParams
+ {
+ public SdkConfigSynchronizerParams Primary { get; set; }
+ public SdkConfigSynchronizerParams Secondary { get; set; }
+ }
+
+ public class SdkConfigSynchronizerParams
+ {
+ public SdkConfigStreamingParams Streaming { get; set; }
+ public SdkConfigPollingParams Polling { get; set; }
+ }
+
+ public class SdkConfigPollingParams
+ {
+ public Uri BaseUri { get; set; }
+ public long? PollIntervalMs { get; set; }
+ }
+
+ public class SdkConfigStreamingParams
+ {
+ public Uri BaseUri { get; set; }
+ public long? InitialRetryDelayMs { get; set; }
+ }
+
public class CommandParams
{
public string Command { get; set; }
diff --git a/pkgs/sdk/server/contract-tests/SdkClientEntity.cs b/pkgs/sdk/server/contract-tests/SdkClientEntity.cs
index 2c1ca66f..f0d62a8e 100644
--- a/pkgs/sdk/server/contract-tests/SdkClientEntity.cs
+++ b/pkgs/sdk/server/contract-tests/SdkClientEntity.cs
@@ -7,7 +7,9 @@
using LaunchDarkly.Sdk.Json;
using LaunchDarkly.Sdk.Server;
using LaunchDarkly.Sdk.Server.Hooks;
+using LaunchDarkly.Sdk.Server.Integrations;
using LaunchDarkly.Sdk.Server.Migrations;
+using LaunchDarkly.Sdk.Server.Subsystems;
namespace TestService
{
@@ -372,9 +374,200 @@ private static Configuration BuildSdkConfig(SdkConfigParams sdkParams, ILogAdapt
builder.Hooks(Components.Hooks(hooks));
}
+ if (sdkParams.DataSystem != null)
+ {
+ var dataSystemBuilder = Components.DataSystem().Custom();
+
+ // TODO: re-enable this code in the future and determine which dependencies on persistent stores need to be added to contract test build process
+ // Configure persistent store if provided
+ // if (sdkParams.DataSystem.Store?.PersistentDataStore != null)
+ // {
+ // var storeConfig = sdkParams.DataSystem.Store.PersistentDataStore;
+ // var storeType = storeConfig.Store.Type.ToLower();
+ // IComponentConfigurer persistentStore = null;
+
+ // PersistentDataStoreBuilder persistentStoreBuilder = null;
+
+ // switch (storeType)
+ // {
+ // case "redis":
+ // var redisBuilder = Redis.DataStore();
+ // if (!string.IsNullOrEmpty(storeConfig.Store.DSN))
+ // {
+ // redisBuilder.Uri(storeConfig.Store.DSN);
+ // }
+ // if (!string.IsNullOrEmpty(storeConfig.Store.Prefix))
+ // {
+ // redisBuilder.Prefix(storeConfig.Store.Prefix);
+ // }
+ // persistentStoreBuilder = Components.PersistentDataStore(redisBuilder);
+ // break;
+
+ // case "dynamodb":
+ // // For DynamoDB, DSN is the table name
+ // var dynamoBuilder = DynamoDB.DataStore(storeConfig.Store.DSN ?? "sdk-contract-tests");
+ // if (!string.IsNullOrEmpty(storeConfig.Store.Prefix))
+ // {
+ // dynamoBuilder.Prefix(storeConfig.Store.Prefix);
+ // }
+ // // DynamoDB uses IPersistentDataStoreAsync, so use the async overload
+ // persistentStoreBuilder = Components.PersistentDataStore(dynamoBuilder);
+ // break;
+
+ // case "consul":
+ // var consulBuilder = Consul.DataStore();
+ // if (!string.IsNullOrEmpty(storeConfig.Store.DSN))
+ // {
+ // consulBuilder.Address(storeConfig.Store.DSN);
+ // }
+ // if (!string.IsNullOrEmpty(storeConfig.Store.Prefix))
+ // {
+ // consulBuilder.Prefix(storeConfig.Store.Prefix);
+ // }
+ // persistentStoreBuilder = Components.PersistentDataStore(consulBuilder);
+ // break;
+ // }
+
+ // if (persistentStoreBuilder != null)
+ // {
+ // // Configure cache
+ // var cacheMode = storeConfig.Cache?.Mode?.ToLower();
+ // if (cacheMode == "off")
+ // {
+ // persistentStoreBuilder.NoCaching();
+ // }
+ // else if (cacheMode == "ttl" && storeConfig.Cache.TTL.HasValue)
+ // {
+ // persistentStoreBuilder.CacheTime(TimeSpan.FromSeconds(storeConfig.Cache.TTL.Value));
+ // }
+ // else if (cacheMode == "infinite")
+ // {
+ // persistentStoreBuilder.CacheForever();
+ // }
+
+ // // Determine store mode
+ // var storeMode = sdkParams.DataSystem.StoreMode == 0
+ // ? DataSystemConfiguration.DataStoreMode.ReadOnly
+ // : DataSystemConfiguration.DataStoreMode.ReadWrite;
+
+ // dataSystemBuilder.PersistentStore(persistentStoreBuilder, storeMode);
+ // }
+ // }
+
+ // Configure initializers
+ if (sdkParams.DataSystem.Initializers != null && sdkParams.DataSystem.Initializers.Length > 0)
+ {
+ var initializers = new List>();
+ foreach (var initializer in sdkParams.DataSystem.Initializers)
+ {
+ if (initializer.Polling != null)
+ {
+ var pollingBuilder = DataSystemComponents.Polling();
+ if (initializer.Polling.BaseUri != null)
+ {
+ var endpointOverride = Components.ServiceEndpoints().Polling(initializer.Polling.BaseUri);
+ pollingBuilder.ServiceEndpointsOverride(endpointOverride);
+ }
+ if (initializer.Polling.PollIntervalMs.HasValue)
+ {
+ pollingBuilder.PollInterval(TimeSpan.FromMilliseconds(initializer.Polling.PollIntervalMs.Value));
+ }
+ if (!string.IsNullOrEmpty(sdkParams.DataSystem.PayloadFilter))
+ {
+ // PayloadFilter is not yet supported in FDv2 builders, so we skip it for now
+ // TODO: Add PayloadFilter support when available
+ }
+ initializers.Add(pollingBuilder);
+ }
+ }
+ if (initializers.Count > 0)
+ {
+ dataSystemBuilder.Initializers(initializers.ToArray());
+ }
+ }
+
+ // Configure synchronizers
+ if (sdkParams.DataSystem.Synchronizers != null)
+ {
+ var synchronizers = new List>();
+
+ // Primary synchronizer
+ if (sdkParams.DataSystem.Synchronizers.Primary != null)
+ {
+ var primary = CreateSynchronizer(sdkParams.DataSystem.Synchronizers.Primary, sdkParams.DataSystem.PayloadFilter);
+ if (primary != null)
+ {
+ synchronizers.Add(primary);
+ }
+ }
+
+ // Secondary synchronizer (optional)
+ if (sdkParams.DataSystem.Synchronizers.Secondary != null)
+ {
+ var secondary = CreateSynchronizer(sdkParams.DataSystem.Synchronizers.Secondary, sdkParams.DataSystem.PayloadFilter);
+ if (secondary != null)
+ {
+ synchronizers.Add(secondary);
+ }
+ }
+
+ if (synchronizers.Count > 0)
+ {
+ dataSystemBuilder.Synchronizers(synchronizers.ToArray());
+ }
+ }
+
+ builder.DataSystem(dataSystemBuilder);
+ }
+
return builder.Build();
}
+ private static IComponentConfigurer CreateSynchronizer(
+ SdkConfigSynchronizerParams synchronizer,
+ string payloadFilter)
+ {
+ if (synchronizer.Polling != null)
+ {
+ var pollingBuilder = DataSystemComponents.Polling();
+ if (synchronizer.Polling.BaseUri != null)
+ {
+ var endpointOverride = Components.ServiceEndpoints().Polling(synchronizer.Polling.BaseUri);
+ pollingBuilder.ServiceEndpointsOverride(endpointOverride);
+ }
+ if (synchronizer.Polling.PollIntervalMs.HasValue)
+ {
+ pollingBuilder.PollInterval(TimeSpan.FromMilliseconds(synchronizer.Polling.PollIntervalMs.Value));
+ }
+ if (!string.IsNullOrEmpty(payloadFilter))
+ {
+ // PayloadFilter is not yet supported in FDv2 builders, so we skip it for now
+ // TODO: Add PayloadFilter support when available
+ }
+ return pollingBuilder;
+ }
+ else if (synchronizer.Streaming != null)
+ {
+ var streamingBuilder = DataSystemComponents.Streaming();
+ if (synchronizer.Streaming.BaseUri != null)
+ {
+ var endpointOverride = Components.ServiceEndpoints().Streaming(synchronizer.Streaming.BaseUri);
+ streamingBuilder.ServiceEndpointsOverride(endpointOverride);
+ }
+ if (synchronizer.Streaming.InitialRetryDelayMs.HasValue)
+ {
+ streamingBuilder.InitialReconnectDelay(TimeSpan.FromMilliseconds(synchronizer.Streaming.InitialRetryDelayMs.Value));
+ }
+ if (!string.IsNullOrEmpty(payloadFilter))
+ {
+ // PayloadFilter is not yet supported in FDv2 builders, so we skip it for now
+ // TODO: Add PayloadFilter support when available
+ }
+ return streamingBuilder;
+ }
+ return null;
+ }
+
private MigrationVariationResponse DoMigrationVariation(MigrationVariationParams migrationVariation)
{
var defaultStage = MigrationStageExtensions.FromDataModelString(migrationVariation.DefaultStage);
diff --git a/pkgs/sdk/server/contract-tests/test-supressions-fdv2.txt b/pkgs/sdk/server/contract-tests/test-supressions-fdv2.txt
new file mode 100644
index 00000000..241f5661
--- /dev/null
+++ b/pkgs/sdk/server/contract-tests/test-supressions-fdv2.txt
@@ -0,0 +1,10 @@
+streaming/
+evaluation/
+hooks/
+migrations/
+secure mode hash/
+context type/
+tags/
+big segments/
+events/
+service endpoints/
\ No newline at end of file
diff --git a/pkgs/sdk/server/src/Properties/AssemblyInfo.cs b/pkgs/sdk/server/src/Properties/AssemblyInfo.cs
index ab67d42e..fd617be8 100644
--- a/pkgs/sdk/server/src/Properties/AssemblyInfo.cs
+++ b/pkgs/sdk/server/src/Properties/AssemblyInfo.cs
@@ -15,6 +15,9 @@
// tests must be run against the Debug configuration of this assembly)
[assembly: InternalsVisibleTo("LaunchDarkly.ServerSdk.Tests")]
+// Allow contract tests to see internal classes
+[assembly: InternalsVisibleTo("ContractTestService")]
+
// Allow mock/proxy objects in unit tests to access internal classes
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
#endif