diff --git a/src/Dapr.Testcontainers/Common/ContainerLogAttachment.cs b/src/Dapr.Testcontainers/Common/ContainerLogAttachment.cs
new file mode 100644
index 000000000..deabe2ae7
--- /dev/null
+++ b/src/Dapr.Testcontainers/Common/ContainerLogAttachment.cs
@@ -0,0 +1,86 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 The Dapr Authors
+// 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.
+// ------------------------------------------------------------------------
+
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using DotNet.Testcontainers.Builders;
+using DotNet.Testcontainers.Configurations;
+
+namespace Dapr.Testcontainers.Common;
+
+internal sealed class ContainerLogAttachment : IAsyncDisposable
+{
+ private readonly FileStream _stdout;
+ private readonly FileStream _stderr;
+
+ public ContainerLogPaths Paths { get; }
+ public IOutputConsumer OutputConsumer { get; }
+
+ private ContainerLogAttachment(string logDirectory, string serviceName, string containerName)
+ {
+ Directory.CreateDirectory(logDirectory);
+
+ var safeServiceName = SanitizeFileName(serviceName);
+ var safeContainerName = SanitizeFileName(containerName);
+ var baseName = string.IsNullOrWhiteSpace(safeServiceName)
+ ? safeContainerName
+ : $"{safeServiceName}-{safeContainerName}";
+
+ var stdoutPath = Path.Combine(logDirectory, $"{baseName}-stdout.log");
+ var stderrPath = Path.Combine(logDirectory, $"{baseName}-stderr.log");
+
+ _stdout = new FileStream(stdoutPath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite);
+ _stderr = new FileStream(stderrPath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite);
+
+ OutputConsumer = Consume.RedirectStdoutAndStderrToStream(_stdout, _stderr);
+ Paths = new ContainerLogPaths(serviceName, containerName, stdoutPath, stderrPath);
+ }
+
+ public static ContainerLogAttachment? TryCreate(string? logDirectory, string serviceName, string containerName)
+ {
+ if (string.IsNullOrWhiteSpace(logDirectory))
+ {
+ return null;
+ }
+
+ return new ContainerLogAttachment(logDirectory, serviceName, containerName);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _stdout.FlushAsync().ConfigureAwait(false);
+ await _stderr.FlushAsync().ConfigureAwait(false);
+ _stdout.Dispose();
+ _stderr.Dispose();
+ }
+
+ private static string SanitizeFileName(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return "container";
+ }
+
+ var invalidChars = Path.GetInvalidFileNameChars();
+ var buffer = new char[value.Length];
+ var length = 0;
+
+ foreach (var ch in value)
+ {
+ buffer[length++] = Array.IndexOf(invalidChars, ch) >= 0 ? '_' : ch;
+ }
+
+ return new string(buffer, 0, length);
+ }
+}
diff --git a/src/Dapr.Testcontainers/Common/ContainerLogPaths.cs b/src/Dapr.Testcontainers/Common/ContainerLogPaths.cs
new file mode 100644
index 000000000..a9d3adc88
--- /dev/null
+++ b/src/Dapr.Testcontainers/Common/ContainerLogPaths.cs
@@ -0,0 +1,23 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 The Dapr Authors
+// 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.
+// ------------------------------------------------------------------------
+
+namespace Dapr.Testcontainers.Common;
+
+///
+/// Describes the log file locations for a container.
+///
+public sealed record ContainerLogPaths(
+ string ServiceName,
+ string ContainerName,
+ string StdoutPath,
+ string StderrPath);
diff --git a/src/Dapr.Testcontainers/Common/Options/DaprRuntimeOptions.cs b/src/Dapr.Testcontainers/Common/Options/DaprRuntimeOptions.cs
index 21474ab65..25b2035d6 100644
--- a/src/Dapr.Testcontainers/Common/Options/DaprRuntimeOptions.cs
+++ b/src/Dapr.Testcontainers/Common/Options/DaprRuntimeOptions.cs
@@ -12,6 +12,7 @@
// ------------------------------------------------------------------------
using System;
+using Dapr.Testcontainers.Common;
namespace Dapr.Testcontainers.Common.Options;
@@ -21,6 +22,22 @@ namespace Dapr.Testcontainers.Common.Options;
public sealed record DaprRuntimeOptions
{
private const string DEFAULT_VERSION_ENVVAR_NAME = "DAPR_RUNTIME_VERSION";
+ private static readonly string[] CiEnvironmentSignals =
+ [
+ "CI",
+ "TF_BUILD",
+ "GITHUB_ACTIONS",
+ "GITLAB_CI",
+ "JENKINS_URL",
+ "TEAMCITY_VERSION",
+ "APPVEYOR",
+ "BUILDKITE",
+ "CIRCLECI",
+ "TRAVIS",
+ "BITBUCKET_BUILD_NUMBER",
+ "BUILD_BUILDID",
+ "SYSTEM_TEAMFOUNDATIONCOLLECTIONURI"
+ ];
///
/// Initializes a new instance of the Dapr runtime options.
@@ -53,6 +70,21 @@ public DaprRuntimeOptions(string version = "latest")
///
public DaprLogLevel LogLevel { get; private set; } = DaprLogLevel.Info;
+ ///
+ /// Enables capturing container logs to files.
+ ///
+ public bool EnableContainerLogs { get; private set; }
+
+ ///
+ /// The directory used to write container logs.
+ ///
+ public string? ContainerLogsDirectory { get; private set; }
+
+ ///
+ /// Indicates whether container logs are preserved after disposal.
+ ///
+ public bool PreserveContainerLogs { get; private set; }
+
///
/// The image tag for the Dapr runtime.
///
@@ -73,6 +105,7 @@ public DaprRuntimeOptions(string version = "latest")
public DaprRuntimeOptions WithLogLevel(DaprLogLevel logLevel)
{
LogLevel = logLevel;
+ TryEnableContainerLogsForCi(logLevel);
return this;
}
@@ -85,4 +118,71 @@ public DaprRuntimeOptions WithAppId(string appId)
AppId = appId;
return this;
}
+
+ ///
+ /// Enables container log capture to files.
+ ///
+ /// The directory to write logs to. If null, a temp directory is created.
+ /// Whether logs are preserved after disposing the environment.
+ public DaprRuntimeOptions WithContainerLogs(string? directory = null, bool preserveOnDispose = true)
+ {
+ EnableContainerLogs = true;
+ PreserveContainerLogs = preserveOnDispose;
+ ContainerLogsDirectory = string.IsNullOrWhiteSpace(directory)
+ ? TestDirectoryManager.CreateTestDirectory("dapr-logs")
+ : directory;
+ return this;
+ }
+
+ internal string? EnsureContainerLogsDirectory()
+ {
+ if (!EnableContainerLogs)
+ {
+ return null;
+ }
+
+ if (string.IsNullOrWhiteSpace(ContainerLogsDirectory))
+ {
+ ContainerLogsDirectory = TestDirectoryManager.CreateTestDirectory("dapr-logs");
+ }
+
+ return ContainerLogsDirectory;
+ }
+
+ private void TryEnableContainerLogsForCi(DaprLogLevel logLevel)
+ {
+ if (EnableContainerLogs || logLevel != DaprLogLevel.Debug)
+ {
+ return;
+ }
+
+ if (!IsCiEnvironment())
+ {
+ return;
+ }
+
+ WithContainerLogs();
+ }
+
+ private static bool IsCiEnvironment()
+ {
+ foreach (var key in CiEnvironmentSignals)
+ {
+ var value = Environment.GetEnvironmentVariable(key);
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ continue;
+ }
+
+ if (string.Equals(key, "CI", StringComparison.OrdinalIgnoreCase)
+ && string.Equals(value, "false", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
}
diff --git a/src/Dapr.Testcontainers/Containers/Dapr/DaprPlacementContainer.cs b/src/Dapr.Testcontainers/Containers/Dapr/DaprPlacementContainer.cs
index c661666df..8be913db2 100644
--- a/src/Dapr.Testcontainers/Containers/Dapr/DaprPlacementContainer.cs
+++ b/src/Dapr.Testcontainers/Containers/Dapr/DaprPlacementContainer.cs
@@ -28,6 +28,7 @@ namespace Dapr.Testcontainers.Containers.Dapr;
public sealed class DaprPlacementContainer : IAsyncStartable
{
private readonly IContainer _container;
+ private readonly ContainerLogAttachment? _logAttachment;
private readonly string _containerName = $"placement-{Guid.NewGuid():N}";
///
@@ -52,17 +53,27 @@ public sealed class DaprPlacementContainer : IAsyncStartable
///
/// The Dapr runtime options.
/// The shared Docker network to connect to.
- public DaprPlacementContainer(DaprRuntimeOptions options, INetwork network)
+ /// The directory to write container logs to.
+ public DaprPlacementContainer(DaprRuntimeOptions options, INetwork network, string? logDirectory = null)
{
+ _logAttachment = ContainerLogAttachment.TryCreate(logDirectory, "placement", _containerName);
+
//Placement service runs via port 50006
- _container = new ContainerBuilder()
+ var containerBuilder = new ContainerBuilder()
.WithImage(options.PlacementImageTag)
.WithName(_containerName)
.WithNetwork(network)
.WithCommand("./placement", "-port", InternalPort.ToString())
.WithPortBinding(InternalPort, assignRandomHostPort: true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("placement server leadership acquired"))
- .Build();
+ ;
+
+ if (_logAttachment is not null)
+ {
+ containerBuilder = containerBuilder.WithOutputConsumer(_logAttachment.OutputConsumer);
+ }
+
+ _container = containerBuilder.Build();
}
///
@@ -75,5 +86,18 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
///
public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken);
///
- public ValueTask DisposeAsync() => _container.DisposeAsync();
+ public async ValueTask DisposeAsync()
+ {
+ await _container.DisposeAsync();
+
+ if (_logAttachment is not null)
+ {
+ await _logAttachment.DisposeAsync();
+ }
+ }
+
+ ///
+ /// Gets the log file locations for this container.
+ ///
+ public ContainerLogPaths? LogPaths => _logAttachment?.Paths;
}
diff --git a/src/Dapr.Testcontainers/Containers/Dapr/DaprSchedulerContainer.cs b/src/Dapr.Testcontainers/Containers/Dapr/DaprSchedulerContainer.cs
index 3e6b09695..bb81003ec 100644
--- a/src/Dapr.Testcontainers/Containers/Dapr/DaprSchedulerContainer.cs
+++ b/src/Dapr.Testcontainers/Containers/Dapr/DaprSchedulerContainer.cs
@@ -30,6 +30,7 @@ namespace Dapr.Testcontainers.Containers.Dapr;
public sealed class DaprSchedulerContainer : IAsyncStartable
{
private readonly IContainer _container;
+ private readonly ContainerLogAttachment? _logAttachment;
// Contains the data directory used by this instance of the Dapr scheduler service
//private readonly string _hostDataDir = Path.Combine(Path.GetTempPath(), $"dapr-scheduler-{Guid.NewGuid():N}");
private readonly string _testDirectory;
@@ -55,8 +56,10 @@ public sealed class DaprSchedulerContainer : IAsyncStartable
///
/// Creates a new instance of a .
///
- public DaprSchedulerContainer(DaprRuntimeOptions options, INetwork network)
+ public DaprSchedulerContainer(DaprRuntimeOptions options, INetwork network, string? logDirectory = null)
{
+ _logAttachment = ContainerLogAttachment.TryCreate(logDirectory, "scheduler", _containerName);
+
// Scheduler service runs via port 51005
const string containerDataDir = "/data/dapr-scheduler";
string[] cmd =
@@ -68,7 +71,7 @@ public DaprSchedulerContainer(DaprRuntimeOptions options, INetwork network)
_testDirectory = TestDirectoryManager.CreateTestDirectory("scheduler");
- _container = new ContainerBuilder()
+ var containerBuilder = new ContainerBuilder()
.WithImage(options.SchedulerImageTag)
.WithName(_containerName)
.WithNetwork(network)
@@ -77,7 +80,14 @@ public DaprSchedulerContainer(DaprRuntimeOptions options, INetwork network)
// Mount an anonymous volume to /data to ensure the scheduler has write permissions
.WithBindMount(_testDirectory, containerDataDir, AccessMode.ReadWrite)
.WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("api is ready"))
- .Build();
+ ;
+
+ if (_logAttachment is not null)
+ {
+ containerBuilder = containerBuilder.WithOutputConsumer(_logAttachment.OutputConsumer);
+ }
+
+ _container = containerBuilder.Build();
}
///
@@ -90,10 +100,21 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
///
public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken);
///
- public ValueTask DisposeAsync()
+ public async ValueTask DisposeAsync()
{
// Remove the data directory if it exists
TestDirectoryManager.CleanUpDirectory(_testDirectory);
- return _container.DisposeAsync();
+
+ await _container.DisposeAsync();
+
+ if (_logAttachment is not null)
+ {
+ await _logAttachment.DisposeAsync();
+ }
}
+
+ ///
+ /// Gets the log file locations for this container.
+ ///
+ public ContainerLogPaths? LogPaths => _logAttachment?.Paths;
}
diff --git a/src/Dapr.Testcontainers/Containers/Dapr/DaprdContainer.cs b/src/Dapr.Testcontainers/Containers/Dapr/DaprdContainer.cs
index 1bf1dfe4d..77018531b 100644
--- a/src/Dapr.Testcontainers/Containers/Dapr/DaprdContainer.cs
+++ b/src/Dapr.Testcontainers/Containers/Dapr/DaprdContainer.cs
@@ -34,6 +34,7 @@ public sealed class DaprdContainer : IAsyncStartable
private const int InternalHttpPort = 3500;
private const int InternalGrpcPort = 50001;
private readonly IContainer _container;
+ private readonly ContainerLogAttachment? _logAttachment;
private string _containerName = $"dapr-{Guid.NewGuid():N}";
///
@@ -47,7 +48,7 @@ public sealed class DaprdContainer : IAsyncStartable
///
/// The gRPC port of the Dapr runtime.
///
- public int GrpcPort { get; private set; }
+ public int GrpcPort { get; private set; }
private readonly int? _requestedHttpPort;
private readonly int? _requestedGrpcPort;
@@ -68,6 +69,7 @@ public sealed class DaprdContainer : IAsyncStartable
/// The hostname and port of the Scheduler service.
/// The host HTTP port to bind to.
/// The host gRPC port to bind to.
+ /// The directory to write container logs to.
public DaprdContainer(
string appId,
string componentsHostFolder,
@@ -76,11 +78,13 @@ public DaprdContainer(
HostPortPair? placementHostAndPort = null,
HostPortPair? schedulerHostAndPort = null,
int? daprHttpPort = null,
- int? daprGrpcPort = null
+ int? daprGrpcPort = null,
+ string? logDirectory = null
)
{
_requestedHttpPort = daprHttpPort;
_requestedGrpcPort = daprGrpcPort;
+ _logAttachment = ContainerLogAttachment.TryCreate(logDirectory, "daprd", _containerName);
const string componentsPath = "/components";
var cmd =
@@ -132,6 +136,11 @@ public DaprdContainer(
.UntilMessageIsLogged("Internal gRPC server is running"));
//.UntilMessageIsLogged(@"^dapr initialized. Status: Running. Init Elapsed "))
+ if (_logAttachment is not null)
+ {
+ containerBuilder = containerBuilder.WithOutputConsumer(_logAttachment.OutputConsumer);
+ }
+
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);
@@ -208,5 +217,18 @@ private static async Task WaitForTcpPortAsync(
///
public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken);
///
- public ValueTask DisposeAsync() => _container.DisposeAsync();
+ public async ValueTask DisposeAsync()
+ {
+ await _container.DisposeAsync();
+
+ if (_logAttachment is not null)
+ {
+ await _logAttachment.DisposeAsync();
+ }
+ }
+
+ ///
+ /// Gets the log file locations for this container.
+ ///
+ public ContainerLogPaths? LogPaths => _logAttachment?.Paths;
}
diff --git a/src/Dapr.Testcontainers/Containers/OllamaContainer.cs b/src/Dapr.Testcontainers/Containers/OllamaContainer.cs
index 7e5b08e02..cf031a40c 100644
--- a/src/Dapr.Testcontainers/Containers/OllamaContainer.cs
+++ b/src/Dapr.Testcontainers/Containers/OllamaContainer.cs
@@ -34,20 +34,30 @@ public sealed class OllamaContainer : IAsyncStartable
private readonly string _containerName = $"ollama-{Guid.NewGuid():N}";
private readonly IContainer _container;
+ private readonly ContainerLogAttachment? _logAttachment;
///
/// Provides an Ollama container.
///
- public OllamaContainer(INetwork network)
+ public OllamaContainer(INetwork network, string? logDirectory = null)
{
- _container = new ContainerBuilder()
+ _logAttachment = ContainerLogAttachment.TryCreate(logDirectory, "ollama", _containerName);
+
+ var containerBuilder = new ContainerBuilder()
.WithImage("ollama/ollama")
.WithName(_containerName)
.WithNetwork(network)
.WithEnvironment("CUDA_VISIBLE_DEVICES", "-1")
.WithPortBinding(InternalPort, assignRandomHostPort: true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(InternalPort))
- .Build();
+ ;
+
+ if (_logAttachment is not null)
+ {
+ containerBuilder = containerBuilder.WithOutputConsumer(_logAttachment.OutputConsumer);
+ }
+
+ _container = containerBuilder.Build();
}
///
@@ -103,7 +113,20 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
///
public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken);
///
- public ValueTask DisposeAsync() => _container.DisposeAsync();
+ public async ValueTask DisposeAsync()
+ {
+ await _container.DisposeAsync();
+
+ if (_logAttachment is not null)
+ {
+ await _logAttachment.DisposeAsync();
+ }
+ }
+
+ ///
+ /// Gets the log file locations for this container.
+ ///
+ public ContainerLogPaths? LogPaths => _logAttachment?.Paths;
private static async Task IsModelAvailableAsync(
HttpClient httpClient,
diff --git a/src/Dapr.Testcontainers/Containers/RabbitMqContainer.cs b/src/Dapr.Testcontainers/Containers/RabbitMqContainer.cs
index cdc9f5c84..76f2376e6 100644
--- a/src/Dapr.Testcontainers/Containers/RabbitMqContainer.cs
+++ b/src/Dapr.Testcontainers/Containers/RabbitMqContainer.cs
@@ -31,21 +31,31 @@ public sealed class RabbitMqContainer : IAsyncStartable
private const int InternalPort = 5672;
private readonly IContainer _container;
+ private readonly ContainerLogAttachment? _logAttachment;
private string _containerName = $"rabbitmq-{Guid.NewGuid():N}";
///
/// Provides a RabbitMQ container.
///
- public RabbitMqContainer(INetwork network)
+ public RabbitMqContainer(INetwork network, string? logDirectory = null)
{
- _container = new ContainerBuilder()
+ _logAttachment = ContainerLogAttachment.TryCreate(logDirectory, "rabbitmq", _containerName);
+
+ var containerBuilder = new ContainerBuilder()
.WithImage("rabbitmq:alpine")
.WithName(_containerName)
.WithNetwork(network)
.WithLogger(ConsoleLogger.Instance)
.WithPortBinding(InternalPort, assignRandomHostPort: true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(InternalPort))
- .Build();
+ ;
+
+ if (_logAttachment is not null)
+ {
+ containerBuilder = containerBuilder.WithOutputConsumer(_logAttachment.OutputConsumer);
+ }
+
+ _container = containerBuilder.Build();
}
///
@@ -75,7 +85,20 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
///
public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken);
///
- public ValueTask DisposeAsync() => _container.DisposeAsync();
+ public async ValueTask DisposeAsync()
+ {
+ await _container.DisposeAsync();
+
+ if (_logAttachment is not null)
+ {
+ await _logAttachment.DisposeAsync();
+ }
+ }
+
+ ///
+ /// Gets the log file locations for this container.
+ ///
+ public ContainerLogPaths? LogPaths => _logAttachment?.Paths;
///
/// Builds out the YAML components for RabbitMQ.
diff --git a/src/Dapr.Testcontainers/Containers/RedisContainer.cs b/src/Dapr.Testcontainers/Containers/RedisContainer.cs
index 363ba253e..4c71deddd 100644
--- a/src/Dapr.Testcontainers/Containers/RedisContainer.cs
+++ b/src/Dapr.Testcontainers/Containers/RedisContainer.cs
@@ -31,19 +31,29 @@ public sealed class RedisContainer : IAsyncStartable
private readonly string _containerName = $"redis-{Guid.NewGuid():N}";
private readonly IContainer _container;
+ private readonly ContainerLogAttachment? _logAttachment;
///
/// Provides a Redis container.
///
- public RedisContainer(INetwork network)
+ public RedisContainer(INetwork network, string? logDirectory = null)
{
- _container = new ContainerBuilder()
+ _logAttachment = ContainerLogAttachment.TryCreate(logDirectory, "redis", _containerName);
+
+ var containerBuilder = new ContainerBuilder()
.WithImage("redis:alpine")
.WithName(_containerName)
.WithNetwork(network)
.WithPortBinding(InternalPort, assignRandomHostPort: true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(InternalPort))
- .Build();
+ ;
+
+ if (_logAttachment is not null)
+ {
+ containerBuilder = containerBuilder.WithOutputConsumer(_logAttachment.OutputConsumer);
+ }
+
+ _container = containerBuilder.Build();
}
///
@@ -72,7 +82,20 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
///
public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken);
///
- public ValueTask DisposeAsync() => _container.DisposeAsync();
+ public async ValueTask DisposeAsync()
+ {
+ await _container.DisposeAsync();
+
+ if (_logAttachment is not null)
+ {
+ await _logAttachment.DisposeAsync();
+ }
+ }
+
+ ///
+ /// Gets the log file locations for this container.
+ ///
+ public ContainerLogPaths? LogPaths => _logAttachment?.Paths;
///
/// Builds out each of the YAML components for Redis
diff --git a/src/Dapr.Testcontainers/Harnesses/ActorHarness.cs b/src/Dapr.Testcontainers/Harnesses/ActorHarness.cs
index c606fa1da..9acc3a7cc 100644
--- a/src/Dapr.Testcontainers/Harnesses/ActorHarness.cs
+++ b/src/Dapr.Testcontainers/Harnesses/ActorHarness.cs
@@ -40,9 +40,9 @@ public sealed class ActorHarness : BaseHarness
public ActorHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options, DaprTestEnvironment? environment = null) : base(componentsDir, startApp, options, environment)
{
this.componentsDir = componentsDir;
- _placement = new DaprPlacementContainer(options, Network);
- _schedueler = new DaprSchedulerContainer(options, Network);
- _redis = new(Network);
+ _placement = new DaprPlacementContainer(options, Network, ContainerLogsDirectory);
+ _schedueler = new DaprSchedulerContainer(options, Network, ContainerLogsDirectory);
+ _redis = new RedisContainer(Network, ContainerLogsDirectory);
}
///
diff --git a/src/Dapr.Testcontainers/Harnesses/BaseHarness.cs b/src/Dapr.Testcontainers/Harnesses/BaseHarness.cs
index 83b5cad2c..93b6383af 100644
--- a/src/Dapr.Testcontainers/Harnesses/BaseHarness.cs
+++ b/src/Dapr.Testcontainers/Harnesses/BaseHarness.cs
@@ -58,6 +58,7 @@ public abstract class BaseHarness : IAsyncContainerFixture
private readonly string componentsDirectory;
private readonly Func? startApp;
private readonly DaprRuntimeOptions options;
+ private readonly string? _logDirectory;
///
/// Provides a base harness for building Dapr building block harnesses.
@@ -80,6 +81,11 @@ protected BaseHarness(string componentsDirectory, Func? startApp, Dap
_environment = new DaprTestEnvironment(options);
_ownsEnvironment = true;
}
+
+ if (options.EnableContainerLogs)
+ {
+ _logDirectory = _environment.ContainerLogsDirectory ?? options.EnsureContainerLogsDirectory();
+ }
}
///
@@ -92,6 +98,16 @@ protected BaseHarness(string componentsDirectory, Func? startApp, Dap
///
protected DaprTestEnvironment Environment => _environment;
+ ///
+ /// The directory used to write container logs, if enabled.
+ ///
+ public string? ContainerLogsDirectory => _logDirectory;
+
+ ///
+ /// Gets the log file locations for the Dapr sidecar container.
+ ///
+ public ContainerLogPaths? DaprdLogPaths => _daprd?.LogPaths;
+
///
/// Gets the port that the Dapr sidecar is configured to talk to - this is the port the test application should use.
///
@@ -205,7 +221,8 @@ DaprPlacementExternalPort is null || DaprPlacementAlias is null
DaprSchedulerExternalPort is null || DaprSchedulerAlias is null
? null : new HostPortPair(DaprSchedulerAlias, DaprSchedulerContainer.InternalPort),
_daprHttpPortOverride,
- _daprGrpcPortOverride);
+ _daprGrpcPortOverride,
+ _logDirectory);
var daprdTask = Task.Run(async () =>
{
diff --git a/src/Dapr.Testcontainers/Harnesses/ConversationHarness.cs b/src/Dapr.Testcontainers/Harnesses/ConversationHarness.cs
index 879fa7e15..233914a7d 100644
--- a/src/Dapr.Testcontainers/Harnesses/ConversationHarness.cs
+++ b/src/Dapr.Testcontainers/Harnesses/ConversationHarness.cs
@@ -42,7 +42,7 @@ public ConversationHarness(string componentsDir, Func? startApp, Dapr
DaprTestEnvironment? environment = null) : base(componentsDir, startApp, options, environment)
{
this.componentsDir = componentsDir;
- _ollama = new(Network);
+ _ollama = new OllamaContainer(Network, ContainerLogsDirectory);
}
///
diff --git a/src/Dapr.Testcontainers/Harnesses/DaprTestEnvironment.cs b/src/Dapr.Testcontainers/Harnesses/DaprTestEnvironment.cs
index 4ac6fa29b..55cdf5434 100644
--- a/src/Dapr.Testcontainers/Harnesses/DaprTestEnvironment.cs
+++ b/src/Dapr.Testcontainers/Harnesses/DaprTestEnvironment.cs
@@ -15,6 +15,7 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
+using Dapr.Testcontainers.Common;
using Dapr.Testcontainers.Common.Options;
using Dapr.Testcontainers.Containers;
using Dapr.Testcontainers.Containers.Dapr;
@@ -34,6 +35,9 @@ public sealed class DaprTestEnvironment : IAsyncDisposable
private readonly DaprPlacementContainer _placement;
private readonly DaprSchedulerContainer _scheduler;
private readonly RedisContainer? _redis;
+ private readonly string? _logDirectory;
+ private readonly bool _preserveContainerLogs;
+ private readonly List _containerLogs = [];
private bool _started;
private readonly bool _ownsNetwork;
private readonly DockerNetworkLease? _networkLease;
@@ -48,6 +52,12 @@ public DaprTestEnvironment(DaprRuntimeOptions? options = null, bool needsActorSt
{
options ??= new DaprRuntimeOptions();
+ if (options.EnableContainerLogs)
+ {
+ _logDirectory = options.EnsureContainerLogsDirectory();
+ _preserveContainerLogs = options.PreserveContainerLogs;
+ }
+
if (network is null)
{
Network = new NetworkBuilder().Build();
@@ -59,12 +69,15 @@ public DaprTestEnvironment(DaprRuntimeOptions? options = null, bool needsActorSt
_ownsNetwork = false;
}
- _placement = new DaprPlacementContainer(options, Network);
- _scheduler = new DaprSchedulerContainer(options, Network);
+ _placement = new DaprPlacementContainer(options, Network, _logDirectory);
+ _scheduler = new DaprSchedulerContainer(options, Network, _logDirectory);
+ RegisterContainerLogs(_placement.LogPaths);
+ RegisterContainerLogs(_scheduler.LogPaths);
if (needsActorState)
{
- _redis = new RedisContainer(Network);
+ _redis = new RedisContainer(Network, _logDirectory);
+ RegisterContainerLogs(_redis.LogPaths);
}
}
@@ -81,6 +94,16 @@ private DaprTestEnvironment(
///
public INetwork Network { get; }
+ ///
+ /// Gets the directory used to write container logs.
+ ///
+ public string? ContainerLogsDirectory => _logDirectory;
+
+ ///
+ /// Gets the log file locations for environment containers.
+ ///
+ public IReadOnlyList ContainerLogs => _containerLogs;
+
///
/// Exposes the redis container, if loaded in the environment.
///
@@ -157,5 +180,18 @@ public async ValueTask DisposeAsync()
if (_networkLease is not null)
await _networkLease.DisposeAsync();
+
+ if (_logDirectory is not null && !_preserveContainerLogs)
+ {
+ TestDirectoryManager.CleanUpDirectory(_logDirectory);
+ }
+ }
+
+ private void RegisterContainerLogs(ContainerLogPaths? logPaths)
+ {
+ if (logPaths is not null)
+ {
+ _containerLogs.Add(logPaths);
+ }
}
}
diff --git a/src/Dapr.Testcontainers/Harnesses/DistributedLockHarness.cs b/src/Dapr.Testcontainers/Harnesses/DistributedLockHarness.cs
index ca3435105..b3fc1179e 100644
--- a/src/Dapr.Testcontainers/Harnesses/DistributedLockHarness.cs
+++ b/src/Dapr.Testcontainers/Harnesses/DistributedLockHarness.cs
@@ -42,7 +42,7 @@ public sealed class DistributedLockHarness : BaseHarness
public DistributedLockHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options, DaprTestEnvironment? environment = null) : base(componentsDir, startApp, options, environment)
{
this.componentsDir = componentsDir;
- _redis = new(Network);
+ _redis = new RedisContainer(Network, ContainerLogsDirectory);
}
///
diff --git a/src/Dapr.Testcontainers/Harnesses/PubSubHarness.cs b/src/Dapr.Testcontainers/Harnesses/PubSubHarness.cs
index a8abb0cf4..0c8370451 100644
--- a/src/Dapr.Testcontainers/Harnesses/PubSubHarness.cs
+++ b/src/Dapr.Testcontainers/Harnesses/PubSubHarness.cs
@@ -37,7 +37,7 @@ public sealed class PubSubHarness : BaseHarness
public PubSubHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options, DaprTestEnvironment? environment = null): base(componentsDir, startApp, options, environment)
{
this.componentsDir = componentsDir;
- _rabbitmq = new(Network);
+ _rabbitmq = new RabbitMqContainer(Network, ContainerLogsDirectory);
}
///
diff --git a/src/Dapr.Testcontainers/Harnesses/StateManagementHarness.cs b/src/Dapr.Testcontainers/Harnesses/StateManagementHarness.cs
index 114f981d5..d00ece7db 100644
--- a/src/Dapr.Testcontainers/Harnesses/StateManagementHarness.cs
+++ b/src/Dapr.Testcontainers/Harnesses/StateManagementHarness.cs
@@ -37,7 +37,7 @@ public sealed class StateManagementHarness : BaseHarness
public StateManagementHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options, DaprTestEnvironment? environment = null) : base(componentsDir, startApp, options, environment)
{
this.componentsDir = componentsDir;
- _redis = new(Network);
+ _redis = new RedisContainer(Network, ContainerLogsDirectory);
}
///
diff --git a/src/Dapr.Testcontainers/Harnesses/WorkflowHarness.cs b/src/Dapr.Testcontainers/Harnesses/WorkflowHarness.cs
index 1bbccaabb..f352ee140 100644
--- a/src/Dapr.Testcontainers/Harnesses/WorkflowHarness.cs
+++ b/src/Dapr.Testcontainers/Harnesses/WorkflowHarness.cs
@@ -36,7 +36,7 @@ public sealed class WorkflowHarness : BaseHarness
/// The isolated environment instance.
public WorkflowHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options, DaprTestEnvironment? environment = null) : base(componentsDir, startApp, options, environment)
{
- _redis = environment?.RedisContainer ?? new RedisContainer(Network);
+ _redis = environment?.RedisContainer ?? new RedisContainer(Network, ContainerLogsDirectory);
_isSelfHostedRedis = environment?.RedisContainer is null;
}
diff --git a/test/Dapr.Testcontainers.Test/Common/Options/DaprRuntimeOptionsTests.cs b/test/Dapr.Testcontainers.Test/Common/Options/DaprRuntimeOptionsTests.cs
index 37926a83b..2f1d0610b 100644
--- a/test/Dapr.Testcontainers.Test/Common/Options/DaprRuntimeOptionsTests.cs
+++ b/test/Dapr.Testcontainers.Test/Common/Options/DaprRuntimeOptionsTests.cs
@@ -1,5 +1,7 @@
using Dapr.Testcontainers.Common.Options;
+using Dapr.Testcontainers.Common;
+
namespace Dapr.Testcontainers.Test.Common.Options;
public sealed class DaprRuntimeOptionsTests : IDisposable
@@ -25,6 +27,29 @@ public void ShouldUseDefaultVersionIfEnvVarNotTest()
Assert.Equal(defaultValue, options.Version);
}
+ [Fact]
+ public void ShouldEnableContainerLogsForCiDebugLogging()
+ {
+ var originalCi = Environment.GetEnvironmentVariable("CI");
+
+ try
+ {
+ Environment.SetEnvironmentVariable("CI", "true");
+
+ var options = new DaprRuntimeOptions()
+ .WithLogLevel(DaprLogLevel.Debug);
+
+ Assert.True(options.EnableContainerLogs);
+ Assert.False(string.IsNullOrWhiteSpace(options.ContainerLogsDirectory));
+
+ TestDirectoryManager.CleanUpDirectory(options.ContainerLogsDirectory!);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("CI", originalCi);
+ }
+ }
+
public void Dispose()
{
// Clear this variable