Skip to content

Commit 23054e8

Browse files
Auto-start Cosmos emulator via Testcontainers (#37999)
Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
1 parent dd9da57 commit 23054e8

File tree

11 files changed

+154
-139
lines changed

11 files changed

+154
-139
lines changed

.agents/skills/cosmos-provider/SKILL.md

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,7 @@ Non-relational provider with its own parallel query pipeline. Uses JSON for docu
2121
- `ETag` for optimistic concurrency
2222
- No cross-container joins
2323

24-
## Azure Cosmos DB Emulator in Docker
24+
## Azure Cosmos DB Emulator for Tests
2525

26-
Cosmos tests on Helix start the emulator from the work item via `PreCommands` that run a Docker container using:
27-
- `eng/testing/run-cosmos-container.ps1`
28-
- `eng/testing/run-cosmos-container.sh`
29-
30-
These scripts can be invoked locally for testing on machines that don't have the emulator installed, but have docker available.
31-
32-
The `Test__Cosmos__SkipConnectionCheck=true` env var is set to prevent tests from being skipped when the emulator failed to start.
26+
- `TestEnvironment.InitializeAsync()` auto-starts a `Testcontainers.CosmosDb` container when `Test__Cosmos__DefaultConnection` is not set. Set the env var to use an existing emulator instead.
27+
- Skip tests requiring unsupported features on the Linux emulator with `[CosmosCondition(CosmosCondition.IsNotLinuxEmulator)]`.

.github/workflows/copilot-setup-steps.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ jobs:
6363
- name: Export environment variables for the agent's session
6464
run: |
6565
echo "Test__SqlServer__DefaultConnection=Server=localhost;Database=test;User=SA;Password=PLACEHOLDERPass$$w0rd;Connect Timeout=60;ConnectRetryCount=0;Trust Server Certificate=true" >> "$GITHUB_ENV"
66+
echo "Test__Cosmos__DefaultConnection=https://localhost:8081" >> "$GITHUB_ENV"
6667
echo "Test__Cosmos__EmulatorType=linux" >> "$GITHUB_ENV"
68+
echo "Test__Cosmos__SkipConnectionCheck=true" >> "$GITHUB_ENV"
6769
echo "DOTNET_ROOT=$PWD/.dotnet/" >> "$GITHUB_ENV"
6870
echo "$PWD/.dotnet/" >> $GITHUB_PATH

eng/helix.proj

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,12 @@
6464
<PreCommands>$(PreCommands); SqlLocalDB start</PreCommands>
6565
</XUnitProject>
6666
</ItemGroup>
67-
67+
6868
<ItemGroup Condition = "'$(HelixTargetQueue.StartsWith(`Windows.11`))'">
69+
<XUnitProject Remove="$(CosmosTests)"/>
70+
</ItemGroup>
71+
72+
<ItemGroup Condition = "'$(HelixTargetQueue.StartsWith(`Windows.Server2025.Amd64`))'">
6973
<XUnitProject Update="$(CosmosTests)">
7074
<PreCommands>$(PreCommands); set Test__Cosmos__SkipConnectionCheck=true</PreCommands>
7175
</XUnitProject>
@@ -80,14 +84,11 @@
8084
</XUnitProject>
8185
</ItemGroup>
8286

83-
<!-- Start Cosmos emulator in Docker on Ubuntu and only run Cosmos tests -->
87+
<!-- Run Cosmos tests on Ubuntu with Docker support (testcontainer auto-starts the emulator) -->
8488
<ItemGroup Condition = "'$(HelixTargetQueue)' == 'Ubuntu.2204.Amd64.XL.Open' OR '$(HelixTargetQueue)' == 'Ubuntu.2204.Amd64.XL'">
8589
<XUnitProject Remove="$(RepoRoot)/test/**/*.csproj"/>
8690
<XUnitProject Remove="$(RepoRoot)/test/**/*.fsproj"/>
87-
<XUnitProject Include="$(CosmosTests)">
88-
<PreCommands>$(PreCommands); chmod +x $HELIX_CORRELATION_PAYLOAD/testing/run-cosmos-container.sh; $HELIX_CORRELATION_PAYLOAD/testing/run-cosmos-container.sh; export Test__Cosmos__DefaultConnection=https://localhost:8081; export Test__Cosmos__SkipConnectionCheck=true; export Test__Cosmos__EmulatorType=linux</PreCommands>
89-
<PostCommands>$(PostCommands); docker stop cosmos-emulator || true; docker rm -f cosmos-emulator || true</PostCommands>
90-
</XUnitProject>
91+
<XUnitProject Include="$(CosmosTests)"/>
9192
</ItemGroup>
9293

9394
<!-- Run tests that don't need SqlServer or Cosmos on bare Ubuntu -->

eng/testing/run-cosmos-container.ps1

Lines changed: 0 additions & 61 deletions
This file was deleted.

eng/testing/run-cosmos-container.sh

Lines changed: 0 additions & 51 deletions
This file was deleted.

test/Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@
1919
<PackageVersion Include="SQLite3MC.PCLRaw.bundle" Version="$(SQLite3MCPCLRawBundleVersion)" />
2020
<PackageVersion Include="SQLitePCLRaw.provider.sqlite3" Version="$(SQLitePCLRawVersion)" />
2121
<PackageVersion Include="SQLitePCLRaw.provider.winsqlite3" Version="$(SQLitePCLRawVersion)" />
22+
<PackageVersion Include="Testcontainers.CosmosDb" Version="4.11.0" />
2223
</ItemGroup>
2324
</Project>

test/EFCore.Cosmos.FunctionalTests/EFCore.Cosmos.FunctionalTests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
7878
<PackageReference Include="Azure.Identity" />
7979
<PackageReference Include="Azure.ResourceManager.CosmosDB" />
80+
<PackageReference Include="Testcontainers.CosmosDb" />
8081
</ItemGroup>
8182

8283
</Project>

test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosDbContextOptionsBuilderExtensions.cs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,25 @@ namespace Microsoft.EntityFrameworkCore.TestUtilities;
77

88
public static class CosmosDbContextOptionsBuilderExtensions
99
{
10+
private static HttpMessageHandler? _handler;
11+
private static Func<HttpClient>? _httpClientFactory;
12+
1013
public static CosmosDbContextOptionsBuilder ApplyConfiguration(this CosmosDbContextOptionsBuilder optionsBuilder)
1114
{
15+
if (_httpClientFactory == null)
16+
{
17+
_handler = TestEnvironment.HttpMessageHandler
18+
?? new HttpClientHandler
19+
{
20+
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
21+
};
22+
_httpClientFactory = () => new HttpClient(_handler, disposeHandler: false);
23+
}
24+
1225
optionsBuilder
1326
.ExecutionStrategy(d => new TestCosmosExecutionStrategy(d))
1427
.RequestTimeout(TimeSpan.FromMinutes(20))
15-
.HttpClientFactory(() => new HttpClient(
16-
new HttpClientHandler
17-
{
18-
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
19-
}))
28+
.HttpClientFactory(_httpClientFactory)
2029
.ConnectionMode(ConnectionMode.Gateway);
2130

2231
return optionsBuilder;

test/EFCore.Cosmos.FunctionalTests/TestUtilities/CosmosTestStore.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@ private static string CreateName(string name)
6565
? name
6666
: name + _runId;
6767

68-
public string ConnectionUri { get; }
68+
public string ConnectionUri { get; private set; }
6969
public string AuthToken { get; }
7070
public TokenCredential TokenCredential { get; }
71-
public string ConnectionString { get; }
71+
public string ConnectionString { get; private set; }
7272

7373
private static readonly SemaphoreSlim _connectionSemaphore = new(1, 1);
7474

@@ -161,6 +161,12 @@ private static bool IsNotConfigured(Exception exception)
161161

162162
protected override async Task InitializeAsync(Func<DbContext> createContext, Func<DbContext, Task>? seed, Func<DbContext, Task>? clean)
163163
{
164+
await TestEnvironment.InitializeAsync().ConfigureAwait(false);
165+
166+
// Update connection details in case InitializeAsync changed them (e.g., testcontainer started).
167+
ConnectionUri = TestEnvironment.DefaultConnection;
168+
ConnectionString = TestEnvironment.ConnectionString;
169+
164170
_initialized = true;
165171

166172
if (_connectionAvailable == false)

test/EFCore.Cosmos.FunctionalTests/TestUtilities/TestEnvironment.cs

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Azure.Core;
55
using Azure.Identity;
66
using Microsoft.Extensions.Configuration;
7+
using Testcontainers.CosmosDb;
78

89
namespace Microsoft.EntityFrameworkCore.TestUtilities;
910

@@ -22,15 +23,124 @@ public static class TestEnvironment
2223
.Build()
2324
.GetSection("Test:Cosmos");
2425

25-
public static string DefaultConnection { get; } = string.IsNullOrEmpty(Config["DefaultConnection"])
26+
private static CosmosDbContainer _container;
27+
private static bool _initialized;
28+
private static readonly SemaphoreSlim _initSemaphore = new(1, 1);
29+
30+
public static string DefaultConnection { get; private set; } = string.IsNullOrEmpty(Config["DefaultConnection"])
2631
? "https://localhost:8081"
2732
: Config["DefaultConnection"];
2833

34+
internal static HttpMessageHandler HttpMessageHandler { get; private set; }
35+
36+
public static async Task InitializeAsync()
37+
{
38+
if (_initialized)
39+
{
40+
return;
41+
}
42+
43+
await _initSemaphore.WaitAsync().ConfigureAwait(false);
44+
45+
try
46+
{
47+
if (_initialized)
48+
{
49+
return;
50+
}
51+
52+
// If a connection string is specified (env var, config.json...), always use that.
53+
var configured = Config["DefaultConnection"];
54+
if (!string.IsNullOrEmpty(configured))
55+
{
56+
DefaultConnection = configured;
57+
_initialized = true;
58+
return;
59+
}
60+
61+
// Try to connect to the default emulator endpoint (e.g. Windows emulator or
62+
// a manually-started Docker container).
63+
if (await TryProbeEmulatorAsync("https://localhost:8081").ConfigureAwait(false))
64+
{
65+
DefaultConnection = "https://localhost:8081";
66+
_initialized = true;
67+
return;
68+
}
69+
70+
// Start a testcontainer with the Linux emulator.
71+
CosmosDbContainer container;
72+
try
73+
{
74+
container = new CosmosDbBuilder("mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview")
75+
.Build();
76+
await container.StartAsync().ConfigureAwait(false);
77+
}
78+
catch (Exception ex)
79+
{
80+
throw new InvalidOperationException(
81+
"Failed to start the Cosmos DB emulator testcontainer. "
82+
+ "Ensure that either the Cosmos DB emulator is running on localhost:8081, "
83+
+ "or Docker is installed and running, "
84+
+ "or set the 'Test__Cosmos__DefaultConnection' environment variable to connect to "
85+
+ "an existing emulator or Cosmos DB instance.",
86+
ex);
87+
}
88+
89+
_container = container;
90+
91+
AppDomain.CurrentDomain.ProcessExit += (_, _) =>
92+
{
93+
try
94+
{
95+
_container.DisposeAsync().AsTask().GetAwaiter().GetResult();
96+
}
97+
catch
98+
{
99+
// Best-effort cleanup: container may already be stopped or Docker daemon
100+
// may have exited before the process exit handler runs.
101+
}
102+
};
103+
104+
DefaultConnection = new UriBuilder(
105+
Uri.UriSchemeHttp,
106+
_container.Hostname,
107+
_container.GetMappedPublicPort(CosmosDbBuilder.CosmosDbPort)).ToString();
108+
HttpMessageHandler = _container.HttpMessageHandler;
109+
110+
_initialized = true;
111+
}
112+
finally
113+
{
114+
_initSemaphore.Release();
115+
}
116+
}
117+
118+
private static async Task<bool> TryProbeEmulatorAsync(string endpoint)
119+
{
120+
try
121+
{
122+
using var handler = new HttpClientHandler
123+
{
124+
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
125+
};
126+
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(3) };
127+
// Any successful response (even 401) means the emulator is up and accepting connections.
128+
using var response = await client.GetAsync(endpoint).ConfigureAwait(false);
129+
return true;
130+
}
131+
catch
132+
{
133+
// Expected: HttpRequestException (connection refused), TaskCanceledException (timeout),
134+
// or SocketException when the emulator is not running.
135+
return false;
136+
}
137+
}
138+
29139
public static string AuthToken { get; } = string.IsNullOrEmpty(Config["AuthToken"])
30140
? _emulatorAuthToken
31141
: Config["AuthToken"];
32142

33-
public static string ConnectionString { get; } = $"AccountEndpoint={DefaultConnection};AccountKey={AuthToken}";
143+
public static string ConnectionString => $"AccountEndpoint={DefaultConnection};AccountKey={AuthToken}";
34144

35145
public static bool UseTokenCredential { get; } = string.Equals(Config["UseTokenCredential"], "true", StringComparison.OrdinalIgnoreCase);
36146

@@ -45,12 +155,14 @@ public static class TestEnvironment
45155
? AzureLocation.WestUS
46156
: Enum.Parse<AzureLocation>(Config["AzureLocation"]);
47157

48-
public static bool IsEmulator { get; } = !UseTokenCredential && (AuthToken == _emulatorAuthToken);
158+
public static bool IsEmulator => !UseTokenCredential && (AuthToken == _emulatorAuthToken);
49159

50160
public static bool SkipConnectionCheck { get; } = string.Equals(Config["SkipConnectionCheck"], "true", StringComparison.OrdinalIgnoreCase);
51161

52-
public static string EmulatorType { get; } = Config["EmulatorType"] ?? (!OperatingSystem.IsWindows() ? "linux" : "");
162+
public static string EmulatorType => _container != null
163+
? "linux"
164+
: Config["EmulatorType"] ?? (!OperatingSystem.IsWindows() ? "linux" : "");
53165

54-
public static bool IsLinuxEmulator { get; } = IsEmulator
166+
public static bool IsLinuxEmulator => IsEmulator
55167
&& EmulatorType.Equals("linux", StringComparison.OrdinalIgnoreCase);
56168
}

0 commit comments

Comments
 (0)