Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 70 additions & 5 deletions src/Aspire.Hosting.MongoDB/MongoDBBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.MongoDB;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using MongoDB.Driver;

namespace Aspire.Hosting;
Expand Down Expand Up @@ -73,12 +74,15 @@ public static IResourceBuilder<MongoDBServerResource> AddMongoDB(this IDistribut
});

var healthCheckKey = $"{name}_check";
// cache the client so it is reused on subsequent calls to the health check
IMongoClient? client = null;
var healthCheck = new MongoDBServerHealthCheck(mongoDBContainer, () => connectionString);
builder.Services.AddHealthChecks()
.AddMongoDb(
sp => client ??= new MongoClient(connectionString ?? throw new InvalidOperationException("Connection string is unavailable")),
name: healthCheckKey);
.Add(new HealthCheckRegistration(
healthCheckKey,
_ => healthCheck,
failureStatus: default,
tags: default,
timeout: default
));

return builder
.AddResource(mongoDBContainer)
Expand Down Expand Up @@ -256,6 +260,67 @@ public static IResourceBuilder<MongoDBServerResource> WithInitFiles(this IResour
return builder.WithContainerFiles(initPath, importFullPath);
}

/// <summary>
/// Configures the MongoDB container to start as a single-node replica set.
/// </summary>
/// <remarks>
/// <para>
/// Enabling replica set mode is required for applications that use MongoDB transactions or change streams.
/// A keyfile is injected into the container to satisfy MongoDB's authentication requirements for replica
/// set mode. The keyfile content is generated as a high-entropy secret and persisted to the AppHost's user
/// secrets store, so it remains stable across runs (required for persistent containers — changing file
/// content would force container recreation).
/// </para>
/// </remarks>
/// <param name="builder">The resource builder.</param>
/// <param name="replicaSetName">The name of the replica set. Defaults to <c>rs0</c>.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
[AspireExport(Description = "Configures the MongoDB container to start as a single-node replica set")]
public static IResourceBuilder<MongoDBServerResource> WithReplicaSet(this IResourceBuilder<MongoDBServerResource> builder, string replicaSetName = "rs0")
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(replicaSetName);

if (builder.Resource.ReplicaSetName is not null)
{
throw new InvalidOperationException($"A replica set has already been configured for the '{builder.Resource.Name}' MongoDB resource.");
}

builder.Resource.ReplicaSetName = replicaSetName;

// High-entropy keyfile generated once and persisted to user secrets in run mode (via
// CreateGeneratedParameter's UserSecretsParameterDefault wrapping), so it is stable across runs —
// important because changing the file content would force the persistent container to be recreated.
// The default password alphabet (lower+upper+numeric, no special) is a strict subset of MongoDB's
// permitted base64 keyfile alphabet, so the generated value is a valid keyfile.
var keyFileParameter = ParameterResourceBuilderExtensions.CreateGeneratedParameter(
builder.ApplicationBuilder,
$"{builder.Resource.Name}-keyfile-content",
secret: true,
new GenerateParameterDefault
{
MinLength = 32,
Special = false,
});

builder.Resource.KeyFileContentParameter = keyFileParameter;

return builder
.WithArgs("--replSet", replicaSetName, "--keyFile", "/tmp/mongodb-keyfile", "--bind_ip_all")
.WithContainerFiles("/tmp", async (_, ct) =>
{
var contents = await keyFileParameter.GetValueAsync(ct).ConfigureAwait(false);
return [
new ContainerFile
{
Name = "mongodb-keyfile",
Contents = contents,
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite,
}
];
}, defaultOwner: 999, defaultGroup: 999);
}

