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
86 changes: 86 additions & 0 deletions src/Dapr.Testcontainers/Common/ContainerLogAttachment.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
23 changes: 23 additions & 0 deletions src/Dapr.Testcontainers/Common/ContainerLogPaths.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Describes the log file locations for a container.
/// </summary>
public sealed record ContainerLogPaths(
string ServiceName,
string ContainerName,
string StdoutPath,
string StderrPath);
100 changes: 100 additions & 0 deletions src/Dapr.Testcontainers/Common/Options/DaprRuntimeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// ------------------------------------------------------------------------

using System;
using Dapr.Testcontainers.Common;

namespace Dapr.Testcontainers.Common.Options;

Expand All @@ -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"
];

/// <summary>
/// Initializes a new instance of the Dapr runtime options.
Expand Down Expand Up @@ -53,6 +70,21 @@ public DaprRuntimeOptions(string version = "latest")
/// </summary>
public DaprLogLevel LogLevel { get; private set; } = DaprLogLevel.Info;

/// <summary>
/// Enables capturing container logs to files.
/// </summary>
public bool EnableContainerLogs { get; private set; }

/// <summary>
/// The directory used to write container logs.
/// </summary>
public string? ContainerLogsDirectory { get; private set; }

/// <summary>
/// Indicates whether container logs are preserved after disposal.
/// </summary>
public bool PreserveContainerLogs { get; private set; }

/// <summary>
/// The image tag for the Dapr runtime.
/// </summary>
Expand All @@ -73,6 +105,7 @@ public DaprRuntimeOptions(string version = "latest")
public DaprRuntimeOptions WithLogLevel(DaprLogLevel logLevel)
{
LogLevel = logLevel;
TryEnableContainerLogsForCi(logLevel);
return this;
}

Expand All @@ -85,4 +118,71 @@ public DaprRuntimeOptions WithAppId(string appId)
AppId = appId;
return this;
}

/// <summary>
/// Enables container log capture to files.
/// </summary>
/// <param name="directory">The directory to write logs to. If null, a temp directory is created.</param>
/// <param name="preserveOnDispose">Whether logs are preserved after disposing the environment.</param>
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}";

/// <summary>
Expand All @@ -52,17 +53,27 @@ public sealed class DaprPlacementContainer : IAsyncStartable
/// </summary>
/// <param name="options">The Dapr runtime options.</param>
/// <param name="network">The shared Docker network to connect to.</param>
public DaprPlacementContainer(DaprRuntimeOptions options, INetwork network)
/// <param name="logDirectory">The directory to write container logs to.</param>
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();
}

/// <inheritdoc />
Expand All @@ -75,5 +86,18 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken);
/// <inheritdoc />
public ValueTask DisposeAsync() => _container.DisposeAsync();
public async ValueTask DisposeAsync()
{
await _container.DisposeAsync();

if (_logAttachment is not null)
{
await _logAttachment.DisposeAsync();
}
}

/// <summary>
/// Gets the log file locations for this container.
/// </summary>
public ContainerLogPaths? LogPaths => _logAttachment?.Paths;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -55,8 +56,10 @@ public sealed class DaprSchedulerContainer : IAsyncStartable
/// <summary>
/// Creates a new instance of a <see cref="DaprSchedulerContainer"/>.
/// </summary>
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 =
Expand All @@ -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)
Expand All @@ -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();
}

/// <inheritdoc />
Expand All @@ -90,10 +100,21 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken);
/// <inheritdoc />
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();
}
}

/// <summary>
/// Gets the log file locations for this container.
/// </summary>
public ContainerLogPaths? LogPaths => _logAttachment?.Paths;
}
Loading
Loading