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 +{ +}