private static void ConfigureMongoExpressContainer(EnvironmentCallbackContext context, MongoDBServerResource resource)
{
// Mongo Express assumes Mongo is being accessed over a default Aspire container network and hardcodes the resource address
Expand Down
82 changes: 82 additions & 0 deletions src/Aspire.Hosting.MongoDB/MongoDBServerHealthCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using MongoDB.Bson;
using MongoDB.Driver;

namespace Aspire.Hosting.MongoDB;

internal sealed class MongoDBServerHealthCheck(MongoDBServerResource resource, Func<string?> connectionStringFactory) : IHealthCheck
{
private IMongoClient? _client;
Comment thread
artiomchi marked this conversation as resolved.

public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
var client = _client;
if (client is null)
{
var connectionString = connectionStringFactory() ?? throw new InvalidOperationException("Connection string is unavailable");
var newClient = new MongoClient(connectionString);
client = Interlocked.CompareExchange(ref _client, newClient, null) ?? newClient;
}

if (resource.ReplicaSetName is { } replicaSetName)
{
var admin = client.GetDatabase("admin");
var helloCmd = new BsonDocument("hello", 1);
var hello = await admin.RunCommandAsync<BsonDocument>(helloCmd, ReadPreference.Nearest, cancellationToken).ConfigureAwait(false);

// The server is the source of truth for replica set state. A node started with
// --replSet that has not had replSetInitiate run reports no `setName` in `hello`;
// the same is true after a container is recreated and its prior config is gone.
if (!hello.Contains("setName"))
Comment thread
artiomchi marked this conversation as resolved.
{
var targetPort = resource.PrimaryEndpoint.TargetPort ?? 27017;
var initCmd = new BsonDocument
{
["replSetInitiate"] = new BsonDocument
{
["_id"] = replicaSetName,
["members"] = new BsonArray
{
new BsonDocument
{
["_id"] = 0,
["host"] = $"{resource.Name}:{targetPort}"
}
}
}
};

try
{
await admin.RunCommandAsync<BsonDocument>(initCmd, ReadPreference.Nearest, cancellationToken).ConfigureAwait(false);
}
catch (MongoCommandException ex) when (ex.Code == 23) // AlreadyInitialized
{
// Race with a concurrent tick that already initiated; safe to ignore.
}

return HealthCheckResult.Unhealthy("Replica set initiation issued; awaiting primary election.");
}

return hello.GetValue("isWritablePrimary", false).AsBoolean
? HealthCheckResult.Healthy()
: HealthCheckResult.Unhealthy("Replica set primary not yet elected.");
}
else
{
using var cursor = await client.ListDatabaseNamesAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy();
}
}
catch (Exception ex)
{
return new HealthCheckResult(context.Registration.FailureStatus, exception: ex);
}
}
}
27 changes: 27 additions & 0 deletions src/Aspire.Hosting.MongoDB/MongoDBServerResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ public MongoDBServerResource(string name, ParameterResource? userNameParameter,
/// </summary>
public ParameterResource? UserNameParameter { get; }

/// <summary>
/// Gets the replica set name if the MongoDB server is configured as a single-node replica set, or <see langword="null"/> if it is running as a standalone server.
/// </summary>
public string? ReplicaSetName { get; internal set; }

/// <summary>
/// Gets the parameter that contains the contents of the keyfile used for MongoDB replica set internal authentication,
/// or <see langword="null"/> if the server is running as a standalone server.
/// </summary>
/// <remarks>
/// The value is generated as a high-entropy secret on first use and persisted to the AppHost's user secrets store
/// in run mode, so it remains stable across runs of the same AppHost. In publish mode the parameter is emitted to
/// the manifest with a <c>generate</c> directive.
/// </remarks>
public ParameterResource? KeyFileContentParameter { get; internal set; }

/// <summary>
/// Gets a reference to the user name for the MongoDB server.
/// </summary>
Expand Down Expand Up @@ -119,6 +135,12 @@ internal ReferenceExpression BuildConnectionString(string? databaseName = null)
builder.Append($"{DefaultAuthenticationMechanism:uri}");
}

if (ReplicaSetName is not null)
{
builder.AppendLiteral(PasswordParameter is not null ? "&" : "?");
builder.AppendLiteral("directConnection=true");
}

