diff --git a/Directory.Packages.props b/Directory.Packages.props index 6db58e8e9..79da9a732 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -52,6 +52,7 @@ + diff --git a/all.sln b/all.sln index f000a22c0..0da90b211 100644 --- a/all.sln +++ b/all.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32929.385 MinimumVisualStudioVersion = 10.0.40219.1 @@ -205,6 +205,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DistributedLock", "Distribu EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DistributedLock", "examples\DistributedLock\DistributedLock\DistributedLock.csproj", "{F2DFB0FE-DF35-4D94-9CC9-43212B1D6F93}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.TestContainers", "src\Dapr.TestContainers\Dapr.TestContainers.csproj", "{D14893E1-EF21-4EB4-9DAD-82B5127832CB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.E2E.Test.PubSub", "test\Dapr.E2E.Test.PubSub\Dapr.E2E.Test.PubSub.csproj", "{4997B2D1-49AF-40B7-8D84-4BD1E94D9B2E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.E2E.Test.Workflow", "test\Dapr.E2E.Test.Workflow\Dapr.E2E.Test.Workflow.csproj", "{3CE069E8-9AAB-4DE4-90AC-077C1DB4EAEB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.E2E.Test.Jobs", "test\Dapr.E2E.Test.Jobs\Dapr.E2E.Test.Jobs.csproj", "{775302E3-69CC-4FBD-98CC-0F889A5D4C63}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -541,6 +549,22 @@ Global {F2DFB0FE-DF35-4D94-9CC9-43212B1D6F93}.Debug|Any CPU.Build.0 = Debug|Any CPU {F2DFB0FE-DF35-4D94-9CC9-43212B1D6F93}.Release|Any CPU.ActiveCfg = Release|Any CPU {F2DFB0FE-DF35-4D94-9CC9-43212B1D6F93}.Release|Any CPU.Build.0 = Release|Any CPU + {D14893E1-EF21-4EB4-9DAD-82B5127832CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D14893E1-EF21-4EB4-9DAD-82B5127832CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D14893E1-EF21-4EB4-9DAD-82B5127832CB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D14893E1-EF21-4EB4-9DAD-82B5127832CB}.Release|Any CPU.Build.0 = Release|Any CPU + {4997B2D1-49AF-40B7-8D84-4BD1E94D9B2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4997B2D1-49AF-40B7-8D84-4BD1E94D9B2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4997B2D1-49AF-40B7-8D84-4BD1E94D9B2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4997B2D1-49AF-40B7-8D84-4BD1E94D9B2E}.Release|Any CPU.Build.0 = Release|Any CPU + {3CE069E8-9AAB-4DE4-90AC-077C1DB4EAEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3CE069E8-9AAB-4DE4-90AC-077C1DB4EAEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3CE069E8-9AAB-4DE4-90AC-077C1DB4EAEB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3CE069E8-9AAB-4DE4-90AC-077C1DB4EAEB}.Release|Any CPU.Build.0 = Release|Any CPU + {775302E3-69CC-4FBD-98CC-0F889A5D4C63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {775302E3-69CC-4FBD-98CC-0F889A5D4C63}.Debug|Any CPU.Build.0 = Debug|Any CPU + {775302E3-69CC-4FBD-98CC-0F889A5D4C63}.Release|Any CPU.ActiveCfg = Release|Any CPU + {775302E3-69CC-4FBD-98CC-0F889A5D4C63}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -640,6 +664,10 @@ Global {9BD12D26-AD9B-4C76-A97F-7A89B7276ABE} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {11D2CA0F-6D38-4DC7-AE06-C1DAE7FC1C20} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} {F2DFB0FE-DF35-4D94-9CC9-43212B1D6F93} = {11D2CA0F-6D38-4DC7-AE06-C1DAE7FC1C20} + {D14893E1-EF21-4EB4-9DAD-82B5127832CB} = {27C5D71D-0721-4221-9286-B94AB07B58CF} + {4997B2D1-49AF-40B7-8D84-4BD1E94D9B2E} = {DD020B34-460F-455F-8D17-CF4A949F100B} + {3CE069E8-9AAB-4DE4-90AC-077C1DB4EAEB} = {DD020B34-460F-455F-8D17-CF4A949F100B} + {775302E3-69CC-4FBD-98CC-0F889A5D4C63} = {DD020B34-460F-455F-8D17-CF4A949F100B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40} diff --git a/src/Dapr.TestContainers/Common/DaprHarnessBuilder.cs b/src/Dapr.TestContainers/Common/DaprHarnessBuilder.cs new file mode 100644 index 000000000..70546a84f --- /dev/null +++ b/src/Dapr.TestContainers/Common/DaprHarnessBuilder.cs @@ -0,0 +1,83 @@ +// ------------------------------------------------------------------------ +// 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.Threading.Tasks; +using Dapr.E2E.Test.Common; +using Dapr.TestContainers.Common.Options; +using Dapr.TestContainers.Harnesses; + +namespace Dapr.TestContainers.Common; + +/// +/// Builds the Dapr harnesses for different building blocks. +/// +/// The Dapr runtime options. +/// The test app to run. +public sealed class DaprHarnessBuilder(DaprRuntimeOptions options, Func? startApp = null) +{ + /// + /// Builds a workflow harness. + /// + /// The path to the Dapr resources. + public WorkflowHarness BuildWorkflow(string componentsDir) => new(componentsDir, startApp, options); + + /// + /// Builds a distributed lock harness. + /// + /// The path to the Dapr resources. + public DistributedLockHarness BuildDistributedLock(string componentsDir) => new(componentsDir, startApp, options); + + /// + /// Builds a conversation harness. + /// + /// The path to the Dapr resources. + public ConversationHarness BuildConversation(string componentsDir) => new(componentsDir, startApp, options); + + /// + /// Builds a cryptography harness. + /// + /// The path to the Dapr resources. + /// The path to the cryptography keys. + public CryptographyHarness BuildCryptography(string componentsDir, string keysDir) => + new(componentsDir, startApp, keysDir, options); + + /// + /// Builds a jobs harness. + /// + /// The path to the Dapr resources. + public JobsHarness BuildJobs(string componentsDir) => new(componentsDir, startApp, options); + + /// + /// Builds a PubSub harness. + /// + /// The path to the Dapr resources. + public PubSubHarness BuildPubSub(string componentsDir) => new(componentsDir, startApp, options); + + /// + /// Builds a state management harness. + /// + /// The path to the Dapr resources. + public StateManagementHarness BuildStateManagement(string componentsDir) => new(componentsDir, startApp, options); + + /// + /// Builds an actor harness. + /// + /// The path to the Dapr resources. + public ActorHarness BuildActors(string componentsDir) => new(componentsDir, startApp, options); + + /// + /// Creates a test application builder for the specified harness. + /// + public static DaprTestApplicationBuilder ForHarness(BaseHarness harness) => new(harness); +} diff --git a/src/Dapr.TestContainers/Common/HostPortPair.cs b/src/Dapr.TestContainers/Common/HostPortPair.cs new file mode 100644 index 000000000..a96fe2464 --- /dev/null +++ b/src/Dapr.TestContainers/Common/HostPortPair.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------ +// 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; + +/// +/// A hostname and port value pair. +/// +public sealed record HostPortPair(string Hostname, int Port) +{ + /// + /// Provides a string version of the record. + /// + public override string ToString() => $"{Hostname}:{Port}"; +} diff --git a/src/Dapr.TestContainers/Common/IAsyncContainerFixture.cs b/src/Dapr.TestContainers/Common/IAsyncContainerFixture.cs new file mode 100644 index 000000000..a3adb135b --- /dev/null +++ b/src/Dapr.TestContainers/Common/IAsyncContainerFixture.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// 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.Threading; +using System.Threading.Tasks; + +namespace Dapr.TestContainers.Common; + +/// +/// Represents a harness that's created for test purposes. +/// +public interface IAsyncContainerFixture : IAsyncDisposable +{ + /// + Task InitializeAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Dapr.TestContainers/Common/IAsyncStartable.cs b/src/Dapr.TestContainers/Common/IAsyncStartable.cs new file mode 100644 index 000000000..f44635aa7 --- /dev/null +++ b/src/Dapr.TestContainers/Common/IAsyncStartable.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------ +// 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.Threading; +using System.Threading.Tasks; + +namespace Dapr.TestContainers.Common; + +/// +/// Represents a resource that can be started and stopped. +/// +public interface IAsyncStartable : IAsyncDisposable +{ + /// + /// Starts the resource. + /// + /// Cancellation token/ + Task StartAsync(CancellationToken cancellationToken = default); + + /// + /// Stops the resource. + /// + /// Cancellation token. + Task StopAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Dapr.TestContainers/Common/Options/DaprLogLevel.cs b/src/Dapr.TestContainers/Common/Options/DaprLogLevel.cs new file mode 100644 index 000000000..77972bf30 --- /dev/null +++ b/src/Dapr.TestContainers/Common/Options/DaprLogLevel.cs @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------ +// 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.Options; + +/// +/// A configurable log level. +/// +public enum DaprLogLevel +{ + /// + /// Represents a "debug" log level. + /// + Debug, + /// + /// Represents an "info" log level. + /// + Info, + /// + /// Represents a "warn" log level. + /// + Warn, + /// + /// Represents an "error" log level. + /// + Error, + /// + /// Represents a "fatal" log level. + /// + Fatal, + /// + /// Represents a "panic" log level. + /// + Panic +} diff --git a/src/Dapr.TestContainers/Common/Options/DaprRuntimeOptions.cs b/src/Dapr.TestContainers/Common/Options/DaprRuntimeOptions.cs new file mode 100644 index 000000000..d5c60625a --- /dev/null +++ b/src/Dapr.TestContainers/Common/Options/DaprRuntimeOptions.cs @@ -0,0 +1,71 @@ +// ------------------------------------------------------------------------ +// 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; + +namespace Dapr.TestContainers.Common.Options; + +/// +/// The various options used to spin up the Dapr containers. +/// +/// The version of the Dapr images to use. +public sealed record DaprRuntimeOptions(string Version = "latest") +{ + /// + /// The application's port. + /// + public int AppPort { get; set; } = 8080; + + /// + /// The ID of the test application. + /// + public string AppId { get; private set; } = $"test-app-{Guid.NewGuid():N}"; + + /// + /// The level of Dapr logs to show. + /// + public DaprLogLevel LogLevel { get; private set; } = DaprLogLevel.Info; + + /// + /// The image tag for the Dapr runtime. + /// + public string RuntimeImageTag => $"daprio/daprd:{Version}"; + /// + /// The image tag for the Dapr placement service. + /// + public string PlacementImageTag => $"daprio/placement:{Version}"; + /// + /// The image tag for the Dapr scheduler service. + /// + public string SchedulerImageTag => $"daprio/scheduler:{Version}"; + + /// + /// Sets the Dapr log level. + /// + /// The log level to specify. + public DaprRuntimeOptions WithLogLevel(DaprLogLevel logLevel) + { + LogLevel = logLevel; + return this; + } + + /// + /// Sets the Dapr App ID. + /// + /// The App ID to use for the test application. + public DaprRuntimeOptions WithAppId(string appId) + { + AppId = appId; + return this; + } +} diff --git a/src/Dapr.TestContainers/Common/PortUtilities.cs b/src/Dapr.TestContainers/Common/PortUtilities.cs new file mode 100644 index 000000000..1d3387f89 --- /dev/null +++ b/src/Dapr.TestContainers/Common/PortUtilities.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// 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.Net; +using System.Net.Sockets; + +namespace Dapr.TestContainers.Common; + +/// +/// Provides port-related utilities. +/// +internal static class PortUtilities +{ + /// + /// Finds a port that's available to use. + /// + /// The available port number. + public static int GetAvailablePort() + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + return ((IPEndPoint)socket.LocalEndPoint!).Port; + } +} diff --git a/src/Dapr.TestContainers/Common/Testing/DaprTestApplication.cs b/src/Dapr.TestContainers/Common/Testing/DaprTestApplication.cs new file mode 100644 index 000000000..c503e34df --- /dev/null +++ b/src/Dapr.TestContainers/Common/Testing/DaprTestApplication.cs @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------ +// 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.Threading.Tasks; +using Dapr.TestContainers.Harnesses; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.TestContainers.Common.Testing; + +/// +/// Represents a running Dapr test application. +/// +public sealed class DaprTestApplication : IAsyncDisposable +{ + private readonly BaseHarness _harness; + private readonly WebApplication? _app; + + internal DaprTestApplication(BaseHarness harness, WebApplication? app) + { + _harness = harness; + _app = app; + } + + /// + /// Gets a service from the application's DI container. + /// + public T GetRequiredService() where T : notnull + { + if (_app is null) + throw new InvalidOperationException("No app configured"); + + return _app.Services.GetRequiredService(); + } + + /// + /// Creates a service scope. + /// + public IServiceScope CreateScope() => + _app?.Services.CreateScope() ?? throw new InvalidOperationException("No app configured"); + + /// + public async ValueTask DisposeAsync() + { + if (_app is not null) + await _app.DisposeAsync(); + + await _harness.DisposeAsync(); + } +} diff --git a/src/Dapr.TestContainers/Common/Testing/DaprTestApplicationBuilder.cs b/src/Dapr.TestContainers/Common/Testing/DaprTestApplicationBuilder.cs new file mode 100644 index 000000000..cba540d05 --- /dev/null +++ b/src/Dapr.TestContainers/Common/Testing/DaprTestApplicationBuilder.cs @@ -0,0 +1,90 @@ +// ------------------------------------------------------------------------ +// 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.Collections.Generic; +using System.Threading.Tasks; +using Dapr.TestContainers.Common.Testing; +using Dapr.TestContainers.Harnesses; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Dapr.E2E.Test.Common; + +/// +/// Fluent builder for creating test applications with Dapr harnesses. +/// +public sealed class DaprTestApplicationBuilder(BaseHarness harness) +{ + private Action? _configureServices; + private Action? _configureApp; + + /// + /// Configures services for the test application. + /// + public DaprTestApplicationBuilder ConfigureServices(Action configure) + { + _configureServices = configure; + return this; + } + + /// + /// Configures the test application pipeline. + /// + public DaprTestApplicationBuilder ConfigureApp(Action configure) + { + _configureApp = configure; + return this; + } + + /// + /// Builds and starts the test application and harness. + /// + /// + public async Task BuildAndStartAsync() + { + await harness.InitializeAsync(); + + WebApplication? app = null; + if (_configureServices is not null || _configureApp is not null) + { + var builder = WebApplication.CreateBuilder(); + + // Configure Dapr endpoints via in-memory configuration instead of environment variables + builder.Configuration.AddInMemoryCollection(new Dictionary + { + { "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 = builder.Build(); + + _configureApp?.Invoke(app); + + await app.StartAsync(); + } + + return new DaprTestApplication(harness, app); + } +} diff --git a/src/Dapr.TestContainers/Configuration/ConfigurationSettings.cs b/src/Dapr.TestContainers/Configuration/ConfigurationSettings.cs new file mode 100644 index 000000000..6b333bebd --- /dev/null +++ b/src/Dapr.TestContainers/Configuration/ConfigurationSettings.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 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.Configuration; + +/// +/// Base Dapr configuration settings. +/// +public abstract class ConfigurationSettings; diff --git a/src/Dapr.TestContainers/Configuration/OtelTracingConfigurationSettings.cs b/src/Dapr.TestContainers/Configuration/OtelTracingConfigurationSettings.cs new file mode 100644 index 000000000..856ffaec0 --- /dev/null +++ b/src/Dapr.TestContainers/Configuration/OtelTracingConfigurationSettings.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------ +// 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.Configuration; + +/// +/// Configuration settings for OTEL tracing. +/// +/// The tracing endpoint address. +/// Indicates whether the endpoint is secure. +/// The tracing protocol. +public sealed class OtelTracingConfigurationSettings(string endpointAddress, bool isSecure, string protocol) : ConfigurationSettings +{ + /// + /// The endpoint address. + /// + public string EndpointAddress => endpointAddress; + /// + /// Whether the endpoint address is secure. + /// + public bool IsSecure => isSecure; + /// + /// The collection protocol. + /// + public string Protocol => protocol; +} diff --git a/src/Dapr.TestContainers/Configuration/SecretScope.cs b/src/Dapr.TestContainers/Configuration/SecretScope.cs new file mode 100644 index 000000000..ecfe9701f --- /dev/null +++ b/src/Dapr.TestContainers/Configuration/SecretScope.cs @@ -0,0 +1,80 @@ +// ------------------------------------------------------------------------ +// 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.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Dapr.TestContainers.Configuration; + +/// +/// Builds out a collection of secret scopes to the Dapr configuration. +/// +/// The secret scopes to emit. +public sealed class SecretScopeConfigurationBuilder(IReadOnlyCollection scopes) +{ + /// + /// Builds the secret scope section of the Dapr configuration file. + /// + /// + public string Build() + { + var sb = new StringBuilder("secrets:"); + sb.AppendLine(" scopes:"); + foreach (var scope in scopes) + { + sb.AppendLine($" - storeName: {scope.StoreName}"); + sb.AppendLine($" defaultAccess: {scope.DefaultAccess.ToString().ToLowerInvariant()}"); + if (scope.AllowedSecrets.Count > 0) + { + sb.AppendLine( + $" allowedSecrets: [{string.Join(", ", scope.AllowedSecrets.Select(s => $"\"{s}\""))}]"); + } + if (scope.DeniedSecrets.Count > 0) + { + sb.AppendLine( + $" deniedSecrets: [{string.Join(", ", scope.DeniedSecrets.Select(s => $"\"{s}\""))}]"); + } + } + + return sb.ToString(); + } +} + +/// +/// Used to scope a named secret store component to one or more secrets for an application. +/// +/// The name of the secret store. +/// The default access to the secrets in the store. +/// The list of secret keys that can be accessed. +/// The list of secret keys that cannot be accessed. +public sealed record SecretScope( + string StoreName, + SecretStoreAccess DefaultAccess, + IReadOnlyCollection AllowedSecrets, + IReadOnlyCollection DeniedSecrets); + +/// +/// The secret store access modifier. +/// +public enum SecretStoreAccess +{ + /// + /// Indicates that secret access should be allowed by default. + /// + Allow, + /// + /// Indicates that secret access should be denied by default. + /// + Deny +} diff --git a/src/Dapr.TestContainers/Configuration/ZipkinTracingConfigurationSettings.cs b/src/Dapr.TestContainers/Configuration/ZipkinTracingConfigurationSettings.cs new file mode 100644 index 000000000..a21c9763d --- /dev/null +++ b/src/Dapr.TestContainers/Configuration/ZipkinTracingConfigurationSettings.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// 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.Configuration; + +/// +/// Configuration settings for Zipkin tracing. +/// +/// The tracing endpoint address. +public sealed class ZipkinTracingConfigurationSettings(string endpointAddress) : ConfigurationSettings +{ + /// + /// The endpoint address. + /// + public string EndpointAddress => endpointAddress; +} diff --git a/src/Dapr.TestContainers/Constants.cs b/src/Dapr.TestContainers/Constants.cs new file mode 100644 index 000000000..badd283a1 --- /dev/null +++ b/src/Dapr.TestContainers/Constants.cs @@ -0,0 +1,47 @@ +// ------------------------------------------------------------------------ +// 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; + +/// +/// Various constants used throughout the project. +/// +public static class Constants +{ + /// + /// Various names used for the Dapr components. + /// + public static class DaprComponentNames + { + /// + /// The name of the State Management component. + /// + public const string StateManagementComponentName = "statestore"; + /// + /// The name of the PubSub component. + /// + public const string PubSubComponentName = "pubsub"; + /// + /// The name of the Conversation component. + /// + public const string ConversationComponentName = "conversation"; + /// + /// The name of the Cryptography component. + /// + public const string CryptographyComponentName = "cryptography"; + /// + /// The name of the Distributed Lock component. + /// + public const string DistributedLockComponentName = "distributed-lock"; + } +} diff --git a/src/Dapr.TestContainers/Containers/Components/Component.cs b/src/Dapr.TestContainers/Containers/Components/Component.cs new file mode 100644 index 000000000..51dc29011 --- /dev/null +++ b/src/Dapr.TestContainers/Containers/Components/Component.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------ +// 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.Collections.Generic; +using System.Linq; + +namespace Dapr.TestContainers.Containers.Components; + +/// +/// Represents a Dapr component. +/// +/// The name of the component. +/// The type of the component. +/// The component's version. +/// Metadata associated with the component. +public abstract class Component(string name, string type, string version, Dictionary metadata) +{ + /// + /// The name of the component. + /// + public string Name => name; + /// + /// The type of the component. + /// + public string Type => type; + /// + /// The version of the component. + /// + public string Version => version; + /// + /// The metadata attached to the component. + /// + public List Metadata => metadata.Select(kv => new MetadataEntry(kv.Key, kv.Value)).ToList(); +} diff --git a/src/Dapr.TestContainers/Containers/Components/MetadataEntry.cs b/src/Dapr.TestContainers/Containers/Components/MetadataEntry.cs new file mode 100644 index 000000000..fcc3c3162 --- /dev/null +++ b/src/Dapr.TestContainers/Containers/Components/MetadataEntry.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 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.Containers.Components; + +/// +/// Provides the name and value for a metadata record in a component. +/// +public sealed record MetadataEntry(string Name, string Value); diff --git a/src/Dapr.TestContainers/Containers/Dapr/DaprPlacementContainer.cs b/src/Dapr.TestContainers/Containers/Dapr/DaprPlacementContainer.cs new file mode 100644 index 000000000..15447fce4 --- /dev/null +++ b/src/Dapr.TestContainers/Containers/Dapr/DaprPlacementContainer.cs @@ -0,0 +1,80 @@ +// ------------------------------------------------------------------------ +// 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.Threading; +using System.Threading.Tasks; +using Dapr.TestContainers.Common; +using Dapr.TestContainers.Common.Options; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; + +namespace Dapr.TestContainers.Containers.Dapr; + +/// +/// The container for the Dapr placement service. +/// +public sealed class DaprPlacementContainer : IAsyncStartable +{ + private readonly IContainer _container; + + private string _containerName = $"placement-{Guid.NewGuid():N}"; + + /// + /// The internal network alias/name of the container. + /// + public string NetworkAlias => _containerName; + /// + /// The container hostname. + /// + public string Host => _container.Hostname; + /// + /// The container's external port. + /// + public int ExternalPort { get; private set; } + /// + /// THe contains' internal port. + /// + public const int InternalPort = 50006; + + /// + /// Initializes a new instance of the . + /// + /// The Dapr runtime options. + /// The shared Docker network to connect to. + public DaprPlacementContainer(DaprRuntimeOptions options, INetwork network) + { + //Placement service runs via port 50006 + _container = 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(); + } + + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _container.StartAsync(cancellationToken); + ExternalPort = _container.GetMappedPublicPort(InternalPort); + } + + /// + public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken); + /// + public ValueTask DisposeAsync() => _container.DisposeAsync(); +} diff --git a/src/Dapr.TestContainers/Containers/Dapr/DaprSchedulerContainer.cs b/src/Dapr.TestContainers/Containers/Dapr/DaprSchedulerContainer.cs new file mode 100644 index 000000000..99c58fc5d --- /dev/null +++ b/src/Dapr.TestContainers/Containers/Dapr/DaprSchedulerContainer.cs @@ -0,0 +1,107 @@ +// ------------------------------------------------------------------------ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapr.TestContainers.Common; +using Dapr.TestContainers.Common.Options; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; + +namespace Dapr.TestContainers.Containers.Dapr; + +/// +/// The container for the Dapr scheduler service. +/// +public sealed class DaprSchedulerContainer : IAsyncStartable +{ + private readonly IContainer _container; + // Contains the data directory used by this instance of the Dapr scheduler service + private string _hostDataDir = Path.Combine(Path.GetTempPath(), $"dapr-scheduler-{Guid.NewGuid():N}"); + private string _containerName = $"scheduler-{Guid.NewGuid():N}"; + + /// + /// The internal network alias/name of the container. + /// + public string NetworkAlias => _containerName; + /// + /// The container's hostname. + /// + public string Host => _container.Hostname; + /// + /// The container's external port. + /// + public int ExternalPort { get; private set; } + /// + /// The container's internal port. + /// + public const int InternalPort = 51005; + + /// + /// Creates a new instance of a . + /// + public DaprSchedulerContainer(DaprRuntimeOptions options, INetwork network) + { + // Scheduler service runs via port 51005 + const string containerDataDir = "/tmp/dapr-scheduler"; + string[] cmd = + [ + "./scheduler", + "--port", InternalPort.ToString(), + "--etcd-data-dir", containerDataDir + ]; + + //Create a unique temp directory on the host for the test run based on this instance's data directory + Directory.CreateDirectory(_hostDataDir); + + _container = new ContainerBuilder() + .WithImage(options.SchedulerImageTag) + .WithName(_containerName) + .WithNetwork(network) + .WithCommand(cmd.ToArray()) + .WithPortBinding(InternalPort, assignRandomHostPort: true) + // Mount an anonymous volume to /data to ensure the scheduler has write permissions + .WithBindMount(_hostDataDir, containerDataDir, AccessMode.ReadWrite) + .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("api is ready")) + .Build(); + } + + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _container.StartAsync(cancellationToken); + ExternalPort = _container.GetMappedPublicPort(InternalPort); + + // Empty dirs with 0777 inside container: + await _container.ExecAsync(["sh", "-c", "mkdir -p ./default-dapr-scheduler-server-0/dapr-0.1 && chmod 0777 ./default-dapr-scheduler-server-0/dapr-0.1" + ], cancellationToken); + await _container.ExecAsync(["sh", "-c", "mkdir -p ./dapr-scheduler-existing-cluster && chmod 0777 ./dapr-scheduler-existing-cluster" + ], cancellationToken); + } + + /// + public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken); + /// + public ValueTask DisposeAsync() + { + // Remove the data directory if it exists + if (Directory.Exists(_hostDataDir)) + Directory.Delete(_hostDataDir, true); + return _container.DisposeAsync(); + } +} diff --git a/src/Dapr.TestContainers/Containers/Dapr/DaprdContainer.cs b/src/Dapr.TestContainers/Containers/Dapr/DaprdContainer.cs new file mode 100644 index 000000000..3ad4a4faa --- /dev/null +++ b/src/Dapr.TestContainers/Containers/Dapr/DaprdContainer.cs @@ -0,0 +1,134 @@ +// ------------------------------------------------------------------------ +// 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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Dapr.TestContainers.Common; +using Dapr.TestContainers.Common.Options; +using DotNet.Testcontainers; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; + +namespace Dapr.TestContainers.Containers.Dapr; + +/// +/// The container for the Dapr runtime. +/// +public sealed class DaprdContainer : IAsyncStartable +{ + private const int InternalHttpPort = 3500; + private const int InternalGrpcPort = 50001; + private readonly IContainer _container; + + private string _containerName = $"dapr-{Guid.NewGuid():N}"; + + /// + /// The internal network alias/name of the container. + /// + public string NetworkAlias => _containerName; + /// + /// The HTTP port of the Dapr runtime. + /// + public int HttpPort { get; private set; } + /// + /// The gRPC port of the Dapr runtime. + /// + public int GrpcPort { get; private set; } + + /// + /// The hostname to locate the Dapr runtime on in the shared Docker network. + /// + public const string ContainerHostAlias = "host.docker.internal"; + + /// + /// Used to initialize a new instance of a .. + /// + /// The ID of the app to initialize daprd with. + /// The path to the Dapr resources directory. + /// The Dapr runtime options. + /// The shared Docker network to connect to. + /// The hostname and port of the Placement service. + /// The hostname and port of the Scheduler service. + public DaprdContainer(string appId, string componentsHostFolder, DaprRuntimeOptions options, INetwork network, HostPortPair? placementHostAndPort = null, HostPortPair? schedulerHostAndPort = null) + { + const string componentsPath = "/components"; + var cmd = + new List + { + "/daprd", + "-app-id", appId, + "-app-port", options.AppPort.ToString(), + "-app-channel-address", "host.docker.internal", + "-dapr-http-port", InternalHttpPort.ToString(), + "-dapr-grpc-port", InternalGrpcPort.ToString(), + "-log-level", options.LogLevel.ToString().ToLowerInvariant(), + "-resources-path", componentsPath + }; + + if (placementHostAndPort is not null) + { + cmd.Add("-placement-host-address"); + cmd.Add(placementHostAndPort.ToString()); + } + else + { + // Explicitly disable placement if not provided to speed up startup + cmd.Add("-placement-host-address"); + cmd.Add(""); + } + + if (schedulerHostAndPort is not null) + { + cmd.Add("-scheduler-host-address"); + cmd.Add(schedulerHostAndPort.ToString()); + } + else + { + // Explicitly disable scheduler if not provider + cmd.Add("-scheduler-host-address"); + cmd.Add(""); + } + + _container = 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(@"^dapr initialized. Status: Running. Init Elapsed ")) + .Build(); + } + + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _container.StartAsync(cancellationToken); + HttpPort = _container.GetMappedPublicPort(InternalHttpPort); + GrpcPort = _container.GetMappedPublicPort(InternalGrpcPort); + } + + /// + public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken); + /// + public ValueTask DisposeAsync() => _container.DisposeAsync(); +} diff --git a/src/Dapr.TestContainers/Containers/LocalStorageCryptographyContainer.cs b/src/Dapr.TestContainers/Containers/LocalStorageCryptographyContainer.cs new file mode 100644 index 000000000..71dc85941 --- /dev/null +++ b/src/Dapr.TestContainers/Containers/LocalStorageCryptographyContainer.cs @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------ +// 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.IO; + +namespace Dapr.TestContainers.Containers; + +/// +/// This is an odd one because it's not actually a container - just the entry point for the YAML file builder. +/// +public sealed class LocalStorageCryptographyContainer +{ + /// + /// Builds out the YAML components for the local storage cryptography implementation. + /// + public static class Yaml + { + /// + /// Writes a + /// + /// + /// + /// + /// + public static string WriteCryptoYamlToFolder(string folderPath, string keyPath, string fileName = "local-crypto.yaml") + { + var yaml = GetLocalStorageYaml(keyPath); + return WriteToFolder(folderPath, fileName, yaml); + } + + private static string WriteToFolder(string folderPath, string fileName, string yaml) + { + Directory.CreateDirectory(folderPath); + var fullPath = Path.Combine(folderPath, fileName); + File.WriteAllText(fullPath, yaml); + return fullPath; + } + + private static string GetLocalStorageYaml(string keyPath) => + $@"apiVersion: dapr.io/v1alpha +kind: Component +metadata: + name: {Constants.DaprComponentNames.CryptographyComponentName} +spec: + type: crypto.dapr.localstorage + version: v1 + metadata: + - name: path + value: {keyPath}"; + } +} diff --git a/src/Dapr.TestContainers/Containers/OllamaContainer.cs b/src/Dapr.TestContainers/Containers/OllamaContainer.cs new file mode 100644 index 000000000..f2dd4006a --- /dev/null +++ b/src/Dapr.TestContainers/Containers/OllamaContainer.cs @@ -0,0 +1,115 @@ +// ------------------------------------------------------------------------ +// 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; +using System.Threading.Tasks; +using Dapr.TestContainers.Common; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; + +namespace Dapr.TestContainers.Containers; + +/// +/// Provides an Ollama container. +/// +public sealed class OllamaContainer : IAsyncStartable +{ + private const int InternalPort = 11434; + private string _containerName = $"ollama-{Guid.NewGuid():N}"; + + private readonly IContainer _container; + + /// + /// Provides an Ollama container. + /// + public OllamaContainer(INetwork network) + { + _container = new ContainerBuilder() + .WithImage("ollama/ollama") + .WithName(_containerName) + .WithNetwork(network) + .WithPortBinding(InternalPort, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(InternalPort)) + .Build(); + } + + /// + /// The internal container port used by Ollama. + /// + public const int ContainerPort = InternalPort; + /// + /// The internal network alias/name of the container. + /// + public string NetworkAlias => _containerName; + /// + /// The hostname of the container. + /// + public string Host => _container.Hostname; + /// + /// The port of the container. + /// + public int Port { get; private set; } + + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _container.StartAsync(cancellationToken); + Port = _container.GetMappedPublicPort(InternalPort); + } + + /// + public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken); + /// + public ValueTask DisposeAsync() => _container.DisposeAsync(); + + /// + /// Builds out the YAML components for Ollama. + /// + public static class Yaml + { + /// + /// Writes the component YAML. + /// + public static string WriteConversationYamlToFolder(string folderPath, string fileName = "ollama-conversation.yaml", string model = "smollm:135m", string cacheTtl = "10m", string endpoint = "http://localhost:11434/v1") + { + var yaml = GetConversationYaml(model, cacheTtl, endpoint); + return WriteToFolder(folderPath, fileName, yaml); + } + + private static string WriteToFolder(string folderPath, string fileName, string yaml) + { + Directory.CreateDirectory(folderPath); + var fullPath = Path.Combine(folderPath, fileName); + File.WriteAllText(fullPath, yaml); + return fullPath; + } + + private static string GetConversationYaml(string model, string cacheTtl, string endpoint) => + $@"apiVersion: dapr.io/v1alpha +kind: Component +metadata: + name: {Constants.DaprComponentNames.ConversationComponentName} +spec: + type: conversation.ollama + metadata: + - name: model + value: {model} + - name: cacheTTL + value: {cacheTtl} + - name: endpoint + value: {endpoint}"; + } +} diff --git a/src/Dapr.TestContainers/Containers/RabbitMqContainer.cs b/src/Dapr.TestContainers/Containers/RabbitMqContainer.cs new file mode 100644 index 000000000..e5c5eda94 --- /dev/null +++ b/src/Dapr.TestContainers/Containers/RabbitMqContainer.cs @@ -0,0 +1,119 @@ +// ------------------------------------------------------------------------ +// 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; +using System.Threading.Tasks; +using Dapr.TestContainers.Common; +using DotNet.Testcontainers; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; + +namespace Dapr.TestContainers.Containers; + +/// +/// Provides a RabbitMQ container. +/// +public sealed class RabbitMqContainer : IAsyncStartable +{ + private const int InternalPort = 5672; + + private readonly IContainer _container; + private string _containerName = $"rabbitmq-{Guid.NewGuid():N}"; + + /// + /// Provides a RabbitMQ container. + /// + public RabbitMqContainer(INetwork network) + { + _container = new ContainerBuilder() + .WithImage("rabbitmq:alpine") + .WithName(_containerName) + .WithNetwork(network) + .WithLogger(ConsoleLogger.Instance) + .WithPortBinding(InternalPort, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(InternalPort)) + .Build(); + } + + /// + /// The internal container port used by RabbitMQ. + /// + public const int ContainerPort = InternalPort; + /// + /// The internal network alias/name of the container. + /// + public string NetworkAlias => _containerName; + /// + /// The hostname of the container. + /// + public string Host => _container.Hostname; + /// + /// The port of the container. + /// + public int Port { get; private set; } + + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _container.StartAsync(cancellationToken); + Port = _container.GetMappedPublicPort(InternalPort); + } + + /// + public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken); + /// + public ValueTask DisposeAsync() => _container.DisposeAsync(); + + /// + /// Builds out the YAML components for RabbitMQ. + /// + public static class Yaml + { + /// + /// Writes a PubSub YAML component. + /// + public static string WritePubSubYamlToFolder(string folderPath, string fileName = "rabbitmq-pubsub.yaml", string rabbitmqHost = "localhost:5672") + { + var yaml = GetPubSubYaml(rabbitmqHost); + return WriteToFolder(folderPath, fileName, yaml); + } + + private static string WriteToFolder(string folderPath, string fileName, string yaml) + { + Directory.CreateDirectory(folderPath); + var fullPath = Path.Combine(folderPath, fileName); + File.WriteAllText(fullPath, yaml); + return fullPath; + } + + private static string GetPubSubYaml(string rabbitmqHost) => + $@"apiVersion: dapr.io/v1alpha +kind: Component +metadata: + name: {Constants.DaprComponentNames.PubSubComponentName} +spec: + type: pubsub.rabbitmq + metadata: + - name: protocol + value: amqp + - name: hostname + value: {rabbitmqHost} + - name: username + value: default + - name: password + value: default"; + } +} diff --git a/src/Dapr.TestContainers/Containers/RedisContainer.cs b/src/Dapr.TestContainers/Containers/RedisContainer.cs new file mode 100644 index 000000000..d6daec0b9 --- /dev/null +++ b/src/Dapr.TestContainers/Containers/RedisContainer.cs @@ -0,0 +1,153 @@ +// ------------------------------------------------------------------------ +// 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; +using System.Threading.Tasks; +using Dapr.TestContainers.Common; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; + +namespace Dapr.TestContainers.Containers; + +/// +/// Provides a Redis container. +/// +public sealed class RedisContainer : IAsyncStartable +{ + private const int InternalPort = 6379; + private readonly string _containerName = $"redis-{Guid.NewGuid():N}"; + + private readonly IContainer _container; + + /// + /// Provides a Redis container. + /// + public RedisContainer(INetwork network) + { + _container = new ContainerBuilder() + .WithImage("redis:alpine") + .WithName(_containerName) + .WithNetwork(network) + .WithPortBinding(InternalPort, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(InternalPort)) + .Build(); + } + + /// + /// The internal container port used by Redis. + /// + public const int ContainerPort = InternalPort; + /// + /// The internal network alias/name of the container. + /// + public string NetworkAlias => _containerName; + /// + /// The hostname of the container. + /// + public string Host => _container.Hostname; + /// + /// The port of the container. + /// + public int Port { get; private set; } + + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _container.StartAsync(cancellationToken); + Port = _container.GetMappedPublicPort(InternalPort); + } + /// + public Task StopAsync(CancellationToken cancellationToken = default) => _container.StopAsync(cancellationToken); + /// + public ValueTask DisposeAsync() => _container.DisposeAsync(); + + /// + /// Builds out each of the YAML components for Redis + /// + public static class Yaml + { + /// + /// Writes a state store YAML component. + /// + public static string WriteStateStoreYamlToFolder(string folderPath, string fileName = "redis-state.yaml",string redisHost = "localhost:6379", + string? passwordSecretName = null) + { + var yaml = GetRedisStateStoreYaml(redisHost, passwordSecretName); + return WriteToFolder(folderPath, fileName, yaml); + } + + /// + /// Writes a distributed lock YAML component. + /// + public static string WriteDistributedLockYamlToFolder(string folderPath, string fileName = "redis-lock.yaml", + string redisHost = "localhost:6379", string? passwordSecretName = null) + { + var yaml = GetDistributedLockYaml(redisHost, passwordSecretName); + return WriteToFolder(folderPath, fileName, yaml); + } + + private static string WriteToFolder(string folderPath, string fileName, string yaml) + { + Directory.CreateDirectory(folderPath); + var fullPath = Path.Combine(folderPath, fileName); + File.WriteAllText(fullPath, yaml); + return fullPath; + } + + private static string BuildSecretBlock(string? passwordSecretName) => + passwordSecretName is null + ? string.Empty + : $" - name: redisPassword\n secretKeyRef:\n name: {passwordSecretName}\n key: redis-password\n"; + + private static string GetDistributedLockYaml(string redisHost, string? passwordSecretName) + { + var secretBlock = BuildSecretBlock(passwordSecretName); + return + $@"apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: {Constants.DaprComponentNames.DistributedLockComponentName} + namespace: default +spec: + type: lock.redis + version: v1 + metadata: + - name: redisHost + value: {redisHost} +{secretBlock}"; + } + + private static string GetRedisStateStoreYaml(string redisHost, string? passwordSecretName) + { + var secretBlock = BuildSecretBlock(passwordSecretName); + return + $@"apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: {Constants.DaprComponentNames.StateManagementComponentName} + namespace: default +spec: + type: state.redis + version: v1 + metadata: + - name: redisHost + value: {redisHost} + - name: actorStateStore + value: ""true"" +{secretBlock}"; + } + } +} diff --git a/src/Dapr.TestContainers/Dapr.TestContainers.csproj b/src/Dapr.TestContainers/Dapr.TestContainers.csproj new file mode 100644 index 000000000..96485ea10 --- /dev/null +++ b/src/Dapr.TestContainers/Dapr.TestContainers.csproj @@ -0,0 +1,15 @@ + + + + enable + Dapr.TestContainers + Dapr TestContainers implementation + TestContainers for validating Dapr applications + + + + + + + + diff --git a/src/Dapr.TestContainers/Harnesses/ActorHarness.cs b/src/Dapr.TestContainers/Harnesses/ActorHarness.cs new file mode 100644 index 000000000..19cd7a379 --- /dev/null +++ b/src/Dapr.TestContainers/Harnesses/ActorHarness.cs @@ -0,0 +1,69 @@ +// ------------------------------------------------------------------------ +// 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.Threading; +using System.Threading.Tasks; +using Dapr.TestContainers.Common.Options; +using Dapr.TestContainers.Containers; +using Dapr.TestContainers.Containers.Dapr; + +namespace Dapr.TestContainers.Harnesses; + +/// +/// Provides an implementation harness for Dapr's actor building block. +/// +public sealed class ActorHarness : BaseHarness +{ + private readonly RedisContainer _redis; + private readonly DaprPlacementContainer _placement; + private readonly DaprSchedulerContainer _schedueler; + private readonly string componentsDir; + + /// + /// Provides an implementation harness for Dapr's actor building block. + /// + /// The directory to Dapr components. + /// The test app to validate in the harness. + /// The dapr runtime options. + public ActorHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options) : base(componentsDir, startApp, options) + { + this.componentsDir = componentsDir; + _placement = new DaprPlacementContainer(options, Network); + _schedueler = new DaprSchedulerContainer(options, Network); + _redis = new(Network); + } + + /// + protected override async Task OnInitializeAsync(CancellationToken cancellationToken) + { + // Start infrastructure + await _redis.StartAsync(cancellationToken); + await _placement.StartAsync(cancellationToken); + await _schedueler.StartAsync(cancellationToken); + + // Emit component YAMLs pointing to Redis + RedisContainer.Yaml.WriteStateStoreYamlToFolder(componentsDir, redisHost: $"{_redis.NetworkAlias}:{RedisContainer.ContainerPort}"); + + DaprPlacementExternalPort = _placement.ExternalPort; + DaprSchedulerExternalPort = _schedueler.ExternalPort; + } + + /// + protected override async ValueTask OnDisposeAsync() + { + await _redis.DisposeAsync(); + await _placement.DisposeAsync(); + await _schedueler.DisposeAsync(); + } +} diff --git a/src/Dapr.TestContainers/Harnesses/BaseHarness.cs b/src/Dapr.TestContainers/Harnesses/BaseHarness.cs new file mode 100644 index 000000000..b19589b90 --- /dev/null +++ b/src/Dapr.TestContainers/Harnesses/BaseHarness.cs @@ -0,0 +1,180 @@ +// ------------------------------------------------------------------------ +// 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; +using System.Threading.Tasks; +using Dapr.TestContainers.Common; +using Dapr.TestContainers.Common.Options; +using Dapr.TestContainers.Containers.Dapr; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Networks; + +namespace Dapr.TestContainers.Harnesses; + +/// +/// Provides a base harness for building Dapr building block harnesses. +/// +public abstract class BaseHarness(string componentsDirectory, Func? startApp, DaprRuntimeOptions options) : IAsyncContainerFixture +{ + /// + /// The Daprd container exposed by the harness. + /// + private protected DaprdContainer? _daprd; + + private readonly TaskCompletionSource _sidecarPortsReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// A shared Docker network that's safer for CI environments - each harness instance gets its own network for isolation. + /// + protected readonly INetwork Network = new NetworkBuilder().Build(); + + /// + /// Gets the port that the Dapr sidecar is configured to talk to - this is the port the test application should use. + /// + public int AppPort { get; } = PortUtilities.GetAvailablePort(); + + /// + /// The HTTP port used by the Daprd container. + /// + public int DaprHttpPort => _daprd?.HttpPort ?? 0; + + /// + /// The HTTP endpoint used by the Daprd container. + /// + public string DaprHttpEndpoint => $"http://{DaprdContainer.ContainerHostAlias}:{DaprHttpPort}"; + + /// + /// The gRPC port used by the Daprd container. + /// + public int DaprGrpcPort => _daprd?.GrpcPort ?? 0; + + /// + /// The gRPC endpoint used by the Daprd container. + /// + public string DaprGrpcEndpoint => $"http://{DaprdContainer.ContainerHostAlias}:{DaprGrpcPort}"; + + /// + /// The Dapr components directory. + /// + protected string ComponentsDirectory => componentsDirectory; + + /// + /// The port of the Dapr placement service, if started. + /// + protected int? DaprPlacementExternalPort { get; set; } + + /// + /// The network alias of the placement container, if started. + /// + protected string? DaprPlacementAlias { get; set; } + + /// + /// The port of the Dapr scheduler service, if started. + /// + protected int? DaprSchedulerExternalPort { get; set; } + + /// + /// The network alias of the scheduler container, if started. + /// + protected string? DaprSchedulerAlias { get; set; } + + /// + /// The specific container startup logic for the harness. + /// + /// Cancellation token. + protected abstract Task OnInitializeAsync(CancellationToken cancellationToken); + + /// + /// Initializes and runs the test app with the harness. + /// + /// Cancellation token. + /// + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + // Run the actual container orchestration defined in the subclass to set up any pre-requisite containers before loading daprd and the start app, if specified + await OnInitializeAsync(cancellationToken); + + // Configure and start daprd; point at placement & scheduler + _daprd = new DaprdContainer( + appId: options.AppId, + componentsHostFolder: ComponentsDirectory, + options: options with {AppPort = this.AppPort}, + Network, + DaprPlacementExternalPort is null || DaprPlacementAlias is null ? null : new HostPortPair(DaprPlacementAlias, DaprPlacementContainer.InternalPort), + DaprSchedulerExternalPort is null || DaprSchedulerAlias is null ? null : new HostPortPair(DaprSchedulerAlias, DaprSchedulerContainer.InternalPort)); + + var daprdTask = Task.Run(async () => + { + await _daprd!.StartAsync(cancellationToken); + + _sidecarPortsReady.TrySetResult(); + }, cancellationToken); + + Task? appTask = null; + if (startApp is not null) + { + appTask = Task.Run(async () => + { + await _sidecarPortsReady.Task.WaitAsync(cancellationToken); + await startApp(AppPort); + }, cancellationToken); + } + + await Task.WhenAll(daprdTask, appTask ?? Task.CompletedTask); + } + + /// + /// Disposes the resources in this harness. + /// + public virtual async ValueTask DisposeAsync() + { + await OnDisposeAsync(); + + if (_daprd is not null) + await _daprd.DisposeAsync(); + + // Clean up the per-instance network + await Network.DisposeAsync(); + + // Clean up generated YAML files + CleanupComponents(ComponentsDirectory); + + GC.SuppressFinalize(this); + } + + /// + /// Override this to dispose harness-specific resources before base cleanup. + /// + protected virtual ValueTask OnDisposeAsync() => ValueTask.CompletedTask; + + /// + /// Deletes the specified directory recursively as part of a clean-up operation. + /// + /// The clean to clean up. + protected static void CleanupComponents(string path) + { + if (Directory.Exists(path)) + { + try + { + Directory.Delete(path, true); + } + catch + { + // Ignore cleanup errors + } + } + } +} diff --git a/src/Dapr.TestContainers/Harnesses/ConversationHarness.cs b/src/Dapr.TestContainers/Harnesses/ConversationHarness.cs new file mode 100644 index 000000000..ced196129 --- /dev/null +++ b/src/Dapr.TestContainers/Harnesses/ConversationHarness.cs @@ -0,0 +1,58 @@ +// ------------------------------------------------------------------------ +// 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.Threading; +using System.Threading.Tasks; +using Dapr.TestContainers.Common.Options; +using Dapr.TestContainers.Containers; + +namespace Dapr.TestContainers.Harnesses; + +/// +/// Provides an implementation harness for conversation functionality. +/// +public sealed class ConversationHarness : BaseHarness +{ + private readonly OllamaContainer _ollama; + private readonly string componentsDir; + + /// + /// Provides an implementation harness for conversation functionality. + /// + /// The directory to Dapr components. + /// The test app to validate in the harness. + /// The Dapr runtime options. + public ConversationHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options) : base(componentsDir, startApp, options) + { + this.componentsDir = componentsDir; + _ollama = new(Network); + } + + /// + protected override async Task OnInitializeAsync(CancellationToken cancellationToken) + { + // Start infrastructure + await _ollama.StartAsync(cancellationToken); + + // Emit component YAMLs for Ollama (use the default tiny model) + OllamaContainer.Yaml.WriteConversationYamlToFolder(componentsDir, + endpoint: $"http://{_ollama.NetworkAlias}:{OllamaContainer.ContainerPort}/v1"); + } + + /// + protected override async ValueTask OnDisposeAsync() + { + await _ollama.DisposeAsync(); + } +} diff --git a/src/Dapr.TestContainers/Harnesses/CryptographyHarness.cs b/src/Dapr.TestContainers/Harnesses/CryptographyHarness.cs new file mode 100644 index 000000000..9bd9a78a8 --- /dev/null +++ b/src/Dapr.TestContainers/Harnesses/CryptographyHarness.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------ +// 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.Threading; +using System.Threading.Tasks; +using Dapr.TestContainers.Common.Options; +using Dapr.TestContainers.Containers; + +namespace Dapr.TestContainers.Harnesses; + +/// +/// Provides an implementation harness for Dapr's cryptography building block. +/// +public sealed class CryptographyHarness : BaseHarness +{ + private readonly string componentsDir; + private readonly string keyPath; + + /// + /// Provides an implementation harness for Dapr's cryptography building block. + /// + /// The directory to Dapr components. + /// The test app to validate in the harness. + /// The Dapr runtime options. + /// The path locally to the cryptography keys to use. + public CryptographyHarness(string componentsDir, Func? startApp, string keyPath, DaprRuntimeOptions options) : base(componentsDir, startApp, options) + { + this.componentsDir = componentsDir; + this.keyPath = keyPath; + } + + /// + protected override Task OnInitializeAsync(CancellationToken cancellationToken) + { + // Emit the component YAML describing the local crypto key store + LocalStorageCryptographyContainer.Yaml.WriteCryptoYamlToFolder(componentsDir, keyPath); + + return Task.CompletedTask; + } +} diff --git a/src/Dapr.TestContainers/Harnesses/DistributedLockHarness.cs b/src/Dapr.TestContainers/Harnesses/DistributedLockHarness.cs new file mode 100644 index 000000000..237f2cb13 --- /dev/null +++ b/src/Dapr.TestContainers/Harnesses/DistributedLockHarness.cs @@ -0,0 +1,57 @@ +// ------------------------------------------------------------------------ +// 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.Threading; +using System.Threading.Tasks; +using Dapr.TestContainers.Common.Options; +using Dapr.TestContainers.Containers; + +namespace Dapr.TestContainers.Harnesses; + +/// +/// Provides an implementation harness for Dapr's distributed lock building block. +/// +public sealed class DistributedLockHarness : BaseHarness +{ + private readonly RedisContainer _redis; + private readonly string componentsDir; + + /// + /// Provides an implementation harness for Dapr's distributed lock building block. + /// + /// The directory to Dapr components. + /// The test app to validate in the harness. + /// The Dapr runtime options. + public DistributedLockHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options) : base(componentsDir, startApp, options) + { + this.componentsDir = componentsDir; + _redis = new(Network); + } + + /// + protected override async Task OnInitializeAsync(CancellationToken cancellationToken) + { + // Start infrastructure + await _redis.StartAsync(cancellationToken); + + // Emit component YAMLs pointing to Redis + RedisContainer.Yaml.WriteDistributedLockYamlToFolder(componentsDir, redisHost: $"{_redis.NetworkAlias}:{RedisContainer.ContainerPort}"); + } + + /// + protected override async ValueTask OnDisposeAsync() + { + await _redis.DisposeAsync(); + } +} diff --git a/src/Dapr.TestContainers/Harnesses/JobsHarness.cs b/src/Dapr.TestContainers/Harnesses/JobsHarness.cs new file mode 100644 index 000000000..7dbc32a7c --- /dev/null +++ b/src/Dapr.TestContainers/Harnesses/JobsHarness.cs @@ -0,0 +1,56 @@ +// ------------------------------------------------------------------------ +// 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.Threading; +using System.Threading.Tasks; +using Dapr.TestContainers.Common.Options; +using Dapr.TestContainers.Containers.Dapr; + +namespace Dapr.TestContainers.Harnesses; + +/// +/// Provides an implementation harness for the Jobs building block. +/// +public sealed class JobsHarness : BaseHarness +{ + private readonly DaprSchedulerContainer _scheduler; + private readonly string componentsDir; + + /// + /// Provides an implementation harness for the Jobs building block. + /// + /// The directory to Dapr components. + /// The test app to validate in the harness. + /// The Dapr runtime options. + public JobsHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options) : base(componentsDir, startApp, options) + { + this.componentsDir = componentsDir; + _scheduler = new DaprSchedulerContainer(options, Network); + } + + /// + protected override async Task OnInitializeAsync(CancellationToken cancellationToken) + { + // Start the infrastructure + await _scheduler.StartAsync(cancellationToken); + DaprSchedulerExternalPort = _scheduler.ExternalPort; + DaprSchedulerAlias = _scheduler.NetworkAlias; + } + + /// + protected override async ValueTask OnDisposeAsync() + { + await _scheduler.DisposeAsync(); + } +} diff --git a/src/Dapr.TestContainers/Harnesses/PubSubHarness.cs b/src/Dapr.TestContainers/Harnesses/PubSubHarness.cs new file mode 100644 index 000000000..13931702d --- /dev/null +++ b/src/Dapr.TestContainers/Harnesses/PubSubHarness.cs @@ -0,0 +1,57 @@ +// ------------------------------------------------------------------------ +// 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.Threading; +using System.Threading.Tasks; +using Dapr.TestContainers.Common.Options; +using Dapr.TestContainers.Containers; + +namespace Dapr.TestContainers.Harnesses; + +/// +/// Provides an implementation harness for Dapr's pub/sub building block. +/// +public sealed class PubSubHarness : BaseHarness +{ + private readonly RabbitMqContainer _rabbitmq; + private readonly string componentsDir; + + /// + /// Provides an implementation harness for Dapr's pub/sub building block. + /// + /// The directory to Dapr components. + /// The test app to validate in the harness. + /// The Dapr runtime options. + public PubSubHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options): base(componentsDir, startApp, options) + { + this.componentsDir = componentsDir; + _rabbitmq = new(Network); + } + + /// + protected override async Task OnInitializeAsync(CancellationToken cancellationToken) + { + // Start infrastructure + await _rabbitmq.StartAsync(cancellationToken); + + // Emit component YAMLs pointing to RabbitMQ + RabbitMqContainer.Yaml.WritePubSubYamlToFolder(componentsDir, rabbitmqHost: $"{_rabbitmq.NetworkAlias}:{RabbitMqContainer.ContainerPort}"); + } + + /// + protected override async ValueTask OnDisposeAsync() + { + await _rabbitmq.DisposeAsync(); + } +} diff --git a/src/Dapr.TestContainers/Harnesses/StateManagementHarness.cs b/src/Dapr.TestContainers/Harnesses/StateManagementHarness.cs new file mode 100644 index 000000000..703c51bd8 --- /dev/null +++ b/src/Dapr.TestContainers/Harnesses/StateManagementHarness.cs @@ -0,0 +1,57 @@ +// ------------------------------------------------------------------------ +// 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.Threading; +using System.Threading.Tasks; +using Dapr.TestContainers.Common.Options; +using Dapr.TestContainers.Containers; + +namespace Dapr.TestContainers.Harnesses; + +/// +/// Provides an implementation harness for Dapr's state management building block. +/// +public sealed class StateManagementHarness : BaseHarness +{ + private readonly RedisContainer _redis; + private readonly string componentsDir; + + /// + /// Provides an implementation harness for Dapr's state management building block. + /// + /// The directory to Dapr components. + /// The test app to validate in the harness. + /// The Dapr runtime options. + public StateManagementHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options) : base(componentsDir, startApp, options) + { + this.componentsDir = componentsDir; + _redis = new(Network); + } + + /// + protected override async Task OnInitializeAsync(CancellationToken cancellationToken) + { + // Start infrastructure + await _redis.StartAsync(cancellationToken); + + // Emit component YAMLs pointing to Redis + RedisContainer.Yaml.WriteStateStoreYamlToFolder(componentsDir, redisHost: $"{_redis.NetworkAlias}:{RedisContainer.ContainerPort}"); + } + + /// + protected override async ValueTask OnDisposeAsync() + { + await _redis.DisposeAsync(); + } +} diff --git a/src/Dapr.TestContainers/Harnesses/WorkflowHarness.cs b/src/Dapr.TestContainers/Harnesses/WorkflowHarness.cs new file mode 100644 index 000000000..6d869af00 --- /dev/null +++ b/src/Dapr.TestContainers/Harnesses/WorkflowHarness.cs @@ -0,0 +1,68 @@ +// ------------------------------------------------------------------------ +// 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.Threading; +using System.Threading.Tasks; +using Dapr.TestContainers.Common.Options; +using Dapr.TestContainers.Containers; +using Dapr.TestContainers.Containers.Dapr; + +namespace Dapr.TestContainers.Harnesses; + +/// +/// Provides an implementation harness for Dapr's Workflow building block. +/// +public sealed class WorkflowHarness : BaseHarness +{ + private readonly RedisContainer _redis; + private readonly DaprPlacementContainer _placement; + private readonly DaprSchedulerContainer _scheduler; + + /// + /// Provides an implementation harness for Dapr's Workflow building block. + /// + /// The directory to Dapr components. + /// The test app to validate in the harness. + /// The Dapr runtime options. + public WorkflowHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options) : base(componentsDir, startApp, options) + { + _placement = new DaprPlacementContainer(options, Network); + _scheduler = new DaprSchedulerContainer(options, Network); + _redis = new(Network); + } + + /// + protected override async Task OnInitializeAsync(CancellationToken cancellationToken) + { + // Start infrastructure + await _redis.StartAsync(cancellationToken); + await _placement.StartAsync(cancellationToken); + await _scheduler.StartAsync(cancellationToken); + + // Emit component YAMLs pointing to Redis + RedisContainer.Yaml.WriteStateStoreYamlToFolder(ComponentsDirectory, redisHost: $"{_redis.NetworkAlias}:{RedisContainer.ContainerPort}"); + + // Set the service ports + this.DaprPlacementExternalPort = _placement.ExternalPort; + this.DaprSchedulerExternalPort = _scheduler.ExternalPort; + } + + /// + protected override async ValueTask OnDisposeAsync() + { + await _placement.DisposeAsync(); + await _scheduler.DisposeAsync(); + await _redis.DisposeAsync(); + } +} diff --git a/test/Dapr.E2E.Test.Jobs/Dapr.E2E.Test.Jobs.csproj b/test/Dapr.E2E.Test.Jobs/Dapr.E2E.Test.Jobs.csproj new file mode 100644 index 000000000..bcbe137eb --- /dev/null +++ b/test/Dapr.E2E.Test.Jobs/Dapr.E2E.Test.Jobs.csproj @@ -0,0 +1,27 @@ + + + + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/test/Dapr.E2E.Test.Jobs/JobsTests.cs b/test/Dapr.E2E.Test.Jobs/JobsTests.cs new file mode 100644 index 000000000..17e4c3b64 --- /dev/null +++ b/test/Dapr.E2E.Test.Jobs/JobsTests.cs @@ -0,0 +1,77 @@ +// ------------------------------------------------------------------------ +// 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.Text; +using Dapr.Jobs; +using Dapr.Jobs.Extensions; +using Dapr.Jobs.Models; +using Dapr.Jobs.Models.Responses; +using Dapr.TestContainers.Common; +using Dapr.TestContainers.Common.Options; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Dapr.E2E.Test.Jobs; + +public sealed class JobsTests +{ + [Fact] + public async Task ShouldScheduleAndReceiveJob() + { + var options = new DaprRuntimeOptions(); + var componentsDir = Path.Combine(Directory.GetCurrentDirectory(), $"jobs-components-{Guid.NewGuid():N}"); + var jobName = $"e2e-job-{Guid.NewGuid():N}"; + var invocationTcs = new TaskCompletionSource<(string payload, string jobName)>(TaskCreationOptions.RunContinuationsAsynchronously); + + var harness = new DaprHarnessBuilder(options).BuildJobs(componentsDir); + await using var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + // Explicitly configure the Dapr client with the correct endpoints + builder.Services.AddDaprJobsClient(configure: (sp, clientBuilder) => + { + var config = sp.GetRequiredService(); + var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"]; + var httpEndpoint = config["DAPR_HTTP_ENDPOINT"]; + + if (!string.IsNullOrEmpty(grpcEndpoint)) + clientBuilder.UseGrpcEndpoint(grpcEndpoint); + if (!string.IsNullOrEmpty(httpEndpoint)) + clientBuilder.UseHttpEndpoint(httpEndpoint); + }); + }) + .ConfigureApp(app => + { + app.MapDaprScheduledJobHandler((string incomingJobName, ReadOnlyMemory payload, + ILogger? logger, CancellationToken _) => + { + logger?.LogInformation("Received job {Job}", incomingJobName); + invocationTcs.TrySetResult((Encoding.UTF8.GetString(payload.Span), incomingJobName)); + }); + }) + .BuildAndStartAsync(); + + // Clean test logic + using var scope = testApp.CreateScope(); + var daprJobsClient = scope.ServiceProvider.GetRequiredService(); + + var payload = "Hello!"u8.ToArray(); + await daprJobsClient.ScheduleJobAsync(jobName, DaprJobSchedule.FromDuration(TimeSpan.FromSeconds(2)), + payload, repeats: 1, overwrite: true); + + var received = await invocationTcs.Task.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.Equal(Encoding.UTF8.GetString(payload), received.payload); + Assert.Equal(jobName, received.jobName); + } +} diff --git a/test/Dapr.E2E.Test.PubSub/Dapr.E2E.Test.PubSub.csproj b/test/Dapr.E2E.Test.PubSub/Dapr.E2E.Test.PubSub.csproj new file mode 100644 index 000000000..60d871640 --- /dev/null +++ b/test/Dapr.E2E.Test.PubSub/Dapr.E2E.Test.PubSub.csproj @@ -0,0 +1,27 @@ + + + + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/test/Dapr.E2E.Test.PubSub/PubsubTests.cs b/test/Dapr.E2E.Test.PubSub/PubsubTests.cs new file mode 100644 index 000000000..920a2aa4c --- /dev/null +++ b/test/Dapr.E2E.Test.PubSub/PubsubTests.cs @@ -0,0 +1,98 @@ +// // ------------------------------------------------------------------------ +// // 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 Dapr.Client; +// using Dapr.TestContainers; +// using Dapr.TestContainers.Common; +// using Dapr.TestContainers.Common.Options; +// using Microsoft.AspNetCore.Builder; +// using Microsoft.AspNetCore.Hosting; +// using Microsoft.AspNetCore.Http; +// using Microsoft.Extensions.DependencyInjection; +// using Microsoft.Extensions.Hosting; +// +// namespace Dapr.E2E.Test.PubSub; +// +// public class PubsubTests +// { +// [Fact] +// public async Task ShouldEnablePublishAndSubscribe() +// { +// var options = new DaprRuntimeOptions(); +// var componentsDir = Path.Combine(Directory.GetCurrentDirectory(), "pubsub-components"); +// const string topicName = "test-topic"; +// +// WebApplication? app = null; +// +// var messageReceived = new TaskCompletionSource(); +// +// // Build and initialize the test harness +// var harnessBuilder = new DaprHarnessBuilder(options, StartApp); +// var harness = harnessBuilder.BuildPubSub(componentsDir); +// +// try +// { +// await harness.InitializeAsync(); +// +// var testAppBuilder = new HostApplicationBuilder(); +// testAppBuilder.Services.AddDaprClient(); +// using var testApp = testAppBuilder.Build(); +// await using var scope = testApp.Services.CreateAsyncScope(); +// var daprClient = scope.ServiceProvider.GetRequiredService(); +// +// // Use DaprClient to publish a message +// const string testMessage = "Hello!"; +// await daprClient.PublishEventAsync(Constants.DaprComponentNames.PubSubComponentName, topicName, +// testMessage); +// +// // Wait for the app to receive the message via the sidecar +// var result = await messageReceived.Task.WaitAsync(TimeSpan.FromSeconds(10)); +// Assert.Equal(testMessage, result); +// } +// finally +// { +// await harness.DisposeAsync(); +// +// if (app != null) +// await app.DisposeAsync(); +// } +// +// return; +// +// // Define the app startup +// async Task StartApp(int port) +// { +// var builder = WebApplication.CreateBuilder(); +// builder.WebHost.UseUrls($"http://localhost:{port}"); +// builder.Services.AddControllers().AddDapr(); +// +// app = builder.Build(); +// +// // Setup the subscription endpoint +// app.UseCloudEvents(); +// app.MapSubscribeHandler(); +// +// // Endpoint that Dapr will call when a message is published +// app.MapPost("/message-handler", async (HttpContext context) => +// { +// var data = await context.Request.ReadFromJsonAsync(); +// messageReceived.TrySetResult(data?.ToString() ?? "empty"); +// return Results.Ok(); +// }) +// .WithTopic(Constants.DaprComponentNames.PubSubComponentName, topicName); +// +// await app.StartAsync(); +// } +// } +// +// } diff --git a/test/Dapr.E2E.Test.Workflow/Dapr.E2E.Test.Workflow.csproj b/test/Dapr.E2E.Test.Workflow/Dapr.E2E.Test.Workflow.csproj new file mode 100644 index 000000000..fc4a8b0db --- /dev/null +++ b/test/Dapr.E2E.Test.Workflow/Dapr.E2E.Test.Workflow.csproj @@ -0,0 +1,26 @@ + + + + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/test/Dapr.E2E.Test.Workflow/WorkflowTests.cs b/test/Dapr.E2E.Test.Workflow/WorkflowTests.cs new file mode 100644 index 000000000..889c6d1e4 --- /dev/null +++ b/test/Dapr.E2E.Test.Workflow/WorkflowTests.cs @@ -0,0 +1,105 @@ +// // ------------------------------------------------------------------------ +// // 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 Dapr.TestContainers.Common; +// using Dapr.TestContainers.Common.Options; +// using Dapr.Workflow; +// using Microsoft.AspNetCore.Builder; +// using Microsoft.AspNetCore.Hosting; +// using Microsoft.Extensions.DependencyInjection; +// using Microsoft.Extensions.Logging; +// +// namespace Dapr.E2E.Test.Workflow; +// +// public class WorkflowTests +// { +// [Fact] +// public async Task ShouldTestTaskChaining() +// { +// var options = new DaprRuntimeOptions(); +// var componentsDir = Path.Combine(Directory.GetCurrentDirectory(), $"test-components-{Guid.NewGuid():N}"); +// +// WebApplication? app = null; +// +// // Build and initialize the test harness +// var harnessBuilder = new DaprHarnessBuilder(options, StartApp); +// var harness = harnessBuilder.BuildWorkflow(componentsDir); +// +// try +// { +// await harness.InitializeAsync(); +// +// await using var scope = app!.Services.CreateAsyncScope(); +// var daprWorkflowClient = scope.ServiceProvider.GetRequiredService(); +// +// // Start the workflow +// var workflowId = Guid.NewGuid().ToString("N"); +// const int startingValue = 8; +// +// await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(TestWorkflow), workflowId, startingValue); +// +// var result = await daprWorkflowClient.WaitForWorkflowCompletionAsync(workflowId, true); +// +// Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); +// var resultValue = result.ReadOutputAs(); +// +// Assert.Equal(16, resultValue); +// } +// finally +// { +// await harness.DisposeAsync(); +// if (app is not null) +// await app.DisposeAsync(); +// } +// +// return; +// +// // Define the app startup +// async Task StartApp(int port) +// { +// var builder = WebApplication.CreateBuilder(); +// builder.Logging.ClearProviders(); +// builder.Logging.AddSimpleConsole(); +// builder.WebHost.UseUrls($"http://0.0.0.0:{port}"); +// builder.Services.AddDaprWorkflow(opt => +// { +// opt.RegisterWorkflow(); +// opt.RegisterActivity(); +// }); +// +// Console.WriteLine($"HTTP: {Environment.GetEnvironmentVariable("DAPR_HTTP_ENDPOINT")}"); +// Console.WriteLine($"GRPC: {Environment.GetEnvironmentVariable("DAPR_GRPC_ENDPOINT")}"); +// +// app = builder.Build(); +// await app.StartAsync(); +// } +// } +// +// private sealed class DoublingActivity : WorkflowActivity +// { +// public override Task RunAsync(WorkflowActivityContext context, int input) +// { +// var square = input * 2; +// return Task.FromResult(square); +// } +// } +// +// private sealed class TestWorkflow : Workflow +// { +// public override async Task RunAsync(WorkflowContext context, int input) +// { +// var result = await context.CallActivityAsync(nameof(DoublingActivity), input); +// return result; +// } +// } +// }