Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 104 additions & 21 deletions src/Dapr.Testcontainers/Common/Testing/DaprTestApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public sealed class DaprTestApplicationBuilder(BaseHarness harness)
{
private Action<WebApplicationBuilder>? _configureServices;
private Action<WebApplication>? _configureApp;
private bool _shouldLoadResourcesFirst = true;

/// <summary>
/// Configures services for the test application.
Expand All @@ -48,39 +49,121 @@ public DaprTestApplicationBuilder ConfigureApp(Action<WebApplication> configure)
return this;
}

/// <summary>
/// Configures the startup order of Dapr resources and the application.
/// </summary>
/// <param name="shouldLoadResourcesFirst">
/// If true (default), Dapr container starts before the app. If false, the
/// app starts before the Dapr container.
/// </param>
public DaprTestApplicationBuilder WithDaprStartupOrder(bool shouldLoadResourcesFirst)
{
_shouldLoadResourcesFirst = shouldLoadResourcesFirst;
return this;
}

/// <summary>
/// Builds and starts the test application and harness.
/// </summary>
/// <returns></returns>
public async Task<DaprTestApplication> BuildAndStartAsync()
{
await harness.InitializeAsync();

WebApplication? app = null;
if (_configureServices is not null || _configureApp is not null)

if (_shouldLoadResourcesFirst)
{
var builder = WebApplication.CreateBuilder();

// Configure Dapr endpoints via in-memory configuration instead of environment variables
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
// Load the harness and resources, then the app
await harness.InitializeAsync();

if (_configureServices is not null || _configureApp is not null)
{
{ "DAPR_HTTP_ENDPOINT", $"http://127.0.0.1:{harness.DaprHttpPort}" },
{ "DAPR_GRPC_ENDPOINT", $"http://127.0.0.1:{harness.DaprGrpcPort}" }
});

builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole();
builder.WebHost.UseUrls($"http://0.0.0.0:{harness.AppPort}");

_configureServices?.Invoke(builder);
app = CreateApp();
await app.StartAsync();
}

app = builder.Build();

_configureApp?.Invoke(app);
return new DaprTestApplication(harness, app);
}

// App-first: start app, then start resources
// If daprd cannot bind the chosen ports, restart the app with new ports
const int maxAttempts = 5;
Exception? lastError = null;

for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
WebApplication? attemptApp = null;

try
{
// Pre-assign prots for the app knows where Dapr will be (avoid collisions)
var httpPort = PortUtilities.GetAvailablePort();
var grpcPort = PortUtilities.GetAvailablePort();
while (grpcPort == httpPort)
grpcPort = PortUtilities.GetAvailablePort();

var appPort = PortUtilities.GetAvailablePort();
while (appPort == httpPort || appPort == grpcPort)
appPort = PortUtilities.GetAvailablePort();

harness.SetPorts(httpPort, grpcPort);
harness.SetAppPort(appPort);

// Load the app (configuration/services/pipeline), but delay StartAsync until daprd is up
if (_configureServices is not null || _configureApp is not null)
{
attemptApp = CreateApp();
await attemptApp.StartAsync();
}

await harness.InitializeAsync();

return new DaprTestApplication(harness, attemptApp);
}
catch (Exception ex)
{
lastError = ex;

await app.StartAsync();
if (attemptApp is not null)
{
try
{
await attemptApp.StopAsync();
}
finally
{
await attemptApp.DisposeAsync();
}
}

// Try again with a frest set of ports
}
}

return new DaprTestApplication(harness, app);
throw new InvalidOperationException(
$"Failed to start app-first Dapr test application after {maxAttempts} attempts.", lastError);
}

private WebApplication CreateApp()
{
var builder = WebApplication.CreateBuilder();

// Configure Dapr endpoints via in-memory configuration instead of environment variables
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "DAPR_HTTP_ENDPOINT", $"http://127.0.0.1:{harness.DaprHttpPort}" },
{ "DAPR_GRPC_ENDPOINT", $"http://127.0.0.1:{harness.DaprGrpcPort}" }
});

builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole();
builder.WebHost.UseUrls($"http://0.0.0.0:{harness.AppPort}");

_configureServices?.Invoke(builder);

var app = builder.Build();

_configureApp?.Invoke(app);

return app;
}
}
95 changes: 87 additions & 8 deletions src/Dapr.Testcontainers/Containers/Dapr/DaprdContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

using System;
using System.Collections.Generic;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Dapr.Testcontainers.Common;
Expand Down Expand Up @@ -48,6 +49,9 @@ public sealed class DaprdContainer : IAsyncStartable
/// </summary>
public int GrpcPort { get; private set; }

private readonly int? _requestedHttpPort;
private readonly int? _requestedGrpcPort;

