testcontainers / testcontainers-dotnet

A library to support tests with throwaway instances of Docker containers for all compatible .NET Standard versions.
https://dotnet.testcontainers.org
MIT License
3.65k stars 250 forks source link

[Enhancement]: Add one node replica set support #1154

Open diegosasw opened 2 months ago

diegosasw commented 2 months ago

Problem

There is no one-node replica set support for testcontainers-dotnet but the java version seems to have something. However, by looking at the java code I have some doubts whether that's properly initializing the replica set.

Solution

Add a WithReplicaSet(string replicaSetName = "rs0") or similar builder method. Add another method to initialize the replica set. Add another method to obtain the ReplicaSet connection string which should be something similar to mongodb://{hostname}:{mapped_port}/?replicaSet=rs0

Benefit

It would allow testing mongoDb replica set and things such as change streams, only available on replica sets. Also, it would get closer to the Java version in functionality.

Alternatives

Currently, mongoDb containers with one node replica set are possible to be created with Ductus.FluentDocker. Also with testcontainers, but the random port is a challenge.

Would you like to help contributing this enhancement?

Yes

diegosasw commented 2 months ago

I have tried to add this functionality on this branch https://github.com/testcontainers/testcontainers-dotnet/tree/57bf8adf56e3ae701b8b488113a1e8db01d41a4a but I wasn't successful.

Please view https://github.com/testcontainers/testcontainers-dotnet/blob/57bf8adf56e3ae701b8b488113a1e8db01d41a4a/tests/Testcontainers.MongoDb.Tests/MongoDbReplicaSetContainerTest.cs as it's the closest I got.

The biggest challenge I am finding is due to the random port. Not really a problem with testcontainers but with a lack of knowledge about mongodb and docker networking.

The mentioned test works well, and it demonstrates how a replica set can be created and initiated and available through a connection string such as mongodb://127.0.0.1:27017/?replicaSet=rs0

But it works because I am disabling the random port, which is not Ok.

If I specify a random port I could get the port number being used but, when initiating the replica set, I would get an error

MongoServerError[InvalidReplicaSetConfig]: No host described in new configuration with {version: 1, term: 0} for replica set rs0 maps to this node

I have manually tried to start the container without executing the rs.initiate(); part, but I wasn't successful. This is the process.

  1. I execute the mongodb container in debug mode and set a breakpoint to leave it running and to explore the port being used
  2. I check the mongodb container Id with
    $ docker ps
    CONTAINER ID   IMAGE                       COMMAND                  CREATED         STATUS         PORTS
      NAMES
    4149a2252b5c   mongo:7.0.7-jammy           "docker-entrypoint.s…"   5 seconds ago   Up 4 seconds   0.0.0.0:27017->27017/tcp   inspiring_hopper
    49c9c66dab55   testcontainers/ryuk:0.6.0   "/bin/ryuk"              6 seconds ago   Up 5 seconds   0.0.0.0:51409->8080/tcp    testcontainers-ryuk-9647d181-f95c-480c-8903-ade881c200d3
  3. I access the container's mongosh
    
    $ docker exec -it 4149a2252b5c mongosh
    Current Mongosh Log ID: 661aa220025bffaaf5db83af
    Connecting to:          mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+2.2.0
    Using MongoDB:          7.0.7
    Using Mongosh:          2.2.0

For mongosh info see: https://docs.mongodb.com/mongodb-shell/

To help improve our products, anonymous usage data is collected and sent to MongoDB periodically (https://www.mongodb.com/legal/privacy-policy). You can opt-out by running the disableTelemetry() command.

test>

4. I try to initiate the replica set

test> rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'mongo:27017'}]}); { ok: 1 }

and this works well for the port 27017, but if I am using a random port and I try to initiate it with that other port (e.g: 51523)

test> rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'mongo:51523'}]}); MongoServerError[InvalidReplicaSetConfig]: No host described in new configuration with {version: 1, term: 0} for replica set rs0 maps to this node



Useful links: 
- https://dev.to/asimmon/the-only-local-mongodb-replica-set-with-docker-compose-guide-youll-ever-need-dao
- https://stackoverflow.com/questions/78056302/mongo-as-replica-set-in-testcontainers-in-net/78277081#78277081
- Discussion https://github.com/testcontainers/testcontainers-dotnet/discussions/1150
eddumelendez commented 2 months ago

