Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add MongoDB replica set support #1196

Merged
8 changes: 8 additions & 0 deletions docs/modules/mongodb.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
```
75 changes: 69 additions & 6 deletions src/Testcontainers.MongoDb/MongoDbBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ public sealed class MongoDbBuilder : ContainerBuilder<MongoDbBuilder, MongoDbCon

public const string DefaultPassword = "mongo";

private const string InitKeyFileScriptFilePath = "/docker-entrypoint-initdb.d/01-init-keyfile.sh";

private const string KeyFileFilePath = "/tmp/mongodb-keyfile";

/// <summary>
/// Initializes a new instance of the <see cref="MongoDbBuilder" /> class.
/// </summary>
Expand Down Expand Up @@ -60,15 +64,44 @@ public MongoDbBuilder WithPassword(string password)
.WithEnvironment("MONGO_INITDB_ROOT_PASSWORD", initDbRootPassword);
}

/// <summary>
/// Initialize MongoDB as a single-node replica set.
/// </summary>
/// <param name="replicaSetName">The replica set name.</param>
/// <returns>A configured instance of <see cref="MongoDbBuilder" />.</returns>
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);
}

/// <inheritdoc />
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);
}

Expand Down Expand Up @@ -118,17 +151,17 @@ protected override MongoDbBuilder Merge(MongoDbConfiguration oldValue, MongoDbCo
}

/// <inheritdoc cref="IWaitUntil" />
private sealed class WaitUntil : IWaitUntil
private sealed class WaitIndicateReadiness : IWaitUntil
{
private static readonly string[] LineEndings = { "\r\n", "\n" };

private readonly int _count;

/// <summary>
/// Initializes a new instance of the <see cref="WaitUntil" /> class.
/// Initializes a new instance of the <see cref="WaitIndicateReadiness" /> class.
/// </summary>
/// <param name="configuration">The container configuration.</param>
public WaitUntil(MongoDbConfiguration configuration)
public WaitIndicateReadiness(MongoDbConfiguration configuration)
{
_count = string.IsNullOrEmpty(configuration.Username) && string.IsNullOrEmpty(configuration.Password) ? 1 : 2;
}
Expand All @@ -145,4 +178,34 @@ public async Task<bool> UntilAsync(IContainer container)
.Count(line => line.Contains("Waiting for connections")));
}
}

/// <inheritdoc cref="IWaitUntil" />
private sealed class WaitInitiateReplicaSet : IWaitUntil
{
private readonly string _scriptContent;

/// <summary>
/// Initializes a new instance of the <see cref="WaitInitiateReplicaSet" /> class.
/// </summary>
/// <param name="configuration">The container configuration.</param>
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}}";
}

/// <inheritdoc />
public Task<bool> UntilAsync(IContainer container)
{
return UntilAsync(container as MongoDbContainer);
}

/// <inheritdoc cref="IWaitUntil.UntilAsync" />
private async Task<bool> UntilAsync(MongoDbContainer container)
{
var execResult = await container.ExecScriptAsync(_scriptContent)
.ConfigureAwait(false);

return 0L.Equals(execResult.ExitCode);
}
}
}
14 changes: 13 additions & 1 deletion src/Testcontainers.MongoDb/MongoDbConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ public sealed class MongoDbConfiguration : ContainerConfiguration
/// </summary>
/// <param name="username">The MongoDb username.</param>
/// <param name="password">The MongoDb password.</param>
/// <param name="replicaSetName">The replica set name.</param>
public MongoDbConfiguration(
string username = null,
string password = null)
string password = null,
string replicaSetName = null)
{
Username = username;
Password = password;
ReplicaSetName = replicaSetName;
}

/// <summary>
Expand Down Expand Up @@ -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);
}

/// <summary>
Expand All @@ -68,4 +72,12 @@ public MongoDbConfiguration(MongoDbConfiguration oldValue, MongoDbConfiguration
/// Gets the MongoDb password.
/// </summary>
public string Password { get; }

/// <summary>
/// Gets the replica set name.
/// </summary>
/// <remarks>
/// If specified, the container will be started as a single-node replica set.
/// </remarks>
public string ReplicaSetName { get; }
}
2 changes: 1 addition & 1 deletion src/Testcontainers/Images/DockerImage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ public bool MatchVersion(Predicate<string> predicate)
/// <inheritdoc />
public bool MatchVersion(Predicate<Version> 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)
{
Expand Down
49 changes: 47 additions & 2 deletions tests/Testcontainers.MongoDb.Tests/MongoDbContainerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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)
{
}
}
Expand Down
Loading