diff --git a/Directory.Packages.props b/Directory.Packages.props
index fb76fde0cb..ed7ed57fae 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -70,6 +70,7 @@
+
diff --git a/src/Aspire.Hosting/ApplicationModel/IResourceBuilder.cs b/src/Aspire.Hosting/ApplicationModel/IResourceBuilder.cs
index 5500cbe9d8..3c23669a52 100644
--- a/src/Aspire.Hosting/ApplicationModel/IResourceBuilder.cs
+++ b/src/Aspire.Hosting/ApplicationModel/IResourceBuilder.cs
@@ -23,14 +23,16 @@ public interface IResourceBuilder where T : IResource
/// Adds an annotation to the resource being built.
///
/// The type of the annotation to add.
+ /// The behavior to use when adding the annotation.
/// The resource builder instance.
- IResourceBuilder WithAnnotation() where TAnnotation : IResourceAnnotation, new() => WithAnnotation(new TAnnotation());
+ IResourceBuilder WithAnnotation(ResourceAnnotationMutationBehavior behavior = ResourceAnnotationMutationBehavior.Append) where TAnnotation : IResourceAnnotation, new() => WithAnnotation(new TAnnotation(), behavior);
///
/// Adds an annotation to the resource being built.
///
/// The type of the annotation to add.
/// The annotation to add.
+ /// The behavior to use when adding the annotation.
/// The resource builder instance.
- IResourceBuilder WithAnnotation(TAnnotation annotation) where TAnnotation : IResourceAnnotation;
+ IResourceBuilder WithAnnotation(TAnnotation annotation, ResourceAnnotationMutationBehavior behavior = ResourceAnnotationMutationBehavior.Append) where TAnnotation : IResourceAnnotation;
}
diff --git a/src/Aspire.Hosting/Redis/IRedisResource.cs b/src/Aspire.Hosting/ApplicationModel/ResourceAnnotationMutationBehavior.cs
similarity index 51%
rename from src/Aspire.Hosting/Redis/IRedisResource.cs
rename to src/Aspire.Hosting/ApplicationModel/ResourceAnnotationMutationBehavior.cs
index 907f83e3fe..4c561d1308 100644
--- a/src/Aspire.Hosting/Redis/IRedisResource.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ResourceAnnotationMutationBehavior.cs
@@ -1,9 +1,10 @@
// 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;
+namespace Aspire.Hosting.ApplicationModel;
-namespace Aspire.Hosting.Redis;
-public interface IRedisResource : IResourceWithConnectionString
+public enum ResourceAnnotationMutationBehavior
{
+ Append,
+ Replace
}
diff --git a/src/Aspire.Hosting/DistributedApplicationResourceBuilder.cs b/src/Aspire.Hosting/DistributedApplicationResourceBuilder.cs
index 9a983858ec..065c4bbbf9 100644
--- a/src/Aspire.Hosting/DistributedApplicationResourceBuilder.cs
+++ b/src/Aspire.Hosting/DistributedApplicationResourceBuilder.cs
@@ -10,8 +10,23 @@ internal sealed class DistributedApplicationResourceBuilder(IDistributedAppli
public T Resource { get; } = resource;
public IDistributedApplicationBuilder ApplicationBuilder { get; } = applicationBuilder;
- public IResourceBuilder WithAnnotation(TAnnotation annotation) where TAnnotation : IResourceAnnotation
+ ///
+ public IResourceBuilder WithAnnotation(TAnnotation annotation, ResourceAnnotationMutationBehavior behavior = ResourceAnnotationMutationBehavior.Append) where TAnnotation : IResourceAnnotation
{
+ // Some defensive code to protect against introducing a new enumeration value without first updating
+ // this code to accomodate it.
+ if (behavior != ResourceAnnotationMutationBehavior.Append && behavior != ResourceAnnotationMutationBehavior.Replace)
+ {
+ throw new ArgumentOutOfRangeException(nameof(behavior), behavior, "ResourceAnnotationMutationBehavior must be either AddAppend or AddReplace.");
+ }
+
+ // If the behavior is AddReplace then there should never be more than one annotation present. The following call will result in an exception which
+ // allows us to easily spot these bugs.
+ if (behavior == ResourceAnnotationMutationBehavior.Replace && Resource.Annotations.OfType().SingleOrDefault() is { } existingAnnotation)
+ {
+ Resource.Annotations.Remove(existingAnnotation);
+ }
+
Resource.Annotations.Add(annotation);
return this;
}
diff --git a/src/Aspire.Hosting/Extensions/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/Extensions/ResourceBuilderExtensions.cs
index fdcff8dfd6..28c8e150e0 100644
--- a/src/Aspire.Hosting/Extensions/ResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting/Extensions/ResourceBuilderExtensions.cs
@@ -105,7 +105,8 @@ public static IResourceBuilder WithEnvironment(this IResourceBuilder bu
/// A reference to the .
public static IResourceBuilder WithManifestPublishingCallback(this IResourceBuilder builder, Action callback) where T : IResource
{
- return builder.WithAnnotation(new ManifestPublishingCallbackAnnotation(callback));
+ // You can only ever have one manifest publishing callback, so it must be a replace operation.
+ return builder.WithAnnotation(new ManifestPublishingCallbackAnnotation(callback), ResourceAnnotationMutationBehavior.Replace);
}
private static bool ContainsAmbiguousEndpoints(IEnumerable endpoints)
diff --git a/src/Aspire.Hosting/Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting/Redis/RedisBuilderExtensions.cs
index 044cc35285..ebafd1953d 100644
--- a/src/Aspire.Hosting/Redis/RedisBuilderExtensions.cs
+++ b/src/Aspire.Hosting/Redis/RedisBuilderExtensions.cs
@@ -19,33 +19,19 @@ public static class RedisBuilderExtensions
///
/// The .
/// The name of the resource. This name will be used as the connection string name when referenced in a dependency.
- /// The host port for the redis server.
+ /// The host port to bind the underlying container to.
/// A reference to the .
- public static IResourceBuilder AddRedisContainer(this IDistributedApplicationBuilder builder, string name, int? port = null)
- {
- var redis = new RedisContainerResource(name);
- return builder.AddResource(redis)
- .WithManifestPublishingCallback(context => WriteRedisContainerResourceToManifest(context, redis))
- .WithAnnotation(new EndpointAnnotation(ProtocolType.Tcp, port: port, containerPort: 6379))
- .WithAnnotation(new ContainerImageAnnotation { Image = "redis", Tag = "latest" });
- }
-
- ///
- /// Adds a Redis container to the application model. The default image is "redis" and tag is "latest".
- ///
- /// The .
- /// The name of the resource. This name will be used as the connection string name when referenced in a dependency.
- /// A reference to the .
- public static IResourceBuilder AddRedis(this IDistributedApplicationBuilder builder, string name)
+ public static IResourceBuilder AddRedis(this IDistributedApplicationBuilder builder, string name, int? port = null)
{
var redis = new RedisResource(name);
return builder.AddResource(redis)
.WithManifestPublishingCallback(WriteRedisResourceToManifest)
- .WithAnnotation(new EndpointAnnotation(ProtocolType.Tcp, containerPort: 6379))
+ .WithAnnotation(new EndpointAnnotation(ProtocolType.Tcp, port: port, containerPort: 6379))
.WithAnnotation(new ContainerImageAnnotation { Image = "redis", Tag = "latest" });
}
- public static IResourceBuilder WithRedisCommander(this IResourceBuilder builder, string? containerName = null, int? hostPort = null) where T: IRedisResource
+
+ public static IResourceBuilder WithRedisCommander(this IResourceBuilder builder, string? containerName = null, int? hostPort = null)
{
if (builder.ApplicationBuilder.Resources.OfType().Any())
{
@@ -70,7 +56,12 @@ private static void WriteRedisResourceToManifest(ManifestPublishingContext conte
context.Writer.WriteString("type", "redis.v0");
}
- private static void WriteRedisContainerResourceToManifest(ManifestPublishingContext context, RedisContainerResource resource)
+ public static IResourceBuilder PublishAsContainer(this IResourceBuilder builder)
+ {
+ return builder.WithManifestPublishingCallback(context => WriteRedisContainerResourceToManifest(context, builder.Resource));
+ }
+
+ private static void WriteRedisContainerResourceToManifest(ManifestPublishingContext context, RedisResource resource)
{
context.WriteContainer(resource);
context.Writer.WriteString( // "connectionString": "...",
diff --git a/src/Aspire.Hosting/Redis/RedisCommanderConfigWriterHook.cs b/src/Aspire.Hosting/Redis/RedisCommanderConfigWriterHook.cs
index a79435eee7..3fecde83c0 100644
--- a/src/Aspire.Hosting/Redis/RedisCommanderConfigWriterHook.cs
+++ b/src/Aspire.Hosting/Redis/RedisCommanderConfigWriterHook.cs
@@ -17,7 +17,7 @@ public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, C
return Task.CompletedTask;
}
- var redisInstances = appModel.Resources.OfType();
+ var redisInstances = appModel.Resources.OfType();
if (!redisInstances.Any())
{
diff --git a/src/Aspire.Hosting/Redis/RedisContainerResource.cs b/src/Aspire.Hosting/Redis/RedisContainerResource.cs
deleted file mode 100644
index 0a69b61818..0000000000
--- a/src/Aspire.Hosting/Redis/RedisContainerResource.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-// 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.Redis;
-
-namespace Aspire.Hosting.ApplicationModel;
-
-///
-/// A resource that represents a Redis container.
-///
-/// The name of the resource.
-public class RedisContainerResource(string name) : ContainerResource(name), IResourceWithConnectionString, IRedisResource
-{
- ///
- /// Gets the connection string for the Redis server.
- ///
- /// A connection string for the redis server in the form "host:port".
- public string GetConnectionString()
- {
- if (!this.TryGetAnnotationsOfType(out var allocatedEndpoints))
- {
- throw new DistributedApplicationException("Redis resource does not have endpoint annotation.");
- }
-
- // We should only have one endpoint for Redis for local scenarios.
- var endpoint = allocatedEndpoints.Single();
- return endpoint.EndPointString;
- }
-}
diff --git a/src/Aspire.Hosting/Redis/RedisResource.cs b/src/Aspire.Hosting/Redis/RedisResource.cs
index 76ae9eb4bb..b42fd63910 100644
--- a/src/Aspire.Hosting/Redis/RedisResource.cs
+++ b/src/Aspire.Hosting/Redis/RedisResource.cs
@@ -1,15 +1,13 @@
// 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.Redis;
-
namespace Aspire.Hosting.ApplicationModel;
///
/// A resource that represents a Redis resource independent of the hosting model.
///
/// The name of the resource.
-public class RedisResource(string name) : Resource(name), IResourceWithConnectionString, IRedisResource
+public class RedisResource(string name) : ContainerResource(name), IResourceWithConnectionString
{
///
/// Gets the connection string for the Redis server.
diff --git a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs
index a050ed8418..2ba835fa48 100644
--- a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs
+++ b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs
@@ -194,7 +194,7 @@ public void EnsureAllRedisManifestTypesHaveVersion0Suffix()
var program = CreateTestProgramJsonDocumentManifestPublisher();
program.AppBuilder.AddRedis("redisabstract");
- program.AppBuilder.AddRedisContainer("rediscontainer");
+ program.AppBuilder.AddRedis("rediscontainer").PublishAsContainer();
// Build AppHost so that publisher can be resolved.
program.Build();
@@ -211,6 +211,27 @@ public void EnsureAllRedisManifestTypesHaveVersion0Suffix()
Assert.Equal("container.v0", container.GetProperty("type").GetString());
}
+ [Fact]
+ public void PublishingRedisResourceAsContainerResultsInConnectionStringProperty()
+ {
+ var program = CreateTestProgramJsonDocumentManifestPublisher();
+
+ program.AppBuilder.AddRedis("rediscontainer").PublishAsContainer();
+
+ // Build AppHost so that publisher can be resolved.
+ program.Build();
+ var publisher = program.GetManifestPublisher();
+
+ program.Run();
+
+ var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");
+
+ var container = resources.GetProperty("rediscontainer");
+ Assert.Equal("container.v0", container.GetProperty("type").GetString());
+ Assert.Equal("{rediscontainer.bindings.tcp.host}:{rediscontainer.bindings.tcp.port}", container.GetProperty("connectionString").GetString());
+
+ }
+
[Fact]
public void EnsureAllPostgresManifestTypesHaveVersion0Suffix()
{
diff --git a/tests/Aspire.Hosting.Tests/Redis/AddRedisTests.cs b/tests/Aspire.Hosting.Tests/Redis/AddRedisTests.cs
index 5923eb3721..eded2b8ed0 100644
--- a/tests/Aspire.Hosting.Tests/Redis/AddRedisTests.cs
+++ b/tests/Aspire.Hosting.Tests/Redis/AddRedisTests.cs
@@ -14,13 +14,13 @@ public class AddRedisTests
public void AddRedisContainerWithDefaultsAddsAnnotationMetadata()
{
var appBuilder = DistributedApplication.CreateBuilder();
- appBuilder.AddRedisContainer("myRedis");
+ appBuilder.AddRedis("myRedis").PublishAsContainer();
var app = appBuilder.Build();
var appModel = app.Services.GetRequiredService();
- var containerResource = Assert.Single(appModel.Resources.OfType());
+ var containerResource = Assert.Single(appModel.Resources.OfType());
Assert.Equal("myRedis", containerResource.Name);
var manifestAnnotation = Assert.Single(containerResource.Annotations.OfType());
@@ -45,13 +45,13 @@ public void AddRedisContainerWithDefaultsAddsAnnotationMetadata()
public void AddRedisContainerAddsAnnotationMetadata()
{
var appBuilder = DistributedApplication.CreateBuilder();
- appBuilder.AddRedisContainer("myRedis", 9813);
+ appBuilder.AddRedis("myRedis", port: 9813);
var app = appBuilder.Build();
var appModel = app.Services.GetRequiredService();
- var containerResource = Assert.Single(appModel.Resources.OfType());
+ var containerResource = Assert.Single(appModel.Resources.OfType());
Assert.Equal("myRedis", containerResource.Name);
var manifestAnnotation = Assert.Single(containerResource.Annotations.OfType());
@@ -76,7 +76,7 @@ public void AddRedisContainerAddsAnnotationMetadata()
public void RedisCreatesConnectionString()
{
var appBuilder = DistributedApplication.CreateBuilder();
- appBuilder.AddRedisContainer("myRedis")
+ appBuilder.AddRedis("myRedis")
.WithAnnotation(
new AllocatedEndpointAnnotation("mybinding",
ProtocolType.Tcp,
@@ -99,7 +99,7 @@ public void WithRedisCommanderAddsRedisCommanderResource()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddRedis("myredis1").WithRedisCommander();
- builder.AddRedisContainer("myredis2").WithRedisCommander();
+ builder.AddRedis("myredis2").WithRedisCommander();
Assert.Single(builder.Resources.OfType());
}
@@ -138,7 +138,7 @@ public async Task MultipleRedisInstanceProducesCorrectRedisHostsVariable()
{
var builder = DistributedApplication.CreateBuilder();
var redis1 = builder.AddRedis("myredis1").WithRedisCommander();
- var redis2 = builder.AddRedisContainer("myredis2").WithRedisCommander();
+ var redis2 = builder.AddRedis("myredis2").WithRedisCommander();
var app = builder.Build();
// Add fake allocated endpoints.
diff --git a/tests/Aspire.Hosting.Tests/Utils/WithAnnotationTests.cs b/tests/Aspire.Hosting.Tests/Utils/WithAnnotationTests.cs
new file mode 100644
index 0000000000..f185f39135
--- /dev/null
+++ b/tests/Aspire.Hosting.Tests/Utils/WithAnnotationTests.cs
@@ -0,0 +1,56 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Xunit;
+
+namespace Aspire.Hosting.Tests.Utils;
+
+public class WithAnnotationTests
+{
+ [Fact]
+ public void WithAnnotationWithTypeParameterAndNoExplicitBehaviorAppends()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var redis = builder.AddRedis("redis")
+ .WithAnnotation()
+ .WithAnnotation();
+
+ var dummyAnnotations = redis.Resource.Annotations.OfType();
+
+ Assert.Equal(2, dummyAnnotations.Count());
+ Assert.NotEqual(dummyAnnotations.First(), dummyAnnotations.Last());
+ }
+
+ [Fact]
+ public void WithAnnotationWithTypeParameterAndArgumentAndNoExplicitBehaviorAppends()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var redis = builder.AddRedis("redis")
+ .WithAnnotation(new DummyAnnotation())
+ .WithAnnotation(new DummyAnnotation());
+
+ var dummyAnnotations = redis.Resource.Annotations.OfType();
+
+ Assert.Equal(2, dummyAnnotations.Count());
+ Assert.NotEqual(dummyAnnotations.First(), dummyAnnotations.Last());
+ }
+
+ [Fact]
+ public void WithAnnotationWithTypeParameterAndArgumentAndAddReplaceBehaviorReplaces()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var redis = builder.AddRedis("redis").WithAnnotation();
+
+ var firstAnnotation = redis.Resource.Annotations.OfType().Single();
+
+ redis.WithAnnotation(ResourceAnnotationMutationBehavior.Replace);
+
+ var secondAnnotation = redis.Resource.Annotations.OfType().Single();
+
+ Assert.NotEqual(firstAnnotation, secondAnnotation);
+ }
+}
+
+public class DummyAnnotation : IResourceAnnotation
+{
+}