Hi @diegosasw, just cross-posting my question here

diegosasw commented 2 months ago

Hi @diegosasw, just cross-posting my question here

I've had a look at your Go PR. I can see you're also using custom object with members when initializing (Java mongo replica set does not have that). Did you make it work with random ports and with connection string with mongodb://host:port/?replica set=rs?

I'm wondering whether you know why this is not working for me.

kieronlanning commented 1 month ago

I've been trying to do this as well, the closest I've gotten is this:

public static IContainer CreateMongoDBWithReplicaSet(Action<ContainerBuilder>? config = null)
{
    var builder = new ContainerBuilder()
        .WithImage("mongo:7.0")
        .WithCommand("--replSet rs0 --bind_ip_all")
        .WithPortBinding(MongoDbBuilder.MongoDbPort, true)
        .WithEnvironment("MONGO_INITDB_ROOT_USERNAME", MongoDbBuilder.DefaultUsername)
        .WithEnvironment("MONGO_INITDB_ROOT_PASSWORD", MongoDbBuilder.DefaultPassword)
        .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(MongoDbBuilder.MongoDbPort))
        .WithStartupCallback(async (container, cancellationToken) =>
        {
            await container.ExecAsync(["bash", "-c", $"echo 'disableTelemetry()' | mongosh -u $MONGO_INITDB_ROOT_USERNAME -p $MONGO_INITDB_ROOT_PASSWORD"], isSuccess: result =>
            {
                result.Stderr.Should().BeNullOrEmpty();

                if (!string.IsNullOrEmpty(result.Stdout))
                {
                    Console.WriteLine("stdout:" + result.Stdout);
                }

                return true;

            }, cancellationToken: cancellationToken);

            await container.ExecAsync(["bash", "-c", $"echo 'try {{ rs.status() }} catch (err) {{ rs.initiate({{_id: \"rs0\", members: [{{ _id: 0, host: \"localhost:{MongoDbBuilder.MongoDbPort}\" }}]}}) }};' | mongosh -u $MONGO_INITDB_ROOT_USERNAME -p $MONGO_INITDB_ROOT_PASSWORD"], e =>
            {
                return false;
            }, cancellationToken: cancellationToken);

            return;

            await container.ExecAsync(["bash", "-c", $"echo 'disableTelemetry(); rs.initiate({{_id: \"rs0\", members: [{{ _id: 0, host: \"localhost:{MongoDbBuilder.MongoDbPort}\" }}]}});' | mongosh -u $MONGO_INITDB_ROOT_USERNAME -p $MONGO_INITDB_ROOT_PASSWORD"], e =>
            {
                return false;
            }, cancellationToken: cancellationToken);
        });

    config?.Invoke(builder);

    return builder.Build();
}

static async Task<bool> ExecAsync(this IContainer container, List<string> commands, Func<ExecResult, bool>? isSuccess = null, bool throwOnFailure = true, int attempts = 10, int delayInMS = 100, CancellationToken cancellationToken = default)
{
    var attemptCount = 0;
    while (attemptCount < attempts)
    {
        try
        {
            var result = await container.ExecAsync(commands, cancellationToken);
            if (result.ExitCode == 0)
            {
                return isSuccess is not null && !isSuccess(result)
                    ? throw new Exception($"Command failed. Stdout: {result.Stdout}, Stderr: {result.Stderr}")
                    : true;
            }

            result.ExitCode.Should().Be(0,
                because: $"MongoDB replica set initialization failed. Attempt {attemptCount + 1} of 10. Stdout: {result.Stdout}, Stderr: {result.Stderr}");
        }
        catch
        {
            await Task.Delay(delayInMS, cancellationToken);
            attemptCount++;
        }
    }

    return throwOnFailure
        ? throw new Exception("Failed to execute command.")
        : false;
}

It's failing as it requires a key, which I've been trying to use mount paths to generate and executing commands but so far failing.

artiomchi commented 3 weeks ago

Hey all, I've been looking for the same functionality myself and have implemented it locally before I saw this issue. I've just created a PR that provides a working configuration for a single node replica set - I've tested it locally on our integration tests, and it's been working perfectly so far

Let me know what you think