diff --git a/Directory.Packages.props b/Directory.Packages.props index 2161e1144..5150a1671 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -90,6 +90,7 @@ + diff --git a/Testcontainers.sln b/Testcontainers.sln index 30abbbec5..9a148f2ad 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -128,6 +128,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.ServiceBus", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Sftp", "src\Testcontainers.Sftp\Testcontainers.Sftp.csproj", "{7D5C6816-0DD2-4E13-A585-033B5D3C80D5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Temporal", "src\Testcontainers.Temporal\Testcontainers.Temporal.csproj", "{24431BF1-7BEB-4C53-BAE8-B9D9F622A240}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Toxiproxy", "src\Testcontainers.Toxiproxy\Testcontainers.Toxiproxy.csproj", "{65A47BA4-4DC8-4206-9B00-CBC87FC944FC}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Typesense", "src\Testcontainers.Typesense\Testcontainers.Typesense.csproj", "{E044A94A-3081-4EE4-8DC6-81601F96DA14}" @@ -266,6 +268,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.ServiceBus.T EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Sftp.Tests", "tests\Testcontainers.Sftp.Tests\Testcontainers.Sftp.Tests.csproj", "{B73C3CC0-9F16-4B34-92BE-6EC0853912C5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Temporal.Tests", "tests\Testcontainers.Temporal.Tests\Testcontainers.Temporal.Tests.csproj", "{28B5DEDF-C19B-4A7E-B276-FC4C83DBB7EF}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Tests", "tests\Testcontainers.Tests\Testcontainers.Tests.csproj", "{27CDB869-A150-4593-958F-6F26E5391E7C}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Toxiproxy.Tests", "tests\Testcontainers.Toxiproxy.Tests\Testcontainers.Toxiproxy.Tests.csproj", "{10726AAA-E93F-4B40-A05E-28308423DABE}" @@ -502,6 +506,10 @@ Global {7D5C6816-0DD2-4E13-A585-033B5D3C80D5}.Debug|Any CPU.Build.0 = Debug|Any CPU {7D5C6816-0DD2-4E13-A585-033B5D3C80D5}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D5C6816-0DD2-4E13-A585-033B5D3C80D5}.Release|Any CPU.Build.0 = Release|Any CPU + {24431BF1-7BEB-4C53-BAE8-B9D9F622A240}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {24431BF1-7BEB-4C53-BAE8-B9D9F622A240}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24431BF1-7BEB-4C53-BAE8-B9D9F622A240}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24431BF1-7BEB-4C53-BAE8-B9D9F622A240}.Release|Any CPU.Build.0 = Release|Any CPU {65A47BA4-4DC8-4206-9B00-CBC87FC944FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {65A47BA4-4DC8-4206-9B00-CBC87FC944FC}.Debug|Any CPU.Build.0 = Debug|Any CPU {65A47BA4-4DC8-4206-9B00-CBC87FC944FC}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -778,6 +786,10 @@ Global {B73C3CC0-9F16-4B34-92BE-6EC0853912C5}.Debug|Any CPU.Build.0 = Debug|Any CPU {B73C3CC0-9F16-4B34-92BE-6EC0853912C5}.Release|Any CPU.ActiveCfg = Release|Any CPU {B73C3CC0-9F16-4B34-92BE-6EC0853912C5}.Release|Any CPU.Build.0 = Release|Any CPU + {28B5DEDF-C19B-4A7E-B276-FC4C83DBB7EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {28B5DEDF-C19B-4A7E-B276-FC4C83DBB7EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28B5DEDF-C19B-4A7E-B276-FC4C83DBB7EF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {28B5DEDF-C19B-4A7E-B276-FC4C83DBB7EF}.Release|Any CPU.Build.0 = Release|Any CPU {27CDB869-A150-4593-958F-6F26E5391E7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {27CDB869-A150-4593-958F-6F26E5391E7C}.Debug|Any CPU.Build.0 = Debug|Any CPU {27CDB869-A150-4593-958F-6F26E5391E7C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -866,6 +878,7 @@ Global {45D6F69C-4D87-4130-AA90-0DB2F7460DAE} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {2E39E532-B81E-4B48-A004-FAE18EDF9E79} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {7D5C6816-0DD2-4E13-A585-033B5D3C80D5} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {24431BF1-7BEB-4C53-BAE8-B9D9F622A240} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {65A47BA4-4DC8-4206-9B00-CBC87FC944FC} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {E044A94A-3081-4EE4-8DC6-81601F96DA14} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {68F8600D-24E9-4E03-9E25-5F6EB338EAC1} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} @@ -935,6 +948,7 @@ Global {9E8E6AA5-65D1-498F-BEAB-BA34723A0050} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {232DD918-46ED-4BA8-B383-1A9146D83064} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {B73C3CC0-9F16-4B34-92BE-6EC0853912C5} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {28B5DEDF-C19B-4A7E-B276-FC4C83DBB7EF} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {27CDB869-A150-4593-958F-6F26E5391E7C} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {10726AAA-E93F-4B40-A05E-28308423DABE} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {73CC8E45-5608-1398-4029-0802428B5565} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} diff --git a/Testcontainers.slnx b/Testcontainers.slnx index f7304fdfb..e6a84283a 100644 --- a/Testcontainers.slnx +++ b/Testcontainers.slnx @@ -64,6 +64,7 @@ + @@ -135,6 +136,7 @@ + diff --git a/src/Testcontainers.Temporal/.editorconfig b/src/Testcontainers.Temporal/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/src/Testcontainers.Temporal/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/src/Testcontainers.Temporal/TemporalBuilder.cs b/src/Testcontainers.Temporal/TemporalBuilder.cs new file mode 100644 index 000000000..fd8fc41ae --- /dev/null +++ b/src/Testcontainers.Temporal/TemporalBuilder.cs @@ -0,0 +1,94 @@ +namespace Testcontainers.Temporal; + +/// +[PublicAPI] +public sealed class TemporalBuilder : ContainerBuilder +{ + public const ushort TemporalGrpcPort = 7233; + + public const ushort TemporalHttpPort = 8233; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The full Docker image name, including the image repository and tag + /// (e.g., temporalio/temporal:1.5.1). + /// + /// + /// Docker image tags available at . + /// + public TemporalBuilder(string image) + : this(new DockerImage(image)) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// An instance that specifies the Docker image to be used + /// for the container builder configuration. + /// + /// + /// Docker image tags available at . + /// + public TemporalBuilder(IImage image) + : this(new TemporalConfiguration()) + { + DockerResourceConfiguration = Init().WithImage(image).DockerResourceConfiguration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + private TemporalBuilder(TemporalConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + DockerResourceConfiguration = resourceConfiguration; + } + + /// + protected override TemporalConfiguration DockerResourceConfiguration { get; } + + /// + public override TemporalContainer Build() + { + Validate(); + return new TemporalContainer(DockerResourceConfiguration); + } + + /// + protected override TemporalBuilder Init() + { + return base.Init() + .WithPortBinding(TemporalGrpcPort, true) + .WithPortBinding(TemporalHttpPort, true) + .WithCommand("server", "start-dev", "--ip", "0.0.0.0") + .WithConnectionStringProvider(new TemporalConnectionStringProvider()) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilExternalTcpPortIsAvailable(TemporalGrpcPort) + .UntilExternalTcpPortIsAvailable(TemporalHttpPort) + .UntilHttpRequestIsSucceeded(request => + request.ForPath("/api/v1/system-info").ForPort(TemporalHttpPort))); + } + + /// + protected override TemporalBuilder Clone(IResourceConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new TemporalConfiguration(resourceConfiguration)); + } + + /// + protected override TemporalBuilder Clone(IContainerConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new TemporalConfiguration(resourceConfiguration)); + } + + /// + protected override TemporalBuilder Merge(TemporalConfiguration oldValue, TemporalConfiguration newValue) + { + return new TemporalBuilder(new TemporalConfiguration(oldValue, newValue)); + } +} \ No newline at end of file diff --git a/src/Testcontainers.Temporal/TemporalConfiguration.cs b/src/Testcontainers.Temporal/TemporalConfiguration.cs new file mode 100644 index 000000000..b9b9051c0 --- /dev/null +++ b/src/Testcontainers.Temporal/TemporalConfiguration.cs @@ -0,0 +1,53 @@ +namespace Testcontainers.Temporal; + +/// +[PublicAPI] +public sealed class TemporalConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + public TemporalConfiguration() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public TemporalConfiguration(IResourceConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public TemporalConfiguration(IContainerConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public TemporalConfiguration(TemporalConfiguration resourceConfiguration) + : this(new TemporalConfiguration(), resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The old Docker resource configuration. + /// The new Docker resource configuration. + public TemporalConfiguration(TemporalConfiguration oldValue, TemporalConfiguration newValue) + : base(oldValue, newValue) + { + } +} \ No newline at end of file diff --git a/src/Testcontainers.Temporal/TemporalConnectionStringProvider.cs b/src/Testcontainers.Temporal/TemporalConnectionStringProvider.cs new file mode 100644 index 000000000..c48bd57e9 --- /dev/null +++ b/src/Testcontainers.Temporal/TemporalConnectionStringProvider.cs @@ -0,0 +1,13 @@ +namespace Testcontainers.Temporal; + +/// +/// Provides the Temporal connection string. +/// +internal sealed class TemporalConnectionStringProvider : ContainerConnectionStringProvider +{ + /// + protected override string GetHostConnectionString() + { + return Container.GetGrpcAddress(); + } +} \ No newline at end of file diff --git a/src/Testcontainers.Temporal/TemporalContainer.cs b/src/Testcontainers.Temporal/TemporalContainer.cs new file mode 100644 index 000000000..64336dc44 --- /dev/null +++ b/src/Testcontainers.Temporal/TemporalContainer.cs @@ -0,0 +1,45 @@ +namespace Testcontainers.Temporal; + +/// +[PublicAPI] +public sealed class TemporalContainer : DockerContainer +{ + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + public TemporalContainer(TemporalConfiguration configuration) + : base(configuration) + { + } + + /// + /// Gets the Temporal gRPC address. + /// + /// + /// The Temporal SDK (client library) expects host:port without a scheme. + /// + /// + /// + /// var clientOptions = new TemporalClientConnectOptions(); + /// clientOptions.TargetHost = temporalContainer.GetGrpcAddress(); + ///
+ /// var connectedClient = await TemporalClient.ConnectAsync(clientOptions); + ///
+ ///
+ /// + /// The Temporal gRPC address in host:port format. + public string GetGrpcAddress() + { + return Hostname + ":" + GetMappedPublicPort(TemporalBuilder.TemporalGrpcPort); + } + + /// + /// Gets the Temporal Web UI address. + /// + /// The Temporal Web UI base address. + public string GetWebUiAddress() + { + return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(TemporalBuilder.TemporalHttpPort)).ToString(); + } +} \ No newline at end of file diff --git a/src/Testcontainers.Temporal/Testcontainers.Temporal.csproj b/src/Testcontainers.Temporal/Testcontainers.Temporal.csproj new file mode 100644 index 000000000..6f204b739 --- /dev/null +++ b/src/Testcontainers.Temporal/Testcontainers.Temporal.csproj @@ -0,0 +1,12 @@ + + + net8.0;net9.0;net10.0;netstandard2.0;netstandard2.1 + latest + + + + + + + + \ No newline at end of file diff --git a/src/Testcontainers.Temporal/Usings.cs b/src/Testcontainers.Temporal/Usings.cs new file mode 100644 index 000000000..26427f77f --- /dev/null +++ b/src/Testcontainers.Temporal/Usings.cs @@ -0,0 +1,7 @@ +global using System; +global using Docker.DotNet.Models; +global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Configurations; +global using DotNet.Testcontainers.Containers; +global using DotNet.Testcontainers.Images; +global using JetBrains.Annotations; \ No newline at end of file diff --git a/tests/Testcontainers.Temporal.Tests/.editorconfig b/tests/Testcontainers.Temporal.Tests/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/tests/Testcontainers.Temporal.Tests/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/tests/Testcontainers.Temporal.Tests/.runs-on b/tests/Testcontainers.Temporal.Tests/.runs-on new file mode 100644 index 000000000..d0395e498 --- /dev/null +++ b/tests/Testcontainers.Temporal.Tests/.runs-on @@ -0,0 +1 @@ +ubuntu-24.04 \ No newline at end of file diff --git a/tests/Testcontainers.Temporal.Tests/Dockerfile b/tests/Testcontainers.Temporal.Tests/Dockerfile new file mode 100644 index 000000000..8a47f2a34 --- /dev/null +++ b/tests/Testcontainers.Temporal.Tests/Dockerfile @@ -0,0 +1 @@ +FROM temporalio/temporal:1.5.1 \ No newline at end of file diff --git a/tests/Testcontainers.Temporal.Tests/TemporalContainerTest.cs b/tests/Testcontainers.Temporal.Tests/TemporalContainerTest.cs new file mode 100644 index 000000000..c705bf1c3 --- /dev/null +++ b/tests/Testcontainers.Temporal.Tests/TemporalContainerTest.cs @@ -0,0 +1,69 @@ +namespace Testcontainers.Temporal; + +public sealed class TemporalContainerTest : IAsyncLifetime +{ + private readonly TemporalContainer _temporalContainer = new TemporalBuilder(TestSession.GetImageFromDockerfile()).Build(); + + public async ValueTask InitializeAsync() + { + await _temporalContainer.StartAsync() + .ConfigureAwait(false); + } + + public ValueTask DisposeAsync() + { + return _temporalContainer.DisposeAsync(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task ListNamespacesReturnsDefaultNamespace() + { + // Given + var clientOptions = new TemporalClientConnectOptions(); + clientOptions.TargetHost = _temporalContainer.GetGrpcAddress(); + + var connectedClient = await TemporalClient.ConnectAsync(clientOptions) + .ConfigureAwait(true); + + // When + var response = await connectedClient.WorkflowService.ListNamespacesAsync(new ListNamespacesRequest()) + .ConfigureAwait(true); + + // Then + Assert.Contains(response.Namespaces, ns => ns.NamespaceInfo.Name == clientOptions.Namespace); + Assert.Equal(_temporalContainer.GetGrpcAddress(), _temporalContainer.GetConnectionString()); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task DescribeWorkflowReturnsStartedWorkflow() + { + // Given + const string workflowType = "my-workflow"; + + var workflowOptions = new WorkflowOptions(); + workflowOptions.Id = Guid.NewGuid().ToString("D"); + workflowOptions.TaskQueue = Guid.NewGuid().ToString("D"); + workflowOptions.Memo = new Dictionary { { "env", "test" } }; + + var clientOptions = new TemporalClientConnectOptions(); + clientOptions.TargetHost = _temporalContainer.GetGrpcAddress(); + + var connectedClient = await TemporalClient.ConnectAsync(clientOptions) + .ConfigureAwait(true); + + // When + var runningWorkflow = await connectedClient.StartWorkflowAsync(workflowType, Array.Empty(), workflowOptions) + .ConfigureAwait(true); + + var workflowDescription = await runningWorkflow.DescribeAsync() + .ConfigureAwait(true); + + // Then + Assert.Equal(workflowType, workflowDescription.WorkflowType); + Assert.Equal(workflowOptions.Id, workflowDescription.Id); + Assert.Equal(workflowOptions.TaskQueue, workflowDescription.TaskQueue); + Assert.True(workflowDescription.Memo.ContainsKey("env")); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Temporal.Tests/Testcontainers.Temporal.Tests.csproj b/tests/Testcontainers.Temporal.Tests/Testcontainers.Temporal.Tests.csproj new file mode 100644 index 000000000..9322f65c0 --- /dev/null +++ b/tests/Testcontainers.Temporal.Tests/Testcontainers.Temporal.Tests.csproj @@ -0,0 +1,24 @@ + + + net10.0 + false + false + Exe + + + + + + + + + + + + + + + PreserveNewest + + + \ No newline at end of file diff --git a/tests/Testcontainers.Temporal.Tests/Usings.cs b/tests/Testcontainers.Temporal.Tests/Usings.cs new file mode 100644 index 000000000..ef18e4163 --- /dev/null +++ b/tests/Testcontainers.Temporal.Tests/Usings.cs @@ -0,0 +1,7 @@ +global using System; +global using System.Collections.Generic; +global using System.Threading.Tasks; +global using DotNet.Testcontainers.Commons; +global using Temporalio.Api.WorkflowService.V1; +global using Temporalio.Client; +global using Xunit; \ No newline at end of file