diff --git a/Directory.Packages.props b/Directory.Packages.props index 5150a1671..5e9236e5b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -89,6 +89,8 @@ + + diff --git a/Testcontainers.sln b/Testcontainers.sln index 9a148f2ad..2ebe217c0 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -124,6 +124,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Redis", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Redpanda", "src\Testcontainers.Redpanda\Testcontainers.Redpanda.csproj", "{45D6F69C-4D87-4130-AA90-0DB2F7460DAE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Seq", "src\Testcontainers.Seq\Testcontainers.Seq.csproj", "{EB246B55-788B-4B58-8739-994A66F91C8A}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.ServiceBus", "src\Testcontainers.ServiceBus\Testcontainers.ServiceBus.csproj", "{2E39E532-B81E-4B48-A004-FAE18EDF9E79}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Sftp", "src\Testcontainers.Sftp\Testcontainers.Sftp.csproj", "{7D5C6816-0DD2-4E13-A585-033B5D3C80D5}" @@ -264,6 +266,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Redpanda.Tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.ResourceReaper.Tests", "tests\Testcontainers.ResourceReaper.Tests\Testcontainers.ResourceReaper.Tests.csproj", "{9E8E6AA5-65D1-498F-BEAB-BA34723A0050}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Seq.Tests", "tests\Testcontainers.Seq.Tests\Testcontainers.Seq.Tests.csproj", "{4EFC0DFB-9F04-4070-848A-856D2F11C1BC}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.ServiceBus.Tests", "tests\Testcontainers.ServiceBus.Tests\Testcontainers.ServiceBus.Tests.csproj", "{232DD918-46ED-4BA8-B383-1A9146D83064}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Sftp.Tests", "tests\Testcontainers.Sftp.Tests\Testcontainers.Sftp.Tests.csproj", "{B73C3CC0-9F16-4B34-92BE-6EC0853912C5}" @@ -498,6 +502,10 @@ Global {45D6F69C-4D87-4130-AA90-0DB2F7460DAE}.Debug|Any CPU.Build.0 = Debug|Any CPU {45D6F69C-4D87-4130-AA90-0DB2F7460DAE}.Release|Any CPU.ActiveCfg = Release|Any CPU {45D6F69C-4D87-4130-AA90-0DB2F7460DAE}.Release|Any CPU.Build.0 = Release|Any CPU + {EB246B55-788B-4B58-8739-994A66F91C8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB246B55-788B-4B58-8739-994A66F91C8A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB246B55-788B-4B58-8739-994A66F91C8A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB246B55-788B-4B58-8739-994A66F91C8A}.Release|Any CPU.Build.0 = Release|Any CPU {2E39E532-B81E-4B48-A004-FAE18EDF9E79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2E39E532-B81E-4B48-A004-FAE18EDF9E79}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E39E532-B81E-4B48-A004-FAE18EDF9E79}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -778,6 +786,10 @@ Global {9E8E6AA5-65D1-498F-BEAB-BA34723A0050}.Debug|Any CPU.Build.0 = Debug|Any CPU {9E8E6AA5-65D1-498F-BEAB-BA34723A0050}.Release|Any CPU.ActiveCfg = Release|Any CPU {9E8E6AA5-65D1-498F-BEAB-BA34723A0050}.Release|Any CPU.Build.0 = Release|Any CPU + {4EFC0DFB-9F04-4070-848A-856D2F11C1BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4EFC0DFB-9F04-4070-848A-856D2F11C1BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4EFC0DFB-9F04-4070-848A-856D2F11C1BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4EFC0DFB-9F04-4070-848A-856D2F11C1BC}.Release|Any CPU.Build.0 = Release|Any CPU {232DD918-46ED-4BA8-B383-1A9146D83064}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {232DD918-46ED-4BA8-B383-1A9146D83064}.Debug|Any CPU.Build.0 = Debug|Any CPU {232DD918-46ED-4BA8-B383-1A9146D83064}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -876,6 +888,7 @@ Global {F6394475-D6F1-46E2-81BF-4BA78A40B878} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {BFDA179A-40EB-4CEB-B8E9-0DF32C65E2C5} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {45D6F69C-4D87-4130-AA90-0DB2F7460DAE} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {EB246B55-788B-4B58-8739-994A66F91C8A} = {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} @@ -946,6 +959,7 @@ Global {31EE94A0-E721-4073-B6F1-DD912D004DEF} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {867BD04E-4670-4FBA-98D5-9F83220E6DFB} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {9E8E6AA5-65D1-498F-BEAB-BA34723A0050} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {4EFC0DFB-9F04-4070-848A-856D2F11C1BC} = {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} diff --git a/Testcontainers.slnx b/Testcontainers.slnx index e6a84283a..abe3157cf 100644 --- a/Testcontainers.slnx +++ b/Testcontainers.slnx @@ -62,6 +62,7 @@ + @@ -134,6 +135,7 @@ + diff --git a/src/Testcontainers.Seq/.editorconfig b/src/Testcontainers.Seq/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/src/Testcontainers.Seq/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/src/Testcontainers.Seq/SeqBuilder.cs b/src/Testcontainers.Seq/SeqBuilder.cs new file mode 100644 index 000000000..fca135508 --- /dev/null +++ b/src/Testcontainers.Seq/SeqBuilder.cs @@ -0,0 +1,125 @@ +namespace Testcontainers.Seq; + +/// +[PublicAPI] +public sealed class SeqBuilder : ContainerBuilder +{ + public const string SeqImage = "datalust/seq:2025.2"; + + public const ushort SeqPort = 80; + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.")] + [ExcludeFromCodeCoverage] + public SeqBuilder() + : this(SeqImage) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The full Docker image name, including the image repository and tag + /// (e.g., datalust/seq:2025.2). + /// + /// + /// Docker image tags available at . + /// + public SeqBuilder(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 SeqBuilder(IImage image) + : this(new SeqConfiguration()) + { + DockerResourceConfiguration = Init().WithImage(image).DockerResourceConfiguration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + private SeqBuilder(SeqConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + DockerResourceConfiguration = resourceConfiguration; + } + + /// + protected override SeqConfiguration DockerResourceConfiguration { get; } + + /// + protected override string AcceptLicenseAgreementEnvVar { get; } = "ACCEPT_EULA"; + + /// + protected override string AcceptLicenseAgreement { get; } = "Y"; + + /// + protected override string DeclineLicenseAgreement { get; } = "N"; + + /// + /// Accepts the license agreement. + /// + /// + /// When is set to true, the Seq license is accepted. + /// + /// A boolean value indicating whether the Seq license agreement is accepted. + /// A configured instance of . + public override SeqBuilder WithAcceptLicenseAgreement(bool acceptLicenseAgreement) + { + var licenseAgreement = acceptLicenseAgreement ? AcceptLicenseAgreement : DeclineLicenseAgreement; + return WithEnvironment(AcceptLicenseAgreementEnvVar, licenseAgreement); + } + + /// + public override SeqContainer Build() + { + Validate(); + ValidateLicenseAgreement(); + + return new SeqContainer(DockerResourceConfiguration); + } + + /// + protected override SeqBuilder Init() + { + return base.Init() + .WithPortBinding(SeqPort, true) + .WithEnvironment("SEQ_FIRSTRUN_NOAUTHENTICATION", "true") + .WithConnectionStringProvider(new SeqConnectionStringProvider()) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => + request.ForPort(SeqPort).ForPath("/health"))); + } + + /// + protected override SeqBuilder Clone(IResourceConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new SeqConfiguration(resourceConfiguration)); + } + + /// + protected override SeqBuilder Clone(IContainerConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new SeqConfiguration(resourceConfiguration)); + } + + /// + protected override SeqBuilder Merge(SeqConfiguration oldValue, SeqConfiguration newValue) + { + return new SeqBuilder(new SeqConfiguration(oldValue, newValue)); + } +} \ No newline at end of file diff --git a/src/Testcontainers.Seq/SeqConfiguration.cs b/src/Testcontainers.Seq/SeqConfiguration.cs new file mode 100644 index 000000000..144a6e097 --- /dev/null +++ b/src/Testcontainers.Seq/SeqConfiguration.cs @@ -0,0 +1,53 @@ +namespace Testcontainers.Seq; + +/// +[PublicAPI] +public sealed class SeqConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + public SeqConfiguration() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public SeqConfiguration(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 SeqConfiguration(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 SeqConfiguration(SeqConfiguration resourceConfiguration) + : this(new SeqConfiguration(), 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 SeqConfiguration(SeqConfiguration oldValue, SeqConfiguration newValue) + : base(oldValue, newValue) + { + } +} \ No newline at end of file diff --git a/src/Testcontainers.Seq/SeqConnectionStringProvider.cs b/src/Testcontainers.Seq/SeqConnectionStringProvider.cs new file mode 100644 index 000000000..9b5f6ea97 --- /dev/null +++ b/src/Testcontainers.Seq/SeqConnectionStringProvider.cs @@ -0,0 +1,13 @@ +namespace Testcontainers.Seq; + +/// +/// Provides the Seq connection string. +/// +internal sealed class SeqConnectionStringProvider : ContainerConnectionStringProvider +{ + /// + protected override string GetHostConnectionString() + { + return Container.GetEndpoint(); + } +} \ No newline at end of file diff --git a/src/Testcontainers.Seq/SeqContainer.cs b/src/Testcontainers.Seq/SeqContainer.cs new file mode 100644 index 000000000..c3a931d2d --- /dev/null +++ b/src/Testcontainers.Seq/SeqContainer.cs @@ -0,0 +1,24 @@ +namespace Testcontainers.Seq; + +/// +[PublicAPI] +public sealed class SeqContainer : DockerContainer +{ + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + public SeqContainer(SeqConfiguration configuration) + : base(configuration) + { + } + + /// + /// Gets the Seq endpoint. + /// + /// The Seq endpoint. + public string GetEndpoint() + { + return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(SeqBuilder.SeqPort)).ToString(); + } +} \ No newline at end of file diff --git a/src/Testcontainers.Seq/Testcontainers.Seq.csproj b/src/Testcontainers.Seq/Testcontainers.Seq.csproj new file mode 100644 index 000000000..6f204b739 --- /dev/null +++ b/src/Testcontainers.Seq/Testcontainers.Seq.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.Seq/Usings.cs b/src/Testcontainers.Seq/Usings.cs new file mode 100644 index 000000000..9bd35674e --- /dev/null +++ b/src/Testcontainers.Seq/Usings.cs @@ -0,0 +1,8 @@ +global using System; +global using System.Diagnostics.CodeAnalysis; +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.EventHubs.Tests/DeclineLicenseAgreementTest.cs b/tests/Testcontainers.EventHubs.Tests/DeclineLicenseAgreementTest.cs new file mode 100644 index 000000000..6f476f042 --- /dev/null +++ b/tests/Testcontainers.EventHubs.Tests/DeclineLicenseAgreementTest.cs @@ -0,0 +1,32 @@ +namespace Testcontainers.EventHubs; + +public sealed partial class DeclineLicenseAgreementTest +{ + private const string EventHubsName = "eh-1"; + + private const string EventHubsConsumerGroupName = "cg-1"; + + [GeneratedRegex("The image '.+' requires you to accept a license agreement\\.")] + private static partial Regex LicenseAgreementNotAccepted(); + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public void WithoutAcceptingLicenseAgreementThrowsArgumentException() + { + var exception = Assert.Throws(() => new EventHubsBuilder(TestSession.GetImageFromDockerfile()).WithConfigurationBuilder(GetServiceConfiguration()).Build()); + Assert.Matches(LicenseAgreementNotAccepted(), exception.Message); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public void WithLicenseAgreementDeclinedThrowsArgumentException() + { + var exception = Assert.Throws(() => new EventHubsBuilder(TestSession.GetImageFromDockerfile()).WithAcceptLicenseAgreement(false).WithConfigurationBuilder(GetServiceConfiguration()).Build()); + Assert.Matches(LicenseAgreementNotAccepted(), exception.Message); + } + + private static EventHubsServiceConfiguration GetServiceConfiguration() + { + return EventHubsServiceConfiguration.Create().WithEntity(EventHubsName, 2, EventHubConsumerClient.DefaultConsumerGroupName, EventHubsConsumerGroupName); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.EventHubs.Tests/Usings.cs b/tests/Testcontainers.EventHubs.Tests/Usings.cs index 39f6fba40..df238ff8d 100644 --- a/tests/Testcontainers.EventHubs.Tests/Usings.cs +++ b/tests/Testcontainers.EventHubs.Tests/Usings.cs @@ -1,5 +1,6 @@ global using System; global using System.Text; +global using System.Text.RegularExpressions; global using System.Threading.Tasks; global using Azure.Messaging.EventHubs; global using Azure.Messaging.EventHubs.Consumer; diff --git a/tests/Testcontainers.Seq.Tests/.editorconfig b/tests/Testcontainers.Seq.Tests/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/tests/Testcontainers.Seq.Tests/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/tests/Testcontainers.Seq.Tests/.runs-on b/tests/Testcontainers.Seq.Tests/.runs-on new file mode 100644 index 000000000..d0395e498 --- /dev/null +++ b/tests/Testcontainers.Seq.Tests/.runs-on @@ -0,0 +1 @@ +ubuntu-24.04 \ No newline at end of file diff --git a/tests/Testcontainers.Seq.Tests/DeclineLicenseAgreementTest.cs b/tests/Testcontainers.Seq.Tests/DeclineLicenseAgreementTest.cs new file mode 100644 index 000000000..59829ad55 --- /dev/null +++ b/tests/Testcontainers.Seq.Tests/DeclineLicenseAgreementTest.cs @@ -0,0 +1,23 @@ +namespace Testcontainers.Seq; + +public sealed partial class DeclineLicenseAgreementTest +{ + [GeneratedRegex("The image '.+' requires you to accept a license agreement\\.")] + private static partial Regex LicenseAgreementNotAccepted(); + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public void WithoutAcceptingLicenseAgreementThrowsArgumentException() + { + var exception = Assert.Throws(() => new SeqBuilder(TestSession.GetImageFromDockerfile()).Build()); + Assert.Matches(LicenseAgreementNotAccepted(), exception.Message); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public void WithLicenseAgreementDeclinedThrowsArgumentException() + { + var exception = Assert.Throws(() => new SeqBuilder(TestSession.GetImageFromDockerfile()).WithAcceptLicenseAgreement(false).Build()); + Assert.Matches(LicenseAgreementNotAccepted(), exception.Message); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Seq.Tests/Dockerfile b/tests/Testcontainers.Seq.Tests/Dockerfile new file mode 100644 index 000000000..83f075b22 --- /dev/null +++ b/tests/Testcontainers.Seq.Tests/Dockerfile @@ -0,0 +1 @@ +FROM datalust/seq:2025.2 \ No newline at end of file diff --git a/tests/Testcontainers.Seq.Tests/SeqContainerTest.cs b/tests/Testcontainers.Seq.Tests/SeqContainerTest.cs new file mode 100644 index 000000000..069a3f716 --- /dev/null +++ b/tests/Testcontainers.Seq.Tests/SeqContainerTest.cs @@ -0,0 +1,46 @@ +namespace Testcontainers.Seq; + +public sealed class SeqContainerTest : IAsyncLifetime +{ + private readonly SeqContainer _seqContainer = new SeqBuilder(TestSession.GetImageFromDockerfile()).WithAcceptLicenseAgreement(true).Build(); + + public async ValueTask InitializeAsync() + { + await _seqContainer.StartAsync() + .ConfigureAwait(false); + } + + public ValueTask DisposeAsync() + { + return _seqContainer.DisposeAsync(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task LogsMessageToSeq() + { + // Given + const string helloWorld = "Hello, World!"; + + var endpoint = _seqContainer.GetEndpoint(); + + var loggerFactory = new LoggerFactory(); + loggerFactory.AddSeq(endpoint); + + var logger = loggerFactory.CreateLogger(nameof(SeqContainerTest)); + logger.LogInformation(helloWorld); + + // Ensure pending messages are sent before querying. + loggerFactory.Dispose(); + + using var connection = new SeqConnection(endpoint); + + // When + var events = await connection.Events.ListAsync(cancellationToken: TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.Single(events); + Assert.Equal(helloWorld, events[0].MessageTemplateTokens.Last().Text); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Seq.Tests/Testcontainers.Seq.Tests.csproj b/tests/Testcontainers.Seq.Tests/Testcontainers.Seq.Tests.csproj new file mode 100644 index 000000000..af9a5ebbe --- /dev/null +++ b/tests/Testcontainers.Seq.Tests/Testcontainers.Seq.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + false + false + Exe + + + + + + + + + + + + + + + + PreserveNewest + + + \ No newline at end of file diff --git a/tests/Testcontainers.Seq.Tests/Usings.cs b/tests/Testcontainers.Seq.Tests/Usings.cs new file mode 100644 index 000000000..67ec661f5 --- /dev/null +++ b/tests/Testcontainers.Seq.Tests/Usings.cs @@ -0,0 +1,8 @@ +global using System; +global using System.Linq; +global using System.Text.RegularExpressions; +global using System.Threading.Tasks; +global using DotNet.Testcontainers.Commons; +global using Microsoft.Extensions.Logging; +global using Seq.Api; +global using Xunit; \ No newline at end of file