From 9a59b3eda781b55ccf30d83d619079997770ac79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edd=C3=BA=20Mel=C3=A9ndez=20Gonzales?= Date: Tue, 6 Aug 2024 05:47:35 -0500 Subject: [PATCH 1/6] feat: Log Docker daemon labels if present (#1226) --- src/Testcontainers/Clients/DockerApiClient.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Testcontainers/Clients/DockerApiClient.cs b/src/Testcontainers/Clients/DockerApiClient.cs index 826b18b58..46dc3aad3 100644 --- a/src/Testcontainers/Clients/DockerApiClient.cs +++ b/src/Testcontainers/Clients/DockerApiClient.cs @@ -107,8 +107,19 @@ await RuntimeInitialized.WaitAsync(ct) runtimeInfo.AppendLine(dockerInfo.OperatingSystem); runtimeInfo.Append(" Total Memory: "); - runtimeInfo.AppendFormat(CultureInfo.InvariantCulture, "{0:F} {1}", dockerInfo.MemTotal / Math.Pow(1024, byteUnits.Length), byteUnits[byteUnits.Length - 1]); + runtimeInfo.AppendLine(String.Format(CultureInfo.InvariantCulture, "{0:F} {1}", dockerInfo.MemTotal / Math.Pow(1024, byteUnits.Length), byteUnits[byteUnits.Length - 1])); + var labels = dockerInfo.Labels; + if (labels != null && labels.Count > 0) + { + runtimeInfo.AppendLine(" Labels: "); + + foreach (var label in labels) + { + runtimeInfo.Append(" "); + runtimeInfo.AppendLine(label); + } + } Logger.LogInformation("{RuntimeInfo}", runtimeInfo); } catch(Exception e) From 1a647621c7b9ed01b801efdddaac37206afc6b6e Mon Sep 17 00:00:00 2001 From: Paulo Morgado <470455+paulomorgado@users.noreply.github.com> Date: Wed, 7 Aug 2024 18:20:26 +0100 Subject: [PATCH 2/6] fix: Add Keycloak health port 9000 starting from major version 25 (#1213) Co-authored-by: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> --- .../KeycloakBuilder.cs | 22 ++++-- src/Testcontainers.Keycloak/Usings.cs | 1 + src/Testcontainers/Images/DockerImage.cs | 22 ++++++ .../Images/FutureDockerImage.cs | 19 ++++++ src/Testcontainers/Images/IImage.cs | 21 ++++++ .../KeycloakContainerTest.cs | 29 +++++++- tests/Testcontainers.Keycloak.Tests/Usings.cs | 1 + .../Fixtures/Images/HealthCheckFixture.cs | 16 +++++ .../Unit/Images/TestcontainersImageTest.cs | 67 +++++++++++++++++++ 9 files changed, 191 insertions(+), 7 deletions(-) diff --git a/src/Testcontainers.Keycloak/KeycloakBuilder.cs b/src/Testcontainers.Keycloak/KeycloakBuilder.cs index e61810f01..1f255cd3a 100644 --- a/src/Testcontainers.Keycloak/KeycloakBuilder.cs +++ b/src/Testcontainers.Keycloak/KeycloakBuilder.cs @@ -8,6 +8,8 @@ public sealed class KeycloakBuilder : ContainerBuilder predicate = v => v.Major >= 25; + + var image = DockerResourceConfiguration.Image; + + // https://www.keycloak.org/docs/latest/release_notes/index.html#management-port-for-metrics-and-health-endpoints. + var isMajorVersionGreaterOrEqual25 = image.MatchLatestOrNightly() || image.MatchVersion(predicate); + + var waitStrategy = Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => + request.ForPath("/health/ready").ForPort(isMajorVersionGreaterOrEqual25 ? KeycloakHealthPort : KeycloakPort)); + + var keycloakBuilder = DockerResourceConfiguration.WaitStrategies.Count() > 1 ? this : WithWaitStrategy(waitStrategy); + return new KeycloakContainer(keycloakBuilder.DockerResourceConfiguration); } /// @@ -70,11 +85,10 @@ protected override KeycloakBuilder Init() .WithImage(KeycloakImage) .WithCommand("start-dev") .WithPortBinding(KeycloakPort, true) + .WithPortBinding(KeycloakHealthPort, true) .WithUsername(DefaultUsername) .WithPassword(DefaultPassword) - .WithEnvironment("KC_HEALTH_ENABLED", "true") - .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => - request.ForPath("/health/ready").ForPort(KeycloakPort))); + .WithEnvironment("KC_HEALTH_ENABLED", "true"); } /// diff --git a/src/Testcontainers.Keycloak/Usings.cs b/src/Testcontainers.Keycloak/Usings.cs index 8e5c20fd5..f82c6873d 100644 --- a/src/Testcontainers.Keycloak/Usings.cs +++ b/src/Testcontainers.Keycloak/Usings.cs @@ -1,4 +1,5 @@ global using System; +global using System.Linq; global using Docker.DotNet.Models; global using DotNet.Testcontainers; global using DotNet.Testcontainers.Builders; diff --git a/src/Testcontainers/Images/DockerImage.cs b/src/Testcontainers/Images/DockerImage.cs index b37ae0c4a..468d292ee 100644 --- a/src/Testcontainers/Images/DockerImage.cs +++ b/src/Testcontainers/Images/DockerImage.cs @@ -2,6 +2,7 @@ namespace DotNet.Testcontainers.Images { using System; using System.Linq; + using System.Text.RegularExpressions; using JetBrains.Annotations; /// @@ -10,6 +11,8 @@ public sealed class DockerImage : IImage { private const string LatestTag = "latest"; + private const string NightlyTag = "nightly"; + private static readonly Func GetDockerImage = MatchImage.Match; private static readonly char[] TrimChars = { ' ', ':', '/' }; @@ -107,6 +110,25 @@ public DockerImage( /// public string GetHostname() => _lazyHostname.Value; + /// + public bool MatchLatestOrNightly() + { + return MatchVersion((string tag) => LatestTag.Equals(tag) || NightlyTag.Equals(tag)); + } + + /// + public bool MatchVersion(Predicate predicate) + { + return predicate(Tag); + } + + /// + public bool MatchVersion(Predicate predicate) + { + var versionMatch = Regex.Match(Tag, "^\\d+(\\.\\d+)?(\\.\\d+)?", RegexOptions.None, TimeSpan.FromSeconds(1)); + return versionMatch.Success && Version.TryParse(versionMatch.Value, out var version) && predicate(version); + } + private static string TrimOrDefault(string value, string defaultValue = default) { return string.IsNullOrEmpty(value) ? defaultValue : value.Trim(TrimChars); diff --git a/src/Testcontainers/Images/FutureDockerImage.cs b/src/Testcontainers/Images/FutureDockerImage.cs index 02271788b..23817169d 100644 --- a/src/Testcontainers/Images/FutureDockerImage.cs +++ b/src/Testcontainers/Images/FutureDockerImage.cs @@ -1,5 +1,6 @@ namespace DotNet.Testcontainers.Images { + using System; using System.Threading; using System.Threading.Tasks; using Docker.DotNet.Models; @@ -74,6 +75,24 @@ public string GetHostname() return _configuration.Image.GetHostname(); } + /// + public bool MatchLatestOrNightly() + { + return _configuration.Image.MatchLatestOrNightly(); + } + + /// + public bool MatchVersion(Predicate predicate) + { + return _configuration.Image.MatchVersion(predicate); + } + + /// + public bool MatchVersion(Predicate predicate) + { + return _configuration.Image.MatchVersion(predicate); + } + /// public async Task CreateAsync(CancellationToken ct = default) { diff --git a/src/Testcontainers/Images/IImage.cs b/src/Testcontainers/Images/IImage.cs index b9badc5d3..307879d23 100644 --- a/src/Testcontainers/Images/IImage.cs +++ b/src/Testcontainers/Images/IImage.cs @@ -1,5 +1,6 @@ namespace DotNet.Testcontainers.Images { + using System; using JetBrains.Annotations; /// @@ -41,5 +42,25 @@ public interface IImage /// The registry hostname. [CanBeNull] string GetHostname(); + + /// + /// Checks if the tag matches either the latest or nightly tag. + /// + /// True if the tag matches the latest or nightly tag, otherwise false. + bool MatchLatestOrNightly(); + + /// + /// Checks if the tag matches the specified predicate. + /// + /// The predicate to match the tag against. + /// True if the tag matches the predicate, otherwise false. + bool MatchVersion(Predicate predicate); + + /// + /// Checks if the tag matches the specified predicate. + /// + /// The predicate to match the tag against. + /// True if the tag matches the predicate, otherwise false. + bool MatchVersion(Predicate predicate); } } diff --git a/tests/Testcontainers.Keycloak.Tests/KeycloakContainerTest.cs b/tests/Testcontainers.Keycloak.Tests/KeycloakContainerTest.cs index 85f6f69f5..23884b4d8 100644 --- a/tests/Testcontainers.Keycloak.Tests/KeycloakContainerTest.cs +++ b/tests/Testcontainers.Keycloak.Tests/KeycloakContainerTest.cs @@ -1,8 +1,13 @@ -namespace Testcontainers.Keycloak.Tests; +namespace Testcontainers.Keycloak; -public sealed class KeycloakContainerTest : IAsyncLifetime +public abstract class KeycloakContainerTest : IAsyncLifetime { - private readonly KeycloakContainer _keycloakContainer = new KeycloakBuilder().Build(); + private readonly KeycloakContainer _keycloakContainer; + + private KeycloakContainerTest(KeycloakContainer keycloakContainer) + { + _keycloakContainer = keycloakContainer; + } public Task InitializeAsync() { @@ -42,4 +47,22 @@ public async Task MasterRealmIsEnabled() // Then Assert.True(masterRealm.Enabled); } + + [UsedImplicitly] + public sealed class KeycloakDefaultConfiguration : KeycloakContainerTest + { + public KeycloakDefaultConfiguration() + : base(new KeycloakBuilder().Build()) + { + } + } + + [UsedImplicitly] + public sealed class KeycloakV25Configuration : KeycloakContainerTest + { + public KeycloakV25Configuration() + : base(new KeycloakBuilder().WithImage("quay.io/keycloak/keycloak:25.0").Build()) + { + } + } } \ No newline at end of file diff --git a/tests/Testcontainers.Keycloak.Tests/Usings.cs b/tests/Testcontainers.Keycloak.Tests/Usings.cs index 66a86e46d..b6c54f314 100644 --- a/tests/Testcontainers.Keycloak.Tests/Usings.cs +++ b/tests/Testcontainers.Keycloak.Tests/Usings.cs @@ -2,5 +2,6 @@ global using System.Net; global using System.Net.Http; global using System.Threading.Tasks; +global using JetBrains.Annotations; global using Keycloak.Net; global using Xunit; \ No newline at end of file diff --git a/tests/Testcontainers.Tests/Fixtures/Images/HealthCheckFixture.cs b/tests/Testcontainers.Tests/Fixtures/Images/HealthCheckFixture.cs index db4034d3e..932c95526 100644 --- a/tests/Testcontainers.Tests/Fixtures/Images/HealthCheckFixture.cs +++ b/tests/Testcontainers.Tests/Fixtures/Images/HealthCheckFixture.cs @@ -1,5 +1,6 @@ namespace DotNet.Testcontainers.Tests.Fixtures { + using System; using System.IO; using System.Threading.Tasks; using DotNet.Testcontainers.Builders; @@ -27,6 +28,21 @@ public string GetHostname() return _image.GetHostname(); } + public bool MatchLatestOrNightly() + { + return _image.MatchLatestOrNightly(); + } + + public bool MatchVersion(Predicate predicate) + { + return _image.MatchVersion(predicate); + } + + public bool MatchVersion(Predicate predicate) + { + return _image.MatchVersion(predicate); + } + public Task InitializeAsync() { return _image.CreateAsync(); diff --git a/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs b/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs index 4eacc1c80..bab17526a 100644 --- a/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs +++ b/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs @@ -59,5 +59,72 @@ public void WhenImageNameGetsAssigned(DockerImageFixtureSerializable serializabl Assert.Equal(expected.Tag, dockerImage.Tag); Assert.Equal(expected.FullName, dockerImage.FullName); } + + [Fact] + public void MatchLatestOrNightly_TagIsLatest_ReturnsTrue() + { + // Given + IImage dockerImage = new DockerImage("foo:latest"); + + // When + var result = dockerImage.MatchLatestOrNightly(); + + // Then + Assert.True(result); + } + + [Fact] + public void MatchLatestOrNightly_TagIsNightly_ReturnsTrue() + { + // Given + IImage dockerImage = new DockerImage("foo:nightly"); + + // When + var result = dockerImage.MatchLatestOrNightly(); + + // Then + Assert.True(result); + } + + [Fact] + public void MatchLatestOrNightly_TagIsNeither_ReturnsFalse() + { + // Given + IImage dockerImage = new DockerImage("foo:1.0.0"); + + // When + var result = dockerImage.MatchLatestOrNightly(); + + // Then + Assert.False(result); + } + + [Fact] + public void MatchVersion_ReturnsTrue_WhenVersionMatchesPredicate() + { + // Given + Predicate predicate = v => v.Major == 1 && v.Minor == 0 && v.Build == 0; + IImage dockerImage = new DockerImage("foo:1.0.0"); + + // When + var result = dockerImage.MatchVersion(predicate); + + // Then + Assert.True(result); + } + + [Fact] + public void MatchVersion_ReturnsFalse_WhenVersionDoesNotMatchPredicate() + { + // Given + Predicate predicate = v => v.Major == 0 && v.Minor == 0 && v.Build == 1; + IImage dockerImage = new DockerImage("foo:1.0.0"); + + // When + var result = dockerImage.MatchVersion(predicate); + + // Then + Assert.False(result); + } } } From ace1fe03b6fc0bf58c5896480584c525b5c65abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Luthi?= Date: Mon, 12 Aug 2024 18:00:28 +0200 Subject: [PATCH 3/6] feat: Improve parsing of Docker image tag version `MatchVersion(Predicate)` (#1231) --- src/Testcontainers/Images/DockerImage.cs | 12 ++++++++++-- .../Unit/Images/TestcontainersImageTest.cs | 14 ++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/Testcontainers/Images/DockerImage.cs b/src/Testcontainers/Images/DockerImage.cs index 468d292ee..1014d54e9 100644 --- a/src/Testcontainers/Images/DockerImage.cs +++ b/src/Testcontainers/Images/DockerImage.cs @@ -1,6 +1,7 @@ namespace DotNet.Testcontainers.Images { using System; + using System.Globalization; using System.Linq; using System.Text.RegularExpressions; using JetBrains.Annotations; @@ -125,8 +126,15 @@ public bool MatchVersion(Predicate predicate) /// public bool MatchVersion(Predicate predicate) { - var versionMatch = Regex.Match(Tag, "^\\d+(\\.\\d+)?(\\.\\d+)?", RegexOptions.None, TimeSpan.FromSeconds(1)); - return versionMatch.Success && Version.TryParse(versionMatch.Value, out var version) && predicate(version); + var versionMatch = Regex.Match(Tag, @"^(\d+)(\.\d+)?(\.\d+)?", RegexOptions.None, TimeSpan.FromSeconds(1)); + if (!versionMatch.Success) + return false; + + if (Version.TryParse(versionMatch.Value, out var version)) + return predicate(version); + + // If the regex matches and Version.TryParse fails then it means it's a major version only (i.e. without any . in the version) + return predicate(new Version(int.Parse(versionMatch.Groups[1].Value, NumberStyles.None), 0)); } private static string TrimOrDefault(string value, string defaultValue = default) diff --git a/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs b/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs index bab17526a..af5ff7a43 100644 --- a/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs +++ b/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs @@ -99,12 +99,18 @@ public void MatchLatestOrNightly_TagIsNeither_ReturnsFalse() Assert.False(result); } - [Fact] - public void MatchVersion_ReturnsTrue_WhenVersionMatchesPredicate() + [Theory] + [InlineData("foo:2", 2, 0, -1, -1)] + [InlineData("foo:2-variant", 2, 0, -1, -1)] + [InlineData("foo:2.3", 2, 3, -1, -1)] + [InlineData("foo:2.3-variant", 2, 3, -1, -1)] + [InlineData("foo:2.3.4", 2, 3, 4, -1)] + [InlineData("foo:2.3.4-variant", 2, 3, 4, -1)] + public void MatchVersion_ReturnsTrue_WhenVersionMatchesPredicate(string image, int major, int minor, int build, int revision) { // Given - Predicate predicate = v => v.Major == 1 && v.Minor == 0 && v.Build == 0; - IImage dockerImage = new DockerImage("foo:1.0.0"); + Predicate predicate = v => v.Major == major && v.Minor == minor && v.Build == build && v.Revision == revision; + IImage dockerImage = new DockerImage(image); // When var result = dockerImage.MatchVersion(predicate); From dd05999702f11f4264f91f8e69619c03264d6cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Luthi?= Date: Tue, 13 Aug 2024 17:45:29 +0200 Subject: [PATCH 4/6] chore: Change global.json roll-forward policy from `latestPatch` to `latestMinor` (#1230) Co-authored-by: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index d031a7632..39c53b83a 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { "version": "8.0.200", - "rollForward": "latestPatch" + "rollForward": "latestMinor" } } From 87184e3e53b270ac352cdf59af6c2334488c7fd1 Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Thu, 15 Aug 2024 06:33:50 +0200 Subject: [PATCH 5/6] feat: Detect CSharp, FSharp and Visual Basic projects (#1234) --- .cake-scripts/version.cake | 2 +- build.cake | 2 +- .../Builders/CommonDirectoryPath.cs | 19 ++++++++++--------- src/Testcontainers/Images/DockerImage.cs | 7 ++++++- .../Unit/Builders/CommonDirectoryPathTest.cs | 7 ++++++- ...ockerEndpointAuthenticationProviderTest.cs | 6 +++--- 6 files changed, 27 insertions(+), 16 deletions(-) diff --git a/.cake-scripts/version.cake b/.cake-scripts/version.cake index aaad7af64..a8ab7b043 100644 --- a/.cake-scripts/version.cake +++ b/.cake-scripts/version.cake @@ -1,4 +1,4 @@ -#addin nuget:?package=Cake.Git&version=2.0.0 +#addin nuget:?package=Cake.Git&version=3.0.0 internal sealed class BuildInformation { diff --git a/build.cake b/build.cake index 742aa36f0..7ce01ac0c 100644 --- a/build.cake +++ b/build.cake @@ -1,4 +1,4 @@ -#tool nuget:?package=dotnet-sonarscanner&version=5.15.0 +#tool nuget:?package=dotnet-sonarscanner&version=7.1.1 #addin nuget:?package=Cake.Sonar&version=1.1.32 diff --git a/src/Testcontainers/Builders/CommonDirectoryPath.cs b/src/Testcontainers/Builders/CommonDirectoryPath.cs index 5aead1617..c7dd2c04f 100644 --- a/src/Testcontainers/Builders/CommonDirectoryPath.cs +++ b/src/Testcontainers/Builders/CommonDirectoryPath.cs @@ -80,18 +80,18 @@ public static CommonDirectoryPath GetSolutionDirectory([CallerFilePath, NotNull] } /// - /// Resolves the first CSharp project file upwards the directory tree. + /// Resolves the first CSharp, FSharp or Visual Basic project file upwards the directory tree. /// /// /// Start node is the caller file path directory. End node is the root directory. /// /// The caller file path. - /// The first CSharp project file upwards the directory tree. - /// Thrown when the CSharp project file was not found upwards the directory tree. + /// The first CSharp, FSharp or Visual Basic project file upwards the directory tree. + /// Thrown when no CSharp, FSharp or Visual Basic project file was found upwards the directory tree. [PublicAPI] public static CommonDirectoryPath GetProjectDirectory([CallerFilePath, NotNull] string filePath = "") { - return new CommonDirectoryPath(GetDirectoryPath(Path.GetDirectoryName(filePath), "*.csproj")); + return new CommonDirectoryPath(GetDirectoryPath(Path.GetDirectoryName(filePath), "*.csproj", "*.fsproj", "*.vbproj")); } /// @@ -105,19 +105,20 @@ public static CommonDirectoryPath GetCallerFileDirectory([CallerFilePath, NotNul return new CommonDirectoryPath(Path.GetDirectoryName(filePath)); } - private static string GetDirectoryPath(string path, string searchPattern) + private static string GetDirectoryPath(string path, params string[] searchPatterns) { - return GetDirectoryPath(Directory.Exists(path) ? new DirectoryInfo(path) : null, searchPattern); + return GetDirectoryPath(Directory.Exists(path) ? new DirectoryInfo(path) : null, searchPatterns); } - private static string GetDirectoryPath(DirectoryInfo path, string searchPattern) + private static string GetDirectoryPath(DirectoryInfo path, params string[] searchPatterns) { if (path != null) { - return path.EnumerateFileSystemInfos(searchPattern, SearchOption.TopDirectoryOnly).Any() ? path.FullName : GetDirectoryPath(path.Parent, searchPattern); + var paths = searchPatterns.SelectMany(searchPattern => path.EnumerateFileSystemInfos(searchPattern, SearchOption.TopDirectoryOnly)).Any(); + return paths ? path.FullName : GetDirectoryPath(path.Parent, searchPatterns); } - var message = $"Cannot find '{searchPattern}' and resolve the base directory in the directory tree."; + var message = $"Cannot find '{string.Join(", ", searchPatterns)}' and resolve the base directory in the directory tree."; throw new DirectoryNotFoundException(message); } } diff --git a/src/Testcontainers/Images/DockerImage.cs b/src/Testcontainers/Images/DockerImage.cs index 1014d54e9..ef7341c44 100644 --- a/src/Testcontainers/Images/DockerImage.cs +++ b/src/Testcontainers/Images/DockerImage.cs @@ -127,13 +127,18 @@ public bool MatchVersion(Predicate predicate) public bool MatchVersion(Predicate predicate) { var versionMatch = Regex.Match(Tag, @"^(\d+)(\.\d+)?(\.\d+)?", RegexOptions.None, TimeSpan.FromSeconds(1)); + if (!versionMatch.Success) + { return false; + } if (Version.TryParse(versionMatch.Value, out var version)) + { return predicate(version); + } - // If the regex matches and Version.TryParse fails then it means it's a major version only (i.e. without any . in the version) + // If the Regex matches and Version.TryParse(string?, out Version?) fails then it means it is a major version only (i.e. without any dot separator) return predicate(new Version(int.Parse(versionMatch.Groups[1].Value, NumberStyles.None), 0)); } diff --git a/tests/Testcontainers.Tests/Unit/Builders/CommonDirectoryPathTest.cs b/tests/Testcontainers.Tests/Unit/Builders/CommonDirectoryPathTest.cs index fb8ce7193..14e4e5ea7 100644 --- a/tests/Testcontainers.Tests/Unit/Builders/CommonDirectoryPathTest.cs +++ b/tests/Testcontainers.Tests/Unit/Builders/CommonDirectoryPathTest.cs @@ -1,6 +1,7 @@ namespace DotNet.Testcontainers.Tests.Unit { using System.IO; + using DotNet.Testcontainers.Commons; using DotNet.Testcontainers.Builders; using Xunit; @@ -8,12 +9,16 @@ public sealed class CommonDirectoryPathTest { public static TheoryData CommonDirectoryPaths() { + using var fsprojFileStream = File.Create(Path.Combine(TestSession.TempDirectoryPath, "Testcontainers.fsproj")); + using var vbprojFileStream = File.Create(Path.Combine(TestSession.TempDirectoryPath, "Testcontainers.vbproj")); var theoryData = new TheoryData(); theoryData.Add(CommonDirectoryPath.GetBinDirectory()); theoryData.Add(CommonDirectoryPath.GetGitDirectory()); theoryData.Add(CommonDirectoryPath.GetProjectDirectory()); theoryData.Add(CommonDirectoryPath.GetSolutionDirectory()); theoryData.Add(CommonDirectoryPath.GetCallerFileDirectory()); + theoryData.Add(CommonDirectoryPath.GetProjectDirectory(fsprojFileStream.Name)); + theoryData.Add(CommonDirectoryPath.GetProjectDirectory(vbprojFileStream.Name)); return theoryData; } @@ -28,7 +33,7 @@ public void CommonDirectoryPathExists(CommonDirectoryPath commonDirectoryPath) public void CommonDirectoryPathNotExists() { var callerFilePath = Path.GetPathRoot(Directory.GetCurrentDirectory()); - Assert.Throws(() => CommonDirectoryPath.GetGitDirectory(callerFilePath)); + Assert.Throws(() => CommonDirectoryPath.GetGitDirectory(callerFilePath!)); } } } diff --git a/tests/Testcontainers.Tests/Unit/Configurations/DockerEndpointAuthenticationProviderTest.cs b/tests/Testcontainers.Tests/Unit/Configurations/DockerEndpointAuthenticationProviderTest.cs index 391a163d6..2ba73314f 100644 --- a/tests/Testcontainers.Tests/Unit/Configurations/DockerEndpointAuthenticationProviderTest.cs +++ b/tests/Testcontainers.Tests/Unit/Configurations/DockerEndpointAuthenticationProviderTest.cs @@ -23,9 +23,9 @@ public sealed class DockerEndpointAuthenticationProviderTest static DockerEndpointAuthenticationProviderTest() { _ = Directory.CreateDirectory(CertificatesDirectoryPath); - _ = File.Create(Path.Combine(CertificatesDirectoryPath, "ca.pem")); - _ = File.Create(Path.Combine(CertificatesDirectoryPath, "cert.pem")); - _ = File.Create(Path.Combine(CertificatesDirectoryPath, "key.pem")); + using var fileStream1 = File.Create(Path.Combine(CertificatesDirectoryPath, "ca.pem")); + using var fileStream2 = File.Create(Path.Combine(CertificatesDirectoryPath, "cert.pem")); + using var fileStream3 = File.Create(Path.Combine(CertificatesDirectoryPath, "key.pem")); } [Theory] From fffd384731bd280b9eacd84b65b772ed6c9f7846 Mon Sep 17 00:00:00 2001 From: Artiom Chilaru Date: Wed, 28 Aug 2024 18:43:34 +0100 Subject: [PATCH 6/6] feat: Add MongoDB replica set support (#1196) Co-authored-by: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> --- docs/modules/mongodb.md | 8 ++ src/Testcontainers.MongoDb/MongoDbBuilder.cs | 75 +++++++++++++++++-- .../MongoDbConfiguration.cs | 14 +++- src/Testcontainers/Images/DockerImage.cs | 2 +- .../MongoDbContainerTest.cs | 49 +++++++++++- 5 files changed, 138 insertions(+), 10 deletions(-) diff --git a/docs/modules/mongodb.md b/docs/modules/mongodb.md index 478d0d63e..7ebbdc6b1 100644 --- a/docs/modules/mongodb.md +++ b/docs/modules/mongodb.md @@ -45,3 +45,11 @@ public sealed class MongoDbContainerTest : IAsyncLifetime ``` To execute the tests, use the command `dotnet test` from a terminal. + +## MongoDb Replica Set + +By default, MongoDB runs as a standalone instance. If your tests require a MongoDB replica set, use the code below which will initialize it as a single-node replica set: + +```csharp +MongoDbContainer _mongoDbContainer = new MongoDbBuilder().WithReplicaSet().Build(); +``` diff --git a/src/Testcontainers.MongoDb/MongoDbBuilder.cs b/src/Testcontainers.MongoDb/MongoDbBuilder.cs index b4221b018..1a806c7a8 100644 --- a/src/Testcontainers.MongoDb/MongoDbBuilder.cs +++ b/src/Testcontainers.MongoDb/MongoDbBuilder.cs @@ -12,6 +12,10 @@ public sealed class MongoDbBuilder : ContainerBuilder /// Initializes a new instance of the class. /// @@ -60,15 +64,44 @@ public MongoDbBuilder WithPassword(string password) .WithEnvironment("MONGO_INITDB_ROOT_PASSWORD", initDbRootPassword); } + /// + /// Initialize MongoDB as a single-node replica set. + /// + /// The replica set name. + /// A configured instance of . + public MongoDbBuilder WithReplicaSet(string replicaSetName = "rs0") + { + var initKeyFileScript = new StringWriter(); + initKeyFileScript.NewLine = "\n"; + initKeyFileScript.WriteLine("#!/bin/bash"); + initKeyFileScript.WriteLine("openssl rand -base64 32 > \"" + KeyFileFilePath + "\""); + initKeyFileScript.WriteLine("chmod 600 \"" + KeyFileFilePath + "\""); + + return Merge(DockerResourceConfiguration, new MongoDbConfiguration(replicaSetName: replicaSetName)) + .WithCommand("--replSet", replicaSetName, "--keyFile", KeyFileFilePath, "--bind_ip_all") + .WithResourceMapping(Encoding.Default.GetBytes(initKeyFileScript.ToString()), InitKeyFileScriptFilePath, Unix.FileMode755); + } + /// public override MongoDbContainer Build() { Validate(); - // The wait strategy relies on the configuration of MongoDb. If credentials are - // provided, the log message "Waiting for connections" appears twice. + IWaitUntil waitUntil; + + if (string.IsNullOrEmpty(DockerResourceConfiguration.ReplicaSetName)) + { + // The wait strategy relies on the configuration of MongoDb. If credentials are + // provided, the log message "Waiting for connections" appears twice. + waitUntil = new WaitIndicateReadiness(DockerResourceConfiguration); + } + else + { + waitUntil = new WaitInitiateReplicaSet(DockerResourceConfiguration); + } + // If the user does not provide a custom waiting strategy, append the default MongoDb waiting strategy. - var mongoDbBuilder = DockerResourceConfiguration.WaitStrategies.Count() > 1 ? this : WithWaitStrategy(Wait.ForUnixContainer().AddCustomWaitStrategy(new WaitUntil(DockerResourceConfiguration))); + var mongoDbBuilder = DockerResourceConfiguration.WaitStrategies.Count() > 1 ? this : WithWaitStrategy(Wait.ForUnixContainer().AddCustomWaitStrategy(waitUntil)); return new MongoDbContainer(mongoDbBuilder.DockerResourceConfiguration); } @@ -118,17 +151,17 @@ protected override MongoDbBuilder Merge(MongoDbConfiguration oldValue, MongoDbCo } /// - private sealed class WaitUntil : IWaitUntil + private sealed class WaitIndicateReadiness : IWaitUntil { private static readonly string[] LineEndings = { "\r\n", "\n" }; private readonly int _count; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The container configuration. - public WaitUntil(MongoDbConfiguration configuration) + public WaitIndicateReadiness(MongoDbConfiguration configuration) { _count = string.IsNullOrEmpty(configuration.Username) && string.IsNullOrEmpty(configuration.Password) ? 1 : 2; } @@ -145,4 +178,34 @@ public async Task UntilAsync(IContainer container) .Count(line => line.Contains("Waiting for connections"))); } } + + /// + private sealed class WaitInitiateReplicaSet : IWaitUntil + { + private readonly string _scriptContent; + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + public WaitInitiateReplicaSet(MongoDbConfiguration configuration) + { + _scriptContent = $"try{{rs.status().ok}}catch(e){{rs.initiate({{'_id':'{configuration.ReplicaSetName}',members:[{{'_id':1,'host':'127.0.0.1:27017'}}]}}).ok}}"; + } + + /// + public Task UntilAsync(IContainer container) + { + return UntilAsync(container as MongoDbContainer); + } + + /// + private async Task UntilAsync(MongoDbContainer container) + { + var execResult = await container.ExecScriptAsync(_scriptContent) + .ConfigureAwait(false); + + return 0L.Equals(execResult.ExitCode); + } + } } \ No newline at end of file diff --git a/src/Testcontainers.MongoDb/MongoDbConfiguration.cs b/src/Testcontainers.MongoDb/MongoDbConfiguration.cs index b0f008ee6..b069b2959 100644 --- a/src/Testcontainers.MongoDb/MongoDbConfiguration.cs +++ b/src/Testcontainers.MongoDb/MongoDbConfiguration.cs @@ -9,12 +9,15 @@ public sealed class MongoDbConfiguration : ContainerConfiguration /// /// The MongoDb username. /// The MongoDb password. + /// The replica set name. public MongoDbConfiguration( string username = null, - string password = null) + string password = null, + string replicaSetName = null) { Username = username; Password = password; + ReplicaSetName = replicaSetName; } /// @@ -57,6 +60,7 @@ public MongoDbConfiguration(MongoDbConfiguration oldValue, MongoDbConfiguration { Username = BuildConfiguration.Combine(oldValue.Username, newValue.Username); Password = BuildConfiguration.Combine(oldValue.Password, newValue.Password); + ReplicaSetName = BuildConfiguration.Combine(oldValue.ReplicaSetName, newValue.ReplicaSetName); } /// @@ -68,4 +72,12 @@ public MongoDbConfiguration(MongoDbConfiguration oldValue, MongoDbConfiguration /// Gets the MongoDb password. /// public string Password { get; } + + /// + /// Gets the replica set name. + /// + /// + /// If specified, the container will be started as a single-node replica set. + /// + public string ReplicaSetName { get; } } \ No newline at end of file diff --git a/src/Testcontainers/Images/DockerImage.cs b/src/Testcontainers/Images/DockerImage.cs index ef7341c44..8347b3fbe 100644 --- a/src/Testcontainers/Images/DockerImage.cs +++ b/src/Testcontainers/Images/DockerImage.cs @@ -126,7 +126,7 @@ public bool MatchVersion(Predicate predicate) /// public bool MatchVersion(Predicate predicate) { - var versionMatch = Regex.Match(Tag, @"^(\d+)(\.\d+)?(\.\d+)?", RegexOptions.None, TimeSpan.FromSeconds(1)); + var versionMatch = Regex.Match(Tag, "^(\\d+)(\\.\\d+)?(\\.\\d+)?", RegexOptions.None, TimeSpan.FromSeconds(1)); if (!versionMatch.Success) { diff --git a/tests/Testcontainers.MongoDb.Tests/MongoDbContainerTest.cs b/tests/Testcontainers.MongoDb.Tests/MongoDbContainerTest.cs index 732ab150f..81cfa2e46 100644 --- a/tests/Testcontainers.MongoDb.Tests/MongoDbContainerTest.cs +++ b/tests/Testcontainers.MongoDb.Tests/MongoDbContainerTest.cs @@ -4,9 +4,12 @@ public abstract class MongoDbContainerTest : IAsyncLifetime { private readonly MongoDbContainer _mongoDbContainer; - private MongoDbContainerTest(MongoDbContainer mongoDbContainer) + private readonly bool _replicaSetEnabled; + + private MongoDbContainerTest(MongoDbContainer mongoDbContainer, bool replicaSetEnabled = false) { _mongoDbContainer = mongoDbContainer; + _replicaSetEnabled = replicaSetEnabled; } public Task InitializeAsync() @@ -49,6 +52,30 @@ public async Task ExecScriptReturnsSuccessful() Assert.Empty(execResult.Stderr); } + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task ReplicaSetStatus() + { + // Given + const string scriptContent = "rs.status().ok;"; + + // When + var execResult = await _mongoDbContainer.ExecScriptAsync(scriptContent) + .ConfigureAwait(true); + + // Then + if (_replicaSetEnabled) + { + Assert.True(0L.Equals(execResult.ExitCode), execResult.Stderr); + Assert.Empty(execResult.Stderr); + } + else + { + Assert.Equal(1L, execResult.ExitCode); + Assert.Equal("MongoServerError: not running with --replSet\n", execResult.Stderr); + } + } + [UsedImplicitly] public sealed class MongoDbDefaultConfiguration : MongoDbContainerTest { @@ -80,7 +107,25 @@ public MongoDbV5Configuration() public sealed class MongoDbV4Configuration : MongoDbContainerTest { public MongoDbV4Configuration() - : base(new MongoDbBuilder().WithImage("mongo:4.4").Build()) + : base(new MongoDbBuilder().WithImage("mongo:4.4").Build(), true /* Replica set status returns "ok" in MongoDB 4.4 without initialization. */) + { + } + } + + [UsedImplicitly] + public sealed class MongoDbReplicaSetDefaultConfiguration : MongoDbContainerTest + { + public MongoDbReplicaSetDefaultConfiguration() + : base(new MongoDbBuilder().WithReplicaSet().Build(), true) + { + } + } + + [UsedImplicitly] + public sealed class MongoDbNamedReplicaSetConfiguration : MongoDbContainerTest + { + public MongoDbNamedReplicaSetConfiguration() + : base(new MongoDbBuilder().WithReplicaSet("rs1").Build(), true) { } }