diff --git a/playground/Redis/Redis.AppHost/Program.cs b/playground/Redis/Redis.AppHost/Program.cs index 93669bc6be..4ccd947367 100644 --- a/playground/Redis/Redis.AppHost/Program.cs +++ b/playground/Redis/Redis.AppHost/Program.cs @@ -1,9 +1,9 @@ var builder = DistributedApplication.CreateBuilder(args); -var redis = builder.AddRedis("redis") - .WithDataVolume() - .WithRedisCommander(c => c.WithHostPort(33803)) - .WithRedisInsight(c => c.WithHostPort(41567)); +var redis = builder.AddRedis("redis"); +redis.WithDataVolume() + .WithRedisCommander(c => c.WithHostPort(33803).WithParentRelationship(redis.Resource)) + .WithRedisInsight(c => c.WithHostPort(41567).WithParentRelationship(redis.Resource)); var garnet = builder.AddGarnet("garnet") .WithDataVolume(); diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index f112ead917..37eeae1f87 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -174,6 +174,7 @@ public static IResourceBuilder RunAsEmulator(this IResou // Create a separate storage emulator for the Event Hub one var storageResource = builder.ApplicationBuilder .AddAzureStorage($"{builder.Resource.Name}-storage") + .WithParentRelationship(builder.Resource) .RunAsEmulator(); var storage = storageResource.Resource; diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index bdb8da57ff..be8e055ea6 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -264,7 +264,8 @@ public static IResourceBuilder RunAsEmulator(this IReso .WithImageRegistry(ServiceBusEmulatorContainerImageTags.AzureSqlEdgeRegistry) .WithEndpoint(targetPort: 1433, name: "tcp") .WithEnvironment("ACCEPT_EULA", "Y") - .WithEnvironment("MSSQL_SA_PASSWORD", password); + .WithEnvironment("MSSQL_SA_PASSWORD", password) + .WithParentRelationship(builder.Resource); builder.WithAnnotation(new EnvironmentCallbackAnnotation((EnvironmentCallbackContext context) => { diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs index 404efccdd2..2952b4f3c3 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs @@ -99,13 +99,6 @@ public async Task BeforeStartAsync(DistributedApplicationModel appModel, Cancell return; } - static IResource? SelectParentResource(IResource resource) => resource switch - { - IAzureResource ar => ar, - IResourceWithParent rp => SelectParentResource(rp.Parent), - _ => null - }; - // Create a map of parents to their children used to propagate state changes later. _parentChildLookup = appModel.Resources.OfType().ToLookup(r => r.Parent); diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs index 4bf6e9f308..2f54f354e9 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs @@ -111,12 +111,12 @@ await notificationService.PublishUpdateAsync(resource, state => state with { ResourceType = resource.GetType().Name, State = new("Starting", KnownResourceStateStyles.Info), - Properties = [ + Properties = state.Properties.SetResourcePropertyRange([ new("azure.subscription.id", context.Subscription.Id.Name), new("azure.resource.group", context.ResourceGroup.Id.Name), new("azure.tenant.domain", context.Tenant.Data.DefaultDomain), new("azure.location", context.Location.ToString()), - ] + ]) }).ConfigureAwait(false); var resourceLogger = loggerService.GetLogger(resource); diff --git a/src/Aspire.Hosting.MongoDB/MongoDBBuilderExtensions.cs b/src/Aspire.Hosting.MongoDB/MongoDBBuilderExtensions.cs index 03eddf2d92..79192fc5fe 100644 --- a/src/Aspire.Hosting.MongoDB/MongoDBBuilderExtensions.cs +++ b/src/Aspire.Hosting.MongoDB/MongoDBBuilderExtensions.cs @@ -158,6 +158,7 @@ public static IResourceBuilder WithMongoExpress(this IResourceBuilder b .WithImageRegistry(MongoDBContainerImageTags.MongoExpressRegistry) .WithEnvironment(context => ConfigureMongoExpressContainer(context, builder.Resource)) .WithHttpEndpoint(targetPort: 8081, name: "http") + .WithParentRelationship(builder.Resource) .ExcludeFromManifest(); configureContainer?.Invoke(resourceBuilder); diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index cab481dc00..adfa0b376d 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -40,14 +40,14 @@ public CustomResourceSnapshot ToSnapshot(Container container, CustomResourceSnap State = state, // Map a container exit code of -1 (unknown) to null ExitCode = container.Status?.ExitCode is null or Conventions.UnknownExitCode ? null : container.Status.ExitCode, - Properties = [ + Properties = previous.Properties.SetResourcePropertyRange([ new(KnownProperties.Container.Image, container.Spec.Image), new(KnownProperties.Container.Id, containerId), new(KnownProperties.Container.Command, container.Spec.Command), new(KnownProperties.Container.Args, container.Status?.EffectiveArgs ?? []) { IsSensitive = true }, new(KnownProperties.Container.Ports, GetPorts()), new(KnownProperties.Container.Lifetime, GetContainerLifetime()), - ], + ]), EnvironmentVariables = environment, CreationTimeStamp = container.Metadata.CreationTimestamp?.ToUniversalTime(), StartTimeStamp = container.Status?.StartupTimestamp?.ToUniversalTime(), @@ -111,13 +111,13 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn ResourceType = KnownResourceTypes.Project, State = state, ExitCode = executable.Status?.ExitCode, - Properties = [ + Properties = previous.Properties.SetResourcePropertyRange([ new(KnownProperties.Executable.Path, executable.Spec.ExecutablePath), new(KnownProperties.Executable.WorkDir, executable.Spec.WorkingDirectory), new(KnownProperties.Executable.Args, executable.Status?.EffectiveArgs ?? []) { IsSensitive = true }, new(KnownProperties.Executable.Pid, executable.Status?.ProcessId), new(KnownProperties.Project.Path, projectPath) - ], + ]), EnvironmentVariables = environment, CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToUniversalTime(), StartTimeStamp = executable.Status?.StartupTimestamp?.ToUniversalTime(), @@ -132,12 +132,12 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn ResourceType = KnownResourceTypes.Executable, State = state, ExitCode = executable.Status?.ExitCode, - Properties = [ + Properties = previous.Properties.SetResourcePropertyRange([ new(KnownProperties.Executable.Path, executable.Spec.ExecutablePath), new(KnownProperties.Executable.WorkDir, executable.Spec.WorkingDirectory), new(KnownProperties.Executable.Args, executable.Status?.EffectiveArgs ?? []) { IsSensitive = true }, new(KnownProperties.Executable.Pid, executable.Status?.ProcessId) - ], + ]), EnvironmentVariables = environment, CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToUniversalTime(), StartTimeStamp = executable.Status?.StartupTimestamp?.ToUniversalTime(), diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index 5607f6a711..dd2f071bcd 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -16,7 +16,7 @@ internal sealed class ApplicationOrchestrator { private readonly IDcpExecutor _dcpExecutor; private readonly DistributedApplicationModel _model; - private readonly ILookup _parentChildLookup; + private readonly ILookup _parentChildLookup; private readonly IDistributedApplicationLifecycleHook[] _lifecycleHooks; private readonly ResourceNotificationService _notificationService; private readonly IDistributedApplicationEventing _eventing; @@ -33,7 +33,7 @@ public ApplicationOrchestrator(DistributedApplicationModel model, { _dcpExecutor = dcpExecutor; _model = model; - _parentChildLookup = GetParentChildLookup(model); + _parentChildLookup = RelationshipEvaluator.GetParentChildLookup(model); _lifecycleHooks = lifecycleHooks.ToArray(); _notificationService = notificationService; _eventing = eventing; @@ -107,6 +107,8 @@ await _notificationService.PublishUpdateAsync(context.Resource, s => s with HealthReports = GetInitialHealthReports(context.Resource) }) .ConfigureAwait(false); + + await SetExecutableChildResourceAsync(context.Resource).ConfigureAwait(false); break; case KnownResourceTypes.Container: await _notificationService.PublishUpdateAsync(context.Resource, s => s with @@ -217,22 +219,6 @@ public async Task StopResourceAsync(string resourceName, CancellationToken cance await _dcpExecutor.StopResourceAsync(resourceReference, cancellationToken).ConfigureAwait(false); } - private static ILookup GetParentChildLookup(DistributedApplicationModel model) - { - static IResource? SelectParentContainerResource(IResource resource) => resource switch - { - IResourceWithParent rp => SelectParentContainerResource(rp.Parent), - IResource r when r.IsContainer() => r, - _ => null - }; - - // parent -> children lookup - return model.Resources.OfType() - .Select(x => (Child: x, Root: SelectParentContainerResource(x.Parent))) - .Where(x => x.Root is not null) - .ToLookup(x => x.Root, x => x.Child); - } - private async Task SetChildResourceAsync(IResource resource, string parentName, string? state, DateTime? startTimeStamp, DateTime? stopTimeStamp) { foreach (var child in _parentChildLookup[resource]) @@ -247,6 +233,21 @@ await _notificationService.PublishUpdateAsync(child, s => s with } } + private async Task SetExecutableChildResourceAsync(IResource resource) + { + // the parent name needs to be an instance name, not the resource name. + // parent the children under the first resource instance. + var parentName = resource.GetResolvedResourceNames()[0]; + + foreach (var child in _parentChildLookup[resource]) + { + await _notificationService.PublishUpdateAsync(child, s => s with + { + Properties = s.Properties.SetResourceProperty(KnownProperties.Resource.ParentName, parentName) + }).ConfigureAwait(false); + } + } + private async Task PublishResourcesWithInitialStateAsync() { // Publish the initial state of the resources that have a snapshot annotation. @@ -286,7 +287,8 @@ private async Task PublishConnectionStringAvailableEvent(IResource resource, Can // we need to dispatch the event for the children. if (_parentChildLookup[resource] is { } children) { - foreach (var child in children.OfType()) + // only dispatch the event for children that have a connection string and are IResourceWithParent, not parented by annotations. + foreach (var child in children.OfType().Where(c => c is IResourceWithParent)) { var childConnectionStringAvailableEvent = new ConnectionStringAvailableEvent(child, _serviceProvider); await _eventing.PublishAsync(childConnectionStringAvailableEvent, cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/Orchestrator/RelationshipEvaluator.cs b/src/Aspire.Hosting/Orchestrator/RelationshipEvaluator.cs new file mode 100644 index 0000000000..44f0e17081 --- /dev/null +++ b/src/Aspire.Hosting/Orchestrator/RelationshipEvaluator.cs @@ -0,0 +1,98 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data; +using System.Diagnostics.CodeAnalysis; +using Aspire.Dashboard.Model; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Orchestrator; + +internal static class RelationshipEvaluator +{ + public static ILookup GetParentChildLookup(DistributedApplicationModel model) + { + static IResource? SelectParentContainerResource(IResource resource) => resource switch + { + IResourceWithParent rp => SelectParentContainerResource(rp.Parent), + IResource r when r.IsContainer() => r, + _ => null + }; + + // parent -> children lookup + // Built from IResourceWithParent first, then from annotations. + return model.Resources.OfType() + .Select(x => (Child: (IResource)x, Root: SelectParentContainerResource(x.Parent))) + .Where(x => x.Root is not null) + .Concat(GetParentChildRelationshipsFromAnnotations(model)) + .ToLookup(x => x.Root!, x => x.Child); + } + + private static IEnumerable<(IResource Child, IResource? Root)> GetParentChildRelationshipsFromAnnotations(DistributedApplicationModel model) + { + static bool TryGetParent(IResource resource, [NotNullWhen(true)] out IResource? parent) + { + if (resource.TryGetAnnotationsOfType(out var relations) && + relations.LastOrDefault(r => r.Type == KnownRelationshipTypes.Parent) is { } parentRelationship) + { + parent = parentRelationship.Resource; + return true; + } + + parent = default; + return false; + } + + static IResource? SelectParentResource(IResource? resource) => resource switch + { + IResource r when TryGetParent(r, out var parent) => parent, + _ => null + }; + + var result = model.Resources.Select(x => (Child: x, Parent: SelectParentResource(x))) + .Where(x => x.Parent is not null) + .ToArray(); + + ValidateRelationships(result!); + + static IResource? SelectRootResource(IResource? resource) => resource switch + { + IResource r when TryGetParent(r, out var parent) => SelectRootResource(parent) ?? parent, + _ => null + }; + + // translate the result to child -> root, which the dashboard expects + return result.Select(x => (x.Child, Root: SelectRootResource(x.Child))); + } + + private static void ValidateRelationships((IResource Child, IResource Parent)[] relationships) + { + if (relationships.Length == 0) + { + return; + } + + var childToParentLookup = relationships.ToDictionary(x => x.Child, x => x.Parent); + + // ensure no circular dependencies + var visited = new Stack(); + foreach (var relation in relationships) + { + ValidateNoCircularDependencies(childToParentLookup, relation.Child, visited); + } + + static void ValidateNoCircularDependencies(Dictionary childToParentLookup, IResource child, Stack visited) + { + visited.Push(child); + if (childToParentLookup.TryGetValue(child, out var parent)) + { + if (visited.Contains(parent)) + { + throw new InvalidOperationException($"Circular dependency detected: {string.Join(" -> ", visited)} -> {parent}"); + } + ValidateNoCircularDependencies(childToParentLookup, parent, visited); + } + visited.Pop(); + } + } +} diff --git a/src/Aspire.Hosting/PublicAPI.Unshipped.txt b/src/Aspire.Hosting/PublicAPI.Unshipped.txt index ba092a4e14..4a3d7d4538 100644 --- a/src/Aspire.Hosting/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Unshipped.txt @@ -276,6 +276,7 @@ static Aspire.Hosting.ResourceBuilderExtensions.WithCommand(this Aspire.Hosti static Aspire.Hosting.ResourceBuilderExtensions.WithHealthCheck(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! key) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ResourceBuilderExtensions.WithHttpHealthCheck(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string? path = null, int? statusCode = null, string? endpointName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ResourceBuilderExtensions.WithHttpsHealthCheck(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string? path = null, int? statusCode = null, string? endpointName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.ResourceBuilderExtensions.WithParentRelationship(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResource! parent) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ResourceBuilderExtensions.WithRelationship(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResource! resource, string! type) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.Utils.VolumeNameGenerator.Generate(Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! suffix) -> string! static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Exited -> string! diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 3864ec18cd..800b474815 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -1064,4 +1064,34 @@ public static IResourceBuilder WithRelationship( return builder.WithAnnotation(new ResourceRelationshipAnnotation(resource, type)); } + + /// + /// Adds a to the resource annotations to add a parent-child relationship. + /// + /// The type of the resource. + /// The resource builder. + /// The parent of . + /// A resource builder. + /// + /// + /// The WithParentRelationship method is used to add parent 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 backend = builder.AddProject<Projects.Backend>("backend"); + /// + /// var frontend = builder.AddProject<Projects.Manager>("frontend") + /// .WithParentRelationship(backend.Resource); + /// + /// + public static IResourceBuilder WithParentRelationship( + this IResourceBuilder builder, + IResource parent) where T : IResource + { + return builder.WithRelationship(parent, KnownRelationshipTypes.Parent); + } } diff --git a/src/Shared/CustomResourceSnapshotExtensions.cs b/src/Shared/CustomResourceSnapshotExtensions.cs index e060961f67..f61cf2db38 100644 --- a/src/Shared/CustomResourceSnapshotExtensions.cs +++ b/src/Shared/CustomResourceSnapshotExtensions.cs @@ -30,4 +30,37 @@ internal static ImmutableArray SetResourceProperty(thi // Add property. return [.. properties, new ResourcePropertySnapshot(name, value)]; } + + internal static ImmutableArray SetResourcePropertyRange(this ImmutableArray properties, IEnumerable newValues) + { + var existingProperties = new List(properties); + var propertiesToAdd = new List(); + + foreach (var newValue in newValues) + { + var found = false; + for (var i = 0; i < existingProperties.Count; i++) + { + var existingProperty = existingProperties[i]; + + if (string.Equals(existingProperty.Name, newValue.Name, StringComparisons.ResourcePropertyName)) + { + if (existingProperty.Value != newValue.Value) + { + existingProperties[i] = existingProperty with { Value = newValue.Value }; + } + + found = true; + break; + } + } + + if (!found) + { + propertiesToAdd.Add(newValue); + } + } + + return [.. existingProperties, .. propertiesToAdd]; + } } diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs index 36efbcf63a..2cfc3708ef 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs @@ -7,6 +7,7 @@ using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Orchestrator; using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -61,6 +62,193 @@ public async Task ParentPropertySetOnChildResource() Assert.Equal(parentResourceId, childParentResourceId); } + [Fact] + public async Task WithParentRelationshipSetsParentPropertyCorrectly() + { + var builder = DistributedApplication.CreateBuilder(); + + var parent = builder.AddContainer("parent", "image"); + var child = builder.AddContainer("child", "image").WithParentRelationship(parent.Resource); + var child2 = builder.AddContainer("child2", "image").WithParentRelationship(parent.Resource); + + var nestedChild = builder.AddContainer("nested-child", "image").WithParentRelationship(child.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; + string? nestedChildParentResourceId = 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 == nestedChild.Resource) + { + nestedChildParentResourceId = 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 && nestedChildParentResourceId != null && child2ParentResourceId != null) + { + return; + } + } + }); + + await events.PublishAsync(new OnResourceStartingContext(CancellationToken.None, KnownResourceTypes.Container, parent.Resource, parent.Resource.Name)); + + await watchResourceTask.DefaultTimeout(); + + Assert.Equal(parentResourceId, childParentResourceId); + Assert.Equal(parentResourceId, child2ParentResourceId); + + // Nested child should have parent set to the root parent, not direct parent + Assert.Equal(parentResourceId, nestedChildParentResourceId); + } + + [Fact] + public async Task LastWithParentRelationshipWins() + { + var builder = DistributedApplication.CreateBuilder(); + + var firstParent = builder.AddContainer("firstParent", "image"); + var secondParent = builder.AddContainer("secondParent", "image"); + + var child = builder.AddContainer("child", "image"); + + child.WithParentRelationship(firstParent.Resource); + child.WithParentRelationship(secondParent.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? firstParentResourceId = null; + string? secondParentResourceId = null; + string? childParentResourceId = null; + var watchResourceTask = Task.Run(async () => + { + await foreach (var item in resourceNotificationService.WatchAsync()) + { + if (item.Resource == firstParent.Resource) + { + firstParentResourceId = item.ResourceId; + } + else if (item.Resource == secondParent.Resource) + { + secondParentResourceId = item.ResourceId; + } + else if (item.Resource == child.Resource) + { + childParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString(); + } + + if (firstParentResourceId != null && secondParentResourceId != null && childParentResourceId != null) + { + return; + } + } + }); + + await events.PublishAsync(new OnResourceStartingContext(CancellationToken.None, KnownResourceTypes.Container, firstParent.Resource, firstParent.Resource.Name)); + await events.PublishAsync(new OnResourceStartingContext(CancellationToken.None, KnownResourceTypes.Container, secondParent.Resource, secondParent.Resource.Name)); + + await watchResourceTask.DefaultTimeout(); + + // child should be parented to the last parent set + Assert.Equal(secondParentResourceId, childParentResourceId); + } + + [Fact] + public async Task WithParentRelationshipWorksWithProjects() + { + var builder = DistributedApplication.CreateBuilder(); + + var projectA = builder.AddProject("projecta"); + var projectB = builder.AddProject("projectb").WithParentRelationship(projectA.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? projectAResourceId = null; + string? projectBParentResourceId = null; + var watchResourceTask = Task.Run(async () => + { + await foreach (var item in resourceNotificationService.WatchAsync()) + { + if (item.Resource == projectA.Resource) + { + projectAResourceId = item.ResourceId; + } + else if (item.Resource == projectB.Resource) + { + projectBParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString(); + } + + if (projectAResourceId != null && projectBParentResourceId != null) + { + return; + } + } + }); + + await events.PublishAsync(new OnResourceStartingContext(CancellationToken.None, KnownResourceTypes.Container, projectA.Resource, projectA.Resource.Name)); + await events.PublishAsync(new OnResourceStartingContext(CancellationToken.None, KnownResourceTypes.Container, projectB.Resource, projectB.Resource.Name)); + + await watchResourceTask.DefaultTimeout(); + + Assert.Equal(projectAResourceId, projectBParentResourceId); + } + + [Fact] + public void DetectsCircularDependency() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var container1 = builder.AddContainer("container1", "image"); + var container2 = builder.AddContainer("container2", "image2"); + var container3 = builder.AddContainer("container3", "image3"); + + container1.WithParentRelationship(container2.Resource); + container2.WithParentRelationship(container3.Resource); + container3.WithParentRelationship(container1.Resource); + + using var app = builder.Build(); + + var e = Assert.Throws(() => app.Services.GetService()); + Assert.Contains("Circular dependency detected", e.Message); + } + private static ApplicationOrchestrator CreateOrchestrator( DistributedApplicationModel distributedAppModel, ResourceNotificationService notificationService, @@ -95,4 +283,17 @@ private sealed class CustomChildResource(string name, IResource parent) : Resour { public IResource Parent => parent; } + + private sealed class ProjectA : IProjectMetadata + { + public string ProjectPath => "projectA"; + + public LaunchSettings LaunchSettings { get; } = new(); + } + + private sealed class ProjectB : IProjectMetadata + { + public string ProjectPath => "projectB"; + public LaunchSettings LaunchSettings { get; } = new(); + } }