Skip to content

Commit

Permalink
feat: Add MongoDB replica set support (#1196)
Browse files Browse the repository at this point in the history
Co-authored-by: Andre Hofmeister <[email protected]>
  • Loading branch information
artiomchi and HofmeisterAn authored Aug 28, 2024
1 parent 87184e3 commit fffd384
Show file tree
Hide file tree
Showing 5 changed files with 138 additions and 10 deletions.
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

0 comments on commit fffd384

Please sign in to comment.