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