Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the ability to set parent relationships in the dashboard #7337

Merged
merged 5 commits into from
Feb 1, 2025
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 4 additions & 4 deletions playground/Redis/Redis.AppHost/Program.cs
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ public static IResourceBuilder<AzureEventHubsResource> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ public static class AzureFunctionsProjectResourceExtensions
}
else
{
storage = builder.AddAzureStorage(storageResourceName).RunAsEmulator().Resource;
storage = builder.AddAzureStorage(storageResourceName)
.WithParentRelationship(resource)
davidfowl marked this conversation as resolved.
Show resolved Hide resolved
.RunAsEmulator().Resource;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,8 @@ public static IResourceBuilder<AzureServiceBusResource> 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) =>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IResourceWithParent>().ToLookup(r => r.Parent);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Hosting.MongoDB/MongoDBBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ public static IResourceBuilder<T> WithMongoExpress<T>(this IResourceBuilder<T> b
.WithImageRegistry(MongoDBContainerImageTags.MongoExpressRegistry)
.WithEnvironment(context => ConfigureMongoExpressContainer(context, builder.Resource))
.WithHttpEndpoint(targetPort: 8081, name: "http")
.WithParentRelationship(builder.Resource)
.ExcludeFromManifest();

configureContainer?.Invoke(resourceBuilder);
Expand Down
12 changes: 6 additions & 6 deletions src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
40 changes: 21 additions & 19 deletions src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ internal sealed class ApplicationOrchestrator
{
private readonly IDcpExecutor _dcpExecutor;
private readonly DistributedApplicationModel _model;
private readonly ILookup<IResource?, IResourceWithParent> _parentChildLookup;
private readonly ILookup<IResource, IResource> _parentChildLookup;
private readonly IDistributedApplicationLifecycleHook[] _lifecycleHooks;
private readonly ResourceNotificationService _notificationService;
private readonly IDistributedApplicationEventing _eventing;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -217,22 +219,6 @@ public async Task StopResourceAsync(string resourceName, CancellationToken cance
await _dcpExecutor.StopResourceAsync(resourceReference, cancellationToken).ConfigureAwait(false);
}

private static ILookup<IResource?, IResourceWithParent> 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<IResourceWithParent>()
.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])
Expand All @@ -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.
Expand Down Expand Up @@ -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<IResourceWithConnectionString>())
// only dispatch the event for children that have a connection string and are IResourceWithParent, not parented by annotations.
foreach (var child in children.OfType<IResourceWithConnectionString>().Where(c => c is IResourceWithParent))
{
var childConnectionStringAvailableEvent = new ConnectionStringAvailableEvent(child, _serviceProvider);
await _eventing.PublishAsync(childConnectionStringAvailableEvent, cancellationToken).ConfigureAwait(false);
Expand Down
98 changes: 98 additions & 0 deletions src/Aspire.Hosting/Orchestrator/RelationshipEvaluator.cs
Original file line number Diff line number Diff line change
@@ -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<IResource, IResource> 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<IResourceWithParent>()
.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<ResourceRelationshipAnnotation>(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<IResource>();
foreach (var relation in relationships)
{
ValidateNoCircularDependencies(childToParentLookup, relation.Child, visited);
}

static void ValidateNoCircularDependencies(Dictionary<IResource, IResource> childToParentLookup, IResource child, Stack<IResource> 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();
}
}
}
1 change: 1 addition & 0 deletions src/Aspire.Hosting/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ static Aspire.Hosting.ResourceBuilderExtensions.WithCommand<T>(this Aspire.Hosti
static Aspire.Hosting.ResourceBuilderExtensions.WithHealthCheck<T>(this Aspire.Hosting.ApplicationModel.IResourceBuilder<T>! builder, string! key) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<T>!
static Aspire.Hosting.ResourceBuilderExtensions.WithHttpHealthCheck<T>(this Aspire.Hosting.ApplicationModel.IResourceBuilder<T>! builder, string? path = null, int? statusCode = null, string? endpointName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<T>!
static Aspire.Hosting.ResourceBuilderExtensions.WithHttpsHealthCheck<T>(this Aspire.Hosting.ApplicationModel.IResourceBuilder<T>! builder, string? path = null, int? statusCode = null, string? endpointName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<T>!
static Aspire.Hosting.ResourceBuilderExtensions.WithParentRelationship<T>(this Aspire.Hosting.ApplicationModel.IResourceBuilder<T>! builder, Aspire.Hosting.ApplicationModel.IResource! parent) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<T>!
static Aspire.Hosting.ResourceBuilderExtensions.WithRelationship<T>(this Aspire.Hosting.ApplicationModel.IResourceBuilder<T>! builder, Aspire.Hosting.ApplicationModel.IResource! resource, string! type) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<T>!
static Aspire.Hosting.Utils.VolumeNameGenerator.Generate<T>(Aspire.Hosting.ApplicationModel.IResourceBuilder<T>! builder, string! suffix) -> string!
static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Exited -> string!
Expand Down
30 changes: 30 additions & 0 deletions src/Aspire.Hosting/ResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1064,4 +1064,34 @@ public static IResourceBuilder<T> WithRelationship<T>(

return builder.WithAnnotation(new ResourceRelationshipAnnotation(resource, type));
}

/// <summary>
/// Adds a <see cref="ResourceRelationshipAnnotation"/> to the resource annotations to add a parent-child relationship.
/// </summary>
/// <typeparam name="T">The type of the resource.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="parent">The parent of <paramref name="builder"/>.</param>
/// <returns>A resource builder.</returns>
/// <remarks>
/// <para>
/// The <c>WithParentRelationship</c> method is used to add parent relationships to the resource. Relationships are used to link
/// resources together in UI.
/// </para>
/// </remarks>
/// <example>
/// This example shows adding a relationship between two resources.
/// <code lang="C#">
/// var builder = DistributedApplication.CreateBuilder(args);
/// var backend = builder.AddProject&lt;Projects.Backend&gt;("backend");
///
/// var frontend = builder.AddProject&lt;Projects.Manager&gt;("frontend")
/// .WithParentRelationship(backend.Resource);
/// </code>
/// </example>
public static IResourceBuilder<T> WithParentRelationship<T>(
this IResourceBuilder<T> builder,
IResource parent) where T : IResource
{
return builder.WithRelationship(parent, KnownRelationshipTypes.Parent);
}
}
33 changes: 33 additions & 0 deletions src/Shared/CustomResourceSnapshotExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,37 @@ internal static ImmutableArray<ResourcePropertySnapshot> SetResourceProperty(thi
// Add property.
return [.. properties, new ResourcePropertySnapshot(name, value)];
}

internal static ImmutableArray<ResourcePropertySnapshot> SetResourcePropertyRange(this ImmutableArray<ResourcePropertySnapshot> properties, IEnumerable<ResourcePropertySnapshot> newValues)
{
var existingProperties = new List<ResourcePropertySnapshot>(properties);
var propertiesToAdd = new List<ResourcePropertySnapshot>();

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];
}
}
Loading
Loading