return builder.Build();
}

Expand Down Expand Up @@ -148,5 +170,10 @@ IEnumerable<KeyValuePair<string, ReferenceExpression>> IResourceWithConnectionSt
}

yield return new("Uri", UriExpression);

if (ReplicaSetName is not null)
{
yield return new("DirectConnection", ReferenceExpression.Create($"true"));
}
}
}
7 changes: 7 additions & 0 deletions src/Aspire.Hosting.MongoDB/api/Aspire.Hosting.MongoDB.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public static partial class MongoDBBuilderExtensions
[AspireExport("withMongoExpress", Description = "Adds a MongoExpress administration platform for MongoDB", RunSyncOnBackgroundThread = true)]
public static ApplicationModel.IResourceBuilder<T> WithMongoExpress<T>(this ApplicationModel.IResourceBuilder<T> builder, System.Action<ApplicationModel.IResourceBuilder<MongoDB.MongoExpressContainerResource>>? configureContainer = null, string? containerName = null)
where T : ApplicationModel.MongoDBServerResource { throw null; }

[AspireExport("withReplicaSet", Description = "Configures the MongoDB container to start as a single-node replica set")]
public static ApplicationModel.IResourceBuilder<ApplicationModel.MongoDBServerResource> WithReplicaSet(this ApplicationModel.IResourceBuilder<ApplicationModel.MongoDBServerResource> builder, string replicaSetName = "rs0") { throw null; }
}
}

Expand Down Expand Up @@ -73,12 +76,16 @@ public MongoDBServerResource(string name) : base(default!, default) { }

public EndpointReferenceExpression Host { get { throw null; } }

public ParameterResource? KeyFileContentParameter { get { throw null; } }

public ParameterResource? PasswordParameter { get { throw null; } }

public EndpointReferenceExpression Port { get { throw null; } }

public EndpointReference PrimaryEndpoint { get { throw null; } }

public string? ReplicaSetName { get { throw null; } }

public ReferenceExpression UriExpression { get { throw null; } }

public ParameterResource? UserNameParameter { get { throw null; } }
Expand Down
130 changes: 130 additions & 0 deletions tests/Aspire.Hosting.MongoDB.Tests/AddMongoDBTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,136 @@ public async Task VerifyManifest()
Assert.Equal(expectedManifest, dbManifest.ToString());
}

[Fact]
public void WithReplicaSetSetsReplicaSetNameToDefaultRs0()
{
var builder = DistributedApplication.CreateBuilder();
var mongo = builder.AddMongoDB("mongodb").WithReplicaSet();

Assert.Equal("rs0", mongo.Resource.ReplicaSetName);
}

[Fact]
public void WithReplicaSetSetsCustomReplicaSetName()
{
var builder = DistributedApplication.CreateBuilder();
var mongo = builder.AddMongoDB("mongodb").WithReplicaSet("myset");

Assert.Equal("myset", mongo.Resource.ReplicaSetName);
}

[Fact]
public async Task WithReplicaSetAddsCorrectContainerArgs()
{
var builder = DistributedApplication.CreateBuilder();
var mongo = builder.AddMongoDB("mongodb").WithReplicaSet();
var args = await ArgumentEvaluator.GetArgumentListAsync(mongo.Resource);

Assert.Contains("--replSet", args);
Assert.Contains("rs0", args);
Assert.Contains("--keyFile", args);
Assert.Contains("/tmp/mongodb-keyfile", args);
Assert.Contains("--bind_ip_all", args);
}

[Fact]
public void WithReplicaSetAddsContainerFileAnnotation()
{
var builder = DistributedApplication.CreateBuilder();
var mongo = builder.AddMongoDB("mongodb").WithReplicaSet();

Assert.Single(mongo.Resource.Annotations.OfType<ContainerFileSystemCallbackAnnotation>(),
a => a.DestinationPath == "/tmp");
}

