diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 911f09ee8e5..689eb2a9e46 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -2239,6 +2239,70 @@ public static IResourceBuilder WithParentRelationship( return builder.WithRelationship(parent, KnownRelationshipTypes.Parent); } + /// + /// Adds a to the resource annotations to add a parent-child relationship. + /// + /// The type of the resource. + /// The resource builder. + /// The child of . + /// A resource builder. + /// + /// + /// The WithChildRelationship method is used to add child relationships to the resource. Relationships are used to link + /// resources together in UI. + /// + /// + /// This example shows adding a relationship between two resources. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var parameter = builder.AddParameter("parameter"); + /// + /// var backend = builder.AddProject<Projects.Backend>("backend"); + /// .WithChildRelationship(parameter); + /// + /// + /// + public static IResourceBuilder WithChildRelationship( + this IResourceBuilder builder, + IResourceBuilder child) where T : IResource + { + child.WithRelationship(builder.Resource, KnownRelationshipTypes.Parent); + return builder; + } + + /// + /// Adds a to the resource annotations to add a parent-child relationship. + /// + /// The type of the resource. + /// The resource builder. + /// The child of . + /// A resource builder. + /// + /// + /// The WithChildRelationship method is used to add child relationships to the resource. Relationships are used to link + /// resources together in UI. + /// + /// + /// This example shows adding a relationship between two resources. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var parameter = builder.AddParameter("parameter"); + /// + /// var backend = builder.AddProject<Projects.Backend>("backend"); + /// .WithChildRelationship(parameter.Resource); + /// + /// + /// + public static IResourceBuilder WithChildRelationship( + this IResourceBuilder builder, + IResource child) where T : IResource + { + var childBuilder = builder.ApplicationBuilder.CreateResourceBuilder(child); + return builder.WithChildRelationship(childBuilder); + } + /// /// Specifies the icon to use when displaying the resource in the dashboard. /// diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs index 38f53d239af..acb6278e6a9 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs @@ -350,10 +350,10 @@ public async Task GrandChildResourceWithConnectionString() var parentResource = builder.AddResource(new ParentResourceWithConnectionString("parent")); var childResource = builder.AddResource( - new ChildResourceWithConnectionString("child", new Dictionary { {"Namespace", "ns"} }, parentResource.Resource) + new ChildResourceWithConnectionString("child", new Dictionary { { "Namespace", "ns" } }, parentResource.Resource) ); var grandChildResource = builder.AddResource( - new ChildResourceWithConnectionString("grand-child", new Dictionary { {"Database", "db"} }, childResource.Resource) + new ChildResourceWithConnectionString("grand-child", new Dictionary { { "Database", "db" } }, childResource.Resource) ); await using var app = builder.Build(); @@ -589,10 +589,10 @@ await events.PublishAsync(new OnResourceChangedContext( // Parent should have the new state Assert.Equal(KnownResourceStates.FailedToStart, parentState); - + // Child container (has own lifetime) should NOT receive parent state Assert.NotEqual(KnownResourceStates.Running, childContainerState); - + // Custom child (does not have own lifetime) SHOULD receive parent state Assert.Equal(KnownResourceStates.FailedToStart, customChildState); } @@ -635,11 +635,171 @@ await events.PublishAsync(new OnResourceChangedContext( // Parent should have the new state Assert.Equal(KnownResourceStates.FailedToStart, parentState); - + // Child project (has own lifetime) should NOT receive parent state Assert.NotEqual(KnownResourceStates.Running, childProjectState); - + // Custom child (does not have own lifetime) SHOULD receive parent state Assert.Equal(KnownResourceStates.FailedToStart, customChildState); } + + [Fact] + public async Task WithChildRelationshipUsingResourceBuilderSetsParentPropertyCorrectly() + { + var builder = DistributedApplication.CreateBuilder(); + + var parent = builder.AddContainer("parent", "image"); + var child = builder.AddContainer("child", "image"); + var child2 = builder.AddContainer("child2", "image"); + + parent.WithChildRelationship(child) + .WithChildRelationship(child2); + + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var events = new DcpExecutorEvents(); + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); + + var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events); + await appOrchestrator.RunApplicationAsync(); + + string? parentResourceId = null; + string? childParentResourceId = null; + string? child2ParentResourceId = null; + var watchResourceTask = Task.Run(async () => + { + await foreach (var item in resourceNotificationService.WatchAsync()) + { + if (item.Resource == parent.Resource) + { + parentResourceId = item.ResourceId; + } + else if (item.Resource == child.Resource) + { + childParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString(); + } + else if (item.Resource == child2.Resource) + { + child2ParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString(); + } + + if (parentResourceId != null && childParentResourceId != null && child2ParentResourceId != null) + { + return; + } + } + }); + + await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None)); + + await watchResourceTask.DefaultTimeout(); + + Assert.Equal(parentResourceId, childParentResourceId); + Assert.Equal(parentResourceId, child2ParentResourceId); + } + + [Fact] + public async Task WithChildRelationshipUsingResourceSetsParentPropertyCorrectly() + { + var builder = DistributedApplication.CreateBuilder(); + + var parent = builder.AddContainer("parent", "image"); + var child = builder.AddContainer("child", "image"); + var child2 = builder.AddContainer("child2", "image"); + + parent.WithChildRelationship(child.Resource) + .WithChildRelationship(child2.Resource); + + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var events = new DcpExecutorEvents(); + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); + + var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events); + await appOrchestrator.RunApplicationAsync(); + + string? parentResourceId = null; + string? childParentResourceId = null; + string? child2ParentResourceId = null; + var watchResourceTask = Task.Run(async () => + { + await foreach (var item in resourceNotificationService.WatchAsync()) + { + if (item.Resource == parent.Resource) + { + parentResourceId = item.ResourceId; + } + else if (item.Resource == child.Resource) + { + childParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString(); + } + else if (item.Resource == child2.Resource) + { + child2ParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString(); + } + + if (parentResourceId != null && childParentResourceId != null && child2ParentResourceId != null) + { + return; + } + } + }); + + await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None)); + + await watchResourceTask.DefaultTimeout(); + + Assert.Equal(parentResourceId, childParentResourceId); + Assert.Equal(parentResourceId, child2ParentResourceId); + } + + [Fact] + public async Task WithChildRelationshipWorksWithProjects() + { + var builder = DistributedApplication.CreateBuilder(); + + var parentProject = builder.AddProject("parent-project"); + var childProject = builder.AddProject("child-project"); + + parentProject.WithChildRelationship(childProject); + + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var events = new DcpExecutorEvents(); + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); + + var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events); + await appOrchestrator.RunApplicationAsync(); + + string? parentProjectResourceId = null; + string? childProjectParentResourceId = null; + var watchResourceTask = Task.Run(async () => + { + await foreach (var item in resourceNotificationService.WatchAsync()) + { + if (item.Resource == parentProject.Resource) + { + parentProjectResourceId = item.ResourceId; + } + else if (item.Resource == childProject.Resource) + { + childProjectParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString(); + } + + if (parentProjectResourceId != null && childProjectParentResourceId != null) + { + return; + } + } + }); + + await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None)); + + await watchResourceTask.DefaultTimeout(); + + Assert.Equal(parentProjectResourceId, childProjectParentResourceId); + } } diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/RelationshipEvaluatorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/RelationshipEvaluatorTests.cs index cbbd6a8d60a..0336483d2a1 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/RelationshipEvaluatorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/RelationshipEvaluatorTests.cs @@ -44,6 +44,97 @@ public void HandlesNestedChildren() Assert.Empty(parentChildLookup[grandChildWithAnnotationsResource.Resource]); } + [Fact] + public void WithChildRelationshipUsingResourceBuilderCreatesCorrectParentChildLookup() + { + var builder = DistributedApplication.CreateBuilder(); + + var parentResource = builder.AddContainer("parent", "image"); + var child1Resource = builder.AddContainer("child1", "image"); + var child2Resource = builder.AddContainer("child2", "image"); + + parentResource.WithChildRelationship(child1Resource) + .WithChildRelationship(child2Resource); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var parentChildLookup = RelationshipEvaluator.GetParentChildLookup(appModel); + Assert.Equal(1, parentChildLookup.Count); + + Assert.Collection(parentChildLookup[parentResource.Resource], + x => Assert.Equal(child1Resource.Resource, x), + x => Assert.Equal(child2Resource.Resource, x)); + } + + [Fact] + public void WithChildRelationshipUsingResourceCreatesCorrectParentChildLookup() + { + var builder = DistributedApplication.CreateBuilder(); + + var parentResource = builder.AddContainer("parent", "image"); + var child1Resource = builder.AddContainer("child1", "image"); + var child2Resource = builder.AddContainer("child2", "image"); + + parentResource.WithChildRelationship(child1Resource.Resource) + .WithChildRelationship(child2Resource.Resource); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var parentChildLookup = RelationshipEvaluator.GetParentChildLookup(appModel); + Assert.Equal(1, parentChildLookup.Count); + + Assert.Collection(parentChildLookup[parentResource.Resource], + x => Assert.Equal(child1Resource.Resource, x), + x => Assert.Equal(child2Resource.Resource, x)); + } + + [Fact] + public void WithChildRelationshipAndWithParentRelationshipWorkTogether() + { + var builder = DistributedApplication.CreateBuilder(); + + var parentResource = builder.AddContainer("parent", "image"); + var child1Resource = builder.AddContainer("child1", "image"); + var child2Resource = builder.AddContainer("child2", "image") + .WithParentRelationship(parentResource); + + parentResource.WithChildRelationship(child1Resource); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var parentChildLookup = RelationshipEvaluator.GetParentChildLookup(appModel); + Assert.Equal(1, parentChildLookup.Count); + + Assert.Collection(parentChildLookup[parentResource.Resource], + x => Assert.Equal(child1Resource.Resource, x), + x => Assert.Equal(child2Resource.Resource, x)); + } + + [Fact] + public void WithChildRelationshipHandlesNestedRelationships() + { + var builder = DistributedApplication.CreateBuilder(); + + var grandParentResource = builder.AddContainer("grandparent", "image"); + var parentResource = builder.AddContainer("parent", "image"); + var childResource = builder.AddContainer("child", "image"); + + grandParentResource.WithChildRelationship(parentResource); + parentResource.WithChildRelationship(childResource); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var parentChildLookup = RelationshipEvaluator.GetParentChildLookup(appModel); + Assert.Equal(2, parentChildLookup.Count); + + Assert.Single(parentChildLookup[grandParentResource.Resource], parentResource.Resource); + Assert.Single(parentChildLookup[parentResource.Resource], childResource.Resource); + } + private sealed class CustomChildResource(string name, IResource parent) : Resource(name), IResourceWithParent { public IResource Parent => parent;