/// <summary>
/// The hostname to locate the Dapr runtime on in the shared Docker network.
/// </summary>
Expand All @@ -62,8 +66,22 @@ public sealed class DaprdContainer : IAsyncStartable
/// <param name="network">The shared Docker network to connect to.</param>
/// <param name="placementHostAndPort">The hostname and port of the Placement service.</param>
/// <param name="schedulerHostAndPort">The hostname and port of the Scheduler service.</param>
public DaprdContainer(string appId, string componentsHostFolder, DaprRuntimeOptions options, INetwork network, HostPortPair? placementHostAndPort = null, HostPortPair? schedulerHostAndPort = null)
/// <param name="daprHttpPort">The host HTTP port to bind to.</param>
/// <param name="daprGrpcPort">The host gRPC port to bind to.</param>
public DaprdContainer(
string appId,
string componentsHostFolder,
DaprRuntimeOptions options,
INetwork network,
HostPortPair? placementHostAndPort = null,
HostPortPair? schedulerHostAndPort = null,
int? daprHttpPort = null,
int? daprGrpcPort = null
)
{
_requestedHttpPort = daprHttpPort;
_requestedGrpcPort = daprGrpcPort;

const string componentsPath = "/components";
var cmd =
new List<string>
Expand Down Expand Up @@ -102,28 +120,89 @@ public DaprdContainer(string appId, string componentsHostFolder, DaprRuntimeOpti
cmd.Add("");
}

_container = new ContainerBuilder()
var containerBuilder = new ContainerBuilder()
.WithImage(options.RuntimeImageTag)
.WithName(_containerName)
.WithLogger(ConsoleLogger.Instance)
.WithCommand(cmd.ToArray())
.WithNetwork(network)
.WithExtraHost(ContainerHostAlias, "host-gateway")
.WithPortBinding(InternalHttpPort, assignRandomHostPort: true)
.WithPortBinding(InternalGrpcPort, assignRandomHostPort: true)
.WithBindMount(componentsHostFolder, componentsPath, AccessMode.ReadOnly)
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilMessageIsLogged("Internal gRPC server is running"))
.UntilMessageIsLogged("Internal gRPC server is running"));
//.UntilMessageIsLogged(@"^dapr initialized. Status: Running. Init Elapsed "))
.Build();

containerBuilder = daprHttpPort is not null ? containerBuilder.WithPortBinding(containerPort: InternalHttpPort, hostPort: daprHttpPort.Value) : containerBuilder.WithPortBinding(port: InternalHttpPort, assignRandomHostPort: true);
containerBuilder = daprGrpcPort is not null ? containerBuilder.WithPortBinding(containerPort: InternalGrpcPort, hostPort: daprGrpcPort.Value) : containerBuilder.WithPortBinding(port: InternalGrpcPort, assignRandomHostPort: true);

_container = containerBuilder.Build();
}

/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken = default)
{
await _container.StartAsync(cancellationToken);
HttpPort = _container.GetMappedPublicPort(InternalHttpPort);
GrpcPort = _container.GetMappedPublicPort(InternalGrpcPort);

var mappedHttpPort = _container.GetMappedPublicPort(InternalHttpPort);
var mappedGrpcPort = _container.GetMappedPublicPort(InternalGrpcPort);

if (_requestedHttpPort is not null && mappedHttpPort != _requestedHttpPort.Value)
{
throw new InvalidOperationException(
$"Dapr HTTP port mapping mismatch. Requested {_requestedHttpPort.Value}, but Docker mapped {mappedHttpPort}");
}

if (_requestedGrpcPort is not null && mappedGrpcPort != _requestedGrpcPort.Value)
{
throw new InvalidOperationException(
$"Dapr gRPC port mapping mismatch. Requested {_requestedGrpcPort.Value}, but Docker mapped {mappedGrpcPort}");
}

HttpPort = mappedHttpPort;
GrpcPort = mappedGrpcPort;

// The container log wait strategy can fire before the host port is actually accepting connections
// (especially on Windows). Ensure the ports are reachable from the test process.
await WaitForTcpPortAsync("127.0.0.1", HttpPort, TimeSpan.FromSeconds(30), cancellationToken);
await WaitForTcpPortAsync("127.0.0.1", GrpcPort, TimeSpan.FromSeconds(30), cancellationToken);
}

private static async Task WaitForTcpPortAsync(
string host,
int port,
TimeSpan timeout,
CancellationToken cancellationToken)
{
var start = DateTimeOffset.UtcNow;
Exception? lastError = null;

while (DateTimeOffset.UtcNow - start < timeout)
{
cancellationToken.ThrowIfCancellationRequested();

try
{
using var client = new TcpClient();
var connectTask = client.ConnectAsync(host, port);

var completed = await Task.WhenAny(connectTask,
Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken));
if (completed == connectTask)
{
// Will throw if connect failed
await connectTask;
return;
}
}
catch (Exception ex) when (ex is SocketException or InvalidOperationException)
{
lastError = ex;
}

await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
}

throw new TimeoutException($"Timed out waiting for TCP port {host}:{port} to accept connections.", lastError);
}

/// <inheritdoc />
Expand Down
Loading