[Fact]
public async Task WithReplicaSetKeyfileContentIsHighEntropyAndStableForResource()
{
var builder = DistributedApplication.CreateBuilder();
var mongo = builder.AddMongoDB("mongodb").WithReplicaSet();
using var app = builder.Build();
var annotation = mongo.Resource.Annotations.OfType<ContainerFileSystemCallbackAnnotation>()
.Single(a => a.DestinationPath == "/tmp");

var entries1 = await annotation.Callback(
new() { Model = mongo.Resource, ServiceProvider = app.Services },
CancellationToken.None);
var keyfile1 = Assert.IsType<ContainerFile>(Assert.Single(entries1));

// Resolving the callback again on the same resource must yield the same content; the parameter
// value is generated lazily and cached on the ParameterResource for the lifetime of the AppHost,
// and is persisted to user secrets so it stays stable across runs as well.
var entries2 = await annotation.Callback(
new() { Model = mongo.Resource, ServiceProvider = app.Services },
CancellationToken.None);
var keyfile2 = Assert.IsType<ContainerFile>(Assert.Single(entries2));

Assert.Equal("mongodb-keyfile", keyfile1.Name);
Assert.Equal(keyfile1.Contents, keyfile2.Contents);
Assert.NotNull(keyfile1.Contents);
Assert.True(keyfile1.Contents!.Length >= 32);
Assert.NotNull(mongo.Resource.KeyFileContentParameter);
Assert.Equal($"{mongo.Resource.Name}-keyfile-content", mongo.Resource.KeyFileContentParameter!.Name);
}

[Fact]
public async Task WithReplicaSetServerConnectionStringIncludesDirectConnectionWithAuth()
{
var appBuilder = DistributedApplication.CreateBuilder();
appBuilder
.AddMongoDB("mongodb")
.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 27017))
.WithReplicaSet();

using var app = appBuilder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var serverResource = Assert.Single(appModel.Resources.OfType<MongoDBServerResource>());

Assert.Equal(
"mongodb://admin:{mongodb-password.value}@{mongodb.bindings.tcp.host}:{mongodb.bindings.tcp.port}/?authSource=admin&authMechanism=SCRAM-SHA-256&directConnection=true",
serverResource.ConnectionStringExpression.ValueExpression);
}

[Fact]
public void WithReplicaSetServerConnectionStringIncludesDirectConnectionWithoutAuth()
{
// Use AddResource to create a resource without a password so we can verify
// the '?' separator (no auth query string prefix) rather than '&'.
var appBuilder = DistributedApplication.CreateBuilder();
var mongo = appBuilder.AddResource(new MongoDBServerResource("mongodb")).WithReplicaSet();

// No password configured → directConnection is appended with '?' not '&'
Assert.Null(mongo.Resource.PasswordParameter);
Assert.Contains("?directConnection=true", mongo.Resource.ConnectionStringExpression.ValueExpression);
Assert.DoesNotContain("&directConnection=true", mongo.Resource.ConnectionStringExpression.ValueExpression);
}

[Fact]
public async Task WithReplicaSetDatabaseConnectionStringIncludesDirectConnection()
{
var appBuilder = DistributedApplication.CreateBuilder();
appBuilder
.AddMongoDB("mongodb")
.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 27017))
.WithReplicaSet()
.AddDatabase("mydb");

using var app = appBuilder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var dbResource = Assert.Single(appModel.Resources.OfType<MongoDBDatabaseResource>());

Assert.Contains("directConnection=true", dbResource.ConnectionStringExpression.ValueExpression);
}

[Fact]
public void WithoutReplicaSetConnectionStringDoesNotIncludeDirectConnection()
{
var appBuilder = DistributedApplication.CreateBuilder();
var mongo = appBuilder.AddMongoDB("mongodb");

Assert.DoesNotContain("directConnection", mongo.Resource.ConnectionStringExpression.ValueExpression);
}

[Fact]
public void ThrowsWithIdenticalChildResourceNames()
{
Expand Down
Loading
Loading