// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREPIPELINES002
using Aspire.Dashboard.Model;
using Aspire.Hosting.Dashboard;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Orchestrator;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace Aspire.Hosting.Tests.Orchestrator;
[Trait("Partition", "3")]
public class ApplicationOrchestratorTests(ITestOutputHelper testOutputHelper)
{
[Fact]
public async Task ParentPropertySetOnChildResource()
{
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var parentResource = builder.AddContainer("database", "image");
var childResource = builder.AddResource(new CustomChildResource("child", parentResource.Resource));
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
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;
var watchResourceTask = Task.Run(async () =>
{
await foreach (var item in resourceNotificationService.WatchAsync())
{
if (item.Resource == parentResource.Resource)
{
parentResourceId = item.ResourceId;
}
else if (item.Resource == childResource.Resource)
{
childParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
}
if (parentResourceId != null && childParentResourceId != null)
{
return;
}
}
});
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
await watchResourceTask.DefaultTimeout();
Assert.Equal(parentResourceId, childParentResourceId);
}
[Fact]
public async Task ParentAnnotationOnChildResource()
{
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var parentResource = builder.AddResource(new CustomResource("parent"));
var childResource = builder.AddResource(new CustomResource("child"))
.WithParentRelationship(parentResource);
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
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;
var watchResourceTask = Task.Run(async () =>
{
await foreach (var item in resourceNotificationService.WatchAsync())
{
if (item.Resource == parentResource.Resource)
{
parentResourceId = item.ResourceId;
}
else if (item.Resource == childResource.Resource)
{
childParentResourceId = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ParentName)?.Value?.ToString();
}
if (parentResourceId != null && childParentResourceId != null)
{
return;
}
}
});
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
await watchResourceTask.DefaultTimeout();
Assert.Equal(parentResourceId, childParentResourceId);
}
[Fact]
public async Task InitializeResourceEventPublished()
{
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var resource = builder.AddResource(new CustomResource("resource"));
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var applicationEventing = builder.Eventing;
var initResourceTcs = new TaskCompletionSource();
InitializeResourceEvent? initEvent = null;
resource.OnInitializeResource((_, @event, _) =>
{
initEvent = @event;
initResourceTcs.SetResult();
return Task.CompletedTask;
});
applicationEventing.Subscribe<InitializeResourceEvent>(resource.Resource, (@event, ct) =>
{
initEvent = @event;
initResourceTcs.SetResult();
return Task.CompletedTask;
});
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: applicationEventing);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
await initResourceTcs.Task; //.DefaultTimeout();
Assert.True(initResourceTcs.Task.IsCompletedSuccessfully);
Assert.NotNull(initEvent);
Assert.NotNull(initEvent.Logger);
Assert.NotNull(initEvent.Services);
Assert.Equal(resource.Resource, initEvent.Resource);
Assert.Equal(resourceNotificationService, initEvent.Notifications);
Assert.Equal(applicationEventing, initEvent.Eventing);
}
[Fact]
public async Task WithParentRelationshipSetsParentPropertyCorrectly()
{
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var parent = builder.AddContainer("parent", "image");
var child = builder.AddContainer("child", "image").WithParentRelationship(parent);
var child2 = builder.AddContainer("child2", "image").WithParentRelationship(parent);
var nestedChild = builder.AddContainer("nested-child", "image").WithParentRelationship(child);
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
await appOrchestrator.RunApplicationAsync();
string? parentResourceId = null;
string? childResourceId = 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)
{
childResourceId = item.ResourceId;
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 OnResourcesPreparedContext(CancellationToken.None));
await watchResourceTask.DefaultTimeout();
Assert.Equal(parentResourceId, childParentResourceId);
Assert.Equal(parentResourceId, child2ParentResourceId);
// Nested child should be parented on the direct parent
Assert.Equal(childResourceId, nestedChildParentResourceId);
}
[Fact]
public async Task LastWithParentRelationshipWins()
{
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var firstParent = builder.AddContainer("firstParent", "image");
var secondParent = builder.AddContainer("secondParent", "image");
var child = builder.AddContainer("child", "image");
child.WithParentRelationship(firstParent);
child.WithParentRelationship(secondParent);
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
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 OnResourcesPreparedContext(CancellationToken.None));
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();
builder.WithTestAndResourceLogging(testOutputHelper);
var projectA = builder.AddProject<ProjectA>("projecta");
var projectB = builder.AddProject<ProjectB>("projectb").WithParentRelationship(projectA);
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
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 OnResourcesPreparedContext(CancellationToken.None));
await watchResourceTask.DefaultTimeout();
Assert.Equal(projectAResourceId, projectBParentResourceId);
}
[Fact]
public void DetectsCircularDependency()
{
using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
var container1 = builder.AddContainer("container1", "image");
var container2 = builder.AddContainer("container2", "image2");
var container3 = builder.AddContainer("container3", "image3");
container1.WithParentRelationship(container2);
container2.WithParentRelationship(container3);
container3.WithParentRelationship(container1);
using var app = builder.Build();
var e = Assert.Throws<InvalidOperationException>(() => app.Services.GetService<ApplicationOrchestrator>());
Assert.Contains("Circular dependency detected", e.Message);
}
[Fact]
public async Task GrandChildResourceWithConnectionString()
{
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var parentResource = builder.AddResource(new ParentResourceWithConnectionString("parent"));
var childResource = builder.AddResource(
new ChildResourceWithConnectionString("child", new Dictionary<string, string> { { "Namespace", "ns" } }, parentResource.Resource)
);
var grandChildResource = builder.AddResource(
new ChildResourceWithConnectionString("grand-child", new Dictionary<string, string> { { "Database", "db" } }, childResource.Resource)
);
await using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var applicationEventing = new DistributedApplicationEventing();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: applicationEventing);
await appOrchestrator.RunApplicationAsync();
bool parentConnectionStringAvailable = false;
bool childConnectionStringAvailable = false;
bool grandChildConnectionStringAvailable = false;
applicationEventing.Subscribe<ConnectionStringAvailableEvent>(parentResource.Resource, (_, _) =>
{
parentConnectionStringAvailable = true;
return Task.CompletedTask;
});
applicationEventing.Subscribe<ConnectionStringAvailableEvent>(childResource.Resource, (_, _) =>
{
childConnectionStringAvailable = true;
return Task.CompletedTask;
});
applicationEventing.Subscribe<ConnectionStringAvailableEvent>(grandChildResource.Resource, (_, _) =>
{
grandChildConnectionStringAvailable = true;
return Task.CompletedTask;
});
await events.PublishAsync(new OnConnectionStringAvailableContext(CancellationToken.None, parentResource.Resource));
Assert.True(parentConnectionStringAvailable);
Assert.True(childConnectionStringAvailable);
Assert.True(grandChildConnectionStringAvailable);
}
[Fact]
public async Task ConnectionStringAvailableEventPublishesBeforeBeforeResourceStartedEvent()
{
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var resource = builder.AddResource(new TestResourceWithConnectionString("test-resource", "Server=localhost:5432;Database=testdb"));
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var applicationEventing = new DistributedApplicationEventing();
var observedEvents = new List<string>();
applicationEventing.Subscribe<ConnectionStringAvailableEvent>(resource.Resource, (_, _) =>
{
observedEvents.Add(nameof(ConnectionStringAvailableEvent));
return Task.CompletedTask;
});
applicationEventing.Subscribe<BeforeResourceStartedEvent>(resource.Resource, (_, _) =>
{
observedEvents.Add(nameof(BeforeResourceStartedEvent));
return Task.CompletedTask;
});
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: applicationEventing);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnConnectionStringAvailableContext(CancellationToken.None, resource.Resource));
await events.PublishAsync(new OnResourceStartingContext(CancellationToken.None, KnownResourceTypes.Executable, resource.Resource, "test-resource-dcp"));
Assert.Collection(
observedEvents,
eventName => Assert.Equal(nameof(ConnectionStringAvailableEvent), eventName),
eventName => Assert.Equal(nameof(BeforeResourceStartedEvent), eventName));
}
[Fact]
public async Task ConnectionStringAvailableEventPublishesConnectionStringAndResolvableProperties()
{
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var unresolvedProperty = ReferenceExpression.Create($"{new ThrowingValueProvider()}");
var resource = builder.AddResource(new TestResourceWithConnectionString("test-resource", "Server=localhost:5432;Database=testdb", "testdb", "localhost", unresolvedProperty));
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var applicationEventing = new DistributedApplicationEventing();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: applicationEventing);
await appOrchestrator.RunApplicationAsync();
string? connectionStringProperty = null;
IReadOnlyDictionary<string, string?>? connectionProperties = null;
bool? isConnectionStringSensitive = null;
bool? areConnectionPropertiesSensitive = null;
var hasDatabaseNameProperty = false;
var watchResourceTask = Task.Run(async () =>
{
await foreach (var item in resourceNotificationService.WatchAsync())
{
if (item.Resource == resource.Resource)
{
var connectionStringProp = item.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.ConnectionString);
if (connectionStringProp is not null)
{
connectionStringProperty = connectionStringProp.Value?.ToString();
isConnectionStringSensitive = connectionStringProp.IsSensitive;
var connectionPropertiesProp = item.Snapshot.Properties.Single(p => p.Name == KnownProperties.Resource.ConnectionProperties);
connectionProperties = Assert.IsAssignableFrom<IReadOnlyDictionary<string, string?>>(connectionPropertiesProp.Value);
areConnectionPropertiesSensitive = connectionPropertiesProp.IsSensitive;
hasDatabaseNameProperty = item.Snapshot.Properties.Any(p => p.Name == "resource.DatabaseName");
return;
}
}
}
});
// Publish the ConnectionStringAvailableEvent to trigger the update
await applicationEventing.PublishAsync(new ConnectionStringAvailableEvent(resource.Resource, app.Services), CancellationToken.None);
await watchResourceTask.DefaultTimeout();
Assert.Equal("Server=localhost:5432;Database=testdb", connectionStringProperty);
Assert.True(isConnectionStringSensitive);
Assert.Equal(
new Dictionary<string, string?>
{
["DatabaseName"] = "testdb",
["Host"] = "localhost"
},
connectionProperties);
Assert.True(areConnectionPropertiesSensitive);
Assert.False(hasDatabaseNameProperty);
}
[Fact]
public async Task OnResourceFailedToStart_WithErrorMessage_SetsErrorStyleOnState()
{
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var container = builder.AddContainer("api", "test-image");
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnResourceFailedToStartContext(
CancellationToken.None,
KnownResourceTypes.Container,
container.Resource,
"api-dcp",
ErrorMessage: "The endpoint `https` is not defined for the resource `api`. Available endpoints: `http`."));
Assert.True(resourceNotificationService.TryGetCurrentState("api-dcp", out var snapshotEvent));
Assert.Equal(KnownResourceStates.FailedToStart, snapshotEvent.Snapshot.State?.Text);
Assert.Equal(KnownResourceStateStyles.Error, snapshotEvent.Snapshot.State?.Style);
}
[Fact]
public async Task OnResourceFailedToStart_WithoutErrorMessage_DoesNotSetErrorStyle()
{
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var container = builder.AddContainer("api", "test-image");
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnResourceFailedToStartContext(
CancellationToken.None,
KnownResourceTypes.Container,
container.Resource,
"api-dcp"));
Assert.True(resourceNotificationService.TryGetCurrentState("api-dcp", out var snapshotEvent));
Assert.Equal(KnownResourceStates.FailedToStart, snapshotEvent.Snapshot.State?.Text);
Assert.Null(snapshotEvent.Snapshot.State?.Style);
}
[Fact]
public async Task OnResourceStarting_ToolResourceType_TransitionsToStarting()
{
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var resource = builder.AddResource(new CustomResource("tool"));
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnResourceStartingContext(CancellationToken.None, KnownResourceTypes.Tool, resource.Resource, "tool-dcp"));
Assert.True(resourceNotificationService.TryGetCurrentState("tool-dcp", out var snapshotEvent));
Assert.Equal(KnownResourceStates.Starting, snapshotEvent.Snapshot.State?.Text);
}
[Fact]
public async Task SelfDrivenResourceWaitsForDependencyBeforeStarting()
{
// Custom resources that drive their own startup publish BeforeResourceStartedEvent from
// OnInitializeResource, so they are still in the initial "NotStarted" state when the wait is
// published rather than having been moved to "Starting" by the orchestrator first.
// https://github.com/microsoft/aspire/issues/17453
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var blockerReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var blocker = AddSelfDrivenResource(builder, "blocker", blockerReleased.Task);
var waiter = AddSelfDrivenResource(builder, "waiter").WaitFor(blocker);
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Waiting, TestContext.Current.CancellationToken).DefaultTimeout();
// The blocker is still inside its initialize callback, so the waiter must not have started.
Assert.True(resourceNotificationService.TryGetCurrentState(waiter.Resource.Name, out var waitingEvent));
Assert.Equal(KnownResourceStates.Waiting, waitingEvent.Snapshot.State?.Text);
blockerReleased.SetResult();
await resourceNotificationService.WaitForResourceAsync(blocker.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
}
[Fact]
public async Task ResourceWaitingOnSelfDrivenResourceWaitsForItToStart()
{
// The reciprocal of SelfDrivenResourceWaitsForDependencyBeforeStarting: the dependency is the resource
// that drives its own startup, while the waiter is a regular DCP-managed resource that the orchestrator
// starts through OnResourceStarting. https://github.com/microsoft/aspire/issues/17453
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var dependencyReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var dependency = AddSelfDrivenResource(builder, "dependency", dependencyReleased.Task);
var waiter = builder.AddResource(new CustomResourceWithWaitSupport("waiter")).WaitFor(dependency);
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
// Stand in for DCP starting the resource. This does not complete until the wait for dependencies is over,
// so it cannot be awaited here. The resource name is used as the DCP name so that the per-instance update
// published by OnResourceStarting and the model-level waiting update address the same snapshot.
var startingTask = events.PublishAsync(new OnResourceStartingContext(
CancellationToken.None, KnownResourceTypes.Executable, waiter.Resource, waiter.Resource.Name));
await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Waiting, TestContext.Current.CancellationToken).DefaultTimeout();
Assert.True(resourceNotificationService.TryGetCurrentState(dependency.Resource.Name, out var dependencyEvent));
Assert.NotEqual(KnownResourceStates.Running, dependencyEvent.Snapshot.State?.Text);
dependencyReleased.SetResult();
await resourceNotificationService.WaitForResourceAsync(dependency.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
await startingTask.DefaultTimeout();
}
[Fact]
public async Task WaitIsNotReleasedByAnotherReplicaLeavingWaitingState()
{
// The wait is released when the resource is forced out of "Waiting" by the start command, but
// WaitForResourceAsync only filters on the model resource name, so the release signal has to be scoped
// to the replica that was forced out. Here the model-level waiting update also re-publishes the running
// sibling replica, and that must not be mistaken for a force-start of the waiting replica.
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var dependencyReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var dependency = AddSelfDrivenResource(builder, "dependency", dependencyReleased.Task);
var waiter = builder.AddResource(new CustomResourceWithWaitSupport("waiter")).WaitFor(dependency);
waiter.Resource.Annotations.Add(new DcpInstancesAnnotation([
new DcpInstance("waiter-abc123", "abc123", 0),
new DcpInstance("waiter-def456", "def456", 1)
]));
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
// The second replica is already running and is not part of this start attempt.
await resourceNotificationService.PublishUpdateAsync(waiter.Resource, "waiter-def456", s => s with
{
State = KnownResourceStates.Running
}).DefaultTimeout();
var startingTask = events.PublishAsync(new OnResourceStartingContext(
CancellationToken.None, KnownResourceTypes.Executable, waiter.Resource, "waiter-abc123"));
await resourceNotificationService.WaitForResourceAsync(
waiter.Resource.Name,
e => e.ResourceId == "waiter-abc123" && e.Snapshot.State?.Text == KnownResourceStates.Waiting,
TestContext.Current.CancellationToken).DefaultTimeout();
// The rebuild command moves non-waiting replicas to "Building" while deliberately leaving waiting
// replicas alone, so this is a state change a waiting replica really does observe from a sibling.
await resourceNotificationService.PublishUpdateAsync(waiter.Resource, "waiter-def456", s => s with
{
State = KnownResourceStates.Building
}).DefaultTimeout();
// Negative assertion: give the wait a chance to be released incorrectly. This can only ever produce a
// false pass, never a false failure - with a model-wide release flag the wait is abandoned immediately.
await Task.Delay(TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken);
Assert.False(startingTask.IsCompleted, "The dependency wait must not be released by a sibling replica leaving the waiting state.");
dependencyReleased.SetResult();
await startingTask.DefaultTimeout();
}
[Fact]
public async Task WaitingUpdateDoesNotDisturbNotStartedReplicas()
{
// A replicated resource that uses WithExplicitStart is started one replica at a time, so only the replica
// named in the start request is moved to "Starting". The waiting update, however, is published as a
// model-level update that reaches every replica, so a sibling that is still "NotStarted" - deliberately not
// started - must be left alone.
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var dependencyReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var dependency = AddSelfDrivenResource(builder, "dependency", dependencyReleased.Task);
var waiter = builder.AddResource(new CustomResourceWithWaitSupport("waiter")).WaitFor(dependency).WithExplicitStart();
waiter.Resource.Annotations.Add(new DcpInstancesAnnotation([
new DcpInstance("waiter-abc123", "abc123", 0),
new DcpInstance("waiter-def456", "def456", 1)
]));
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
// Both replicas of an explicit start resource report "NotStarted" until someone starts them.
await PublishReplicaStatesAsync(resourceNotificationService, waiter.Resource, KnownResourceStates.NotStarted);
// Only the first replica is being started.
var startingTask = events.PublishAsync(new OnResourceStartingContext(
CancellationToken.None, KnownResourceTypes.Executable, waiter.Resource, "waiter-abc123"));
await resourceNotificationService.WaitForResourceAsync(
waiter.Resource.Name,
e => e.ResourceId == "waiter-abc123" && e.Snapshot.State?.Text == KnownResourceStates.Waiting,
TestContext.Current.CancellationToken).DefaultTimeout();
Assert.True(resourceNotificationService.TryGetCurrentState("waiter-def456", out var siblingEvent));
Assert.Equal(KnownResourceStates.NotStarted, siblingEvent.Snapshot.State?.Text);
dependencyReleased.SetResult();
await startingTask.DefaultTimeout();
}
[Fact]
public async Task StartingNotStartedReplicaIsForwardedToDcpWhileSiblingWaits()
{
// The consequence of WaitingUpdateDoesNotDisturbNotStartedReplicas: StartResourceAsync reads "Waiting" as
// proof that startup is already in flight and only updates the snapshot, skipping DCP. If the waiting update
// relabelled the untouched replica, its start command would silently do nothing - the dashboard would show
// "Starting" while no process was ever launched.
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var dependencyReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var dependency = AddSelfDrivenResource(builder, "dependency", dependencyReleased.Task);
var waiter = builder.AddResource(new CustomResourceWithWaitSupport("waiter")).WaitFor(dependency).WithExplicitStart();
waiter.Resource.Annotations.Add(new DcpInstancesAnnotation([
new DcpInstance("waiter-abc123", "abc123", 0),
new DcpInstance("waiter-def456", "def456", 1)
]));
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var dcpExecutor = new TestDcpExecutor();
dcpExecutor.AddResource(waiter.Resource, "waiter-abc123");
dcpExecutor.AddResource(waiter.Resource, "waiter-def456");
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing, dcpExecutor: dcpExecutor);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
await PublishReplicaStatesAsync(resourceNotificationService, waiter.Resource, KnownResourceStates.NotStarted);
var startingTask = events.PublishAsync(new OnResourceStartingContext(
CancellationToken.None, KnownResourceTypes.Executable, waiter.Resource, "waiter-abc123"));
await resourceNotificationService.WaitForResourceAsync(
waiter.Resource.Name,
e => e.ResourceId == "waiter-abc123" && e.Snapshot.State?.Text == KnownResourceStates.Waiting,
TestContext.Current.CancellationToken).DefaultTimeout();
// Stand in for the user invoking the start command on the replica that was never started.
await appOrchestrator.StartResourceAsync("waiter-def456", TestContext.Current.CancellationToken).DefaultTimeout();
Assert.Equal(["waiter-def456"], dcpExecutor.StartedResources);
dependencyReleased.SetResult();
await startingTask.DefaultTimeout();
}
[Fact]
public async Task ForceStartingOneReplicaDoesNotReleaseAnotherReplicasWait()
{
// Each individually started replica gets its own BeforeResourceStartedEvent, but WaitForResourceAsync
// filters only on the model resource name, so every one of those waits is fed events for every replica.
// Forcing replica B out of "Waiting" with the start command must not release replica A's wait - the user
// only asked for B, and A would otherwise start with unmet dependencies.
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var dependencyReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var dependency = AddSelfDrivenResource(builder, "dependency", dependencyReleased.Task);
var waiter = builder.AddResource(new CustomResourceWithWaitSupport("waiter")).WaitFor(dependency);
waiter.Resource.Annotations.Add(new DcpInstancesAnnotation([
new DcpInstance("waiter-abc123", "abc123", 0),
new DcpInstance("waiter-def456", "def456", 1)
]));
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var dcpExecutor = new TestDcpExecutor();
dcpExecutor.AddResource(waiter.Resource, "waiter-abc123");
dcpExecutor.AddResource(waiter.Resource, "waiter-def456");
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing, dcpExecutor: dcpExecutor);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
await PublishReplicaStatesAsync(resourceNotificationService, waiter.Resource, KnownResourceStates.NotStarted);
// Both replicas are started, so both end up waiting on the same dependency.
var startingFirstReplicaTask = events.PublishAsync(new OnResourceStartingContext(
CancellationToken.None, KnownResourceTypes.Executable, waiter.Resource, "waiter-abc123"));
await resourceNotificationService.WaitForResourceAsync(
waiter.Resource.Name,
e => e.ResourceId == "waiter-abc123" && e.Snapshot.State?.Text == KnownResourceStates.Waiting,
TestContext.Current.CancellationToken).DefaultTimeout();
var startingSecondReplicaTask = events.PublishAsync(new OnResourceStartingContext(
CancellationToken.None, KnownResourceTypes.Executable, waiter.Resource, "waiter-def456"));
await resourceNotificationService.WaitForResourceAsync(
waiter.Resource.Name,
e => e.ResourceId == "waiter-def456" && e.Snapshot.State?.Text == KnownResourceStates.Waiting,
TestContext.Current.CancellationToken).DefaultTimeout();
// Stand in for the user invoking the start command on the second replica to force it past the wait.
await appOrchestrator.StartResourceAsync("waiter-def456", TestContext.Current.CancellationToken).DefaultTimeout();
// The replica was already waiting, so the start was served by moving it to "Starting" rather than by DCP.
Assert.Empty(dcpExecutor.StartedResources);
await startingSecondReplicaTask.DefaultTimeout();
// Negative assertion: give the first replica's wait a chance to be released incorrectly. This can only
// ever produce a false pass, never a false failure - with a model-wide release flag the wait is abandoned
// as soon as the second replica leaves "Waiting".
await Task.Delay(TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken);
Assert.False(startingFirstReplicaTask.IsCompleted, "Force-starting one replica must not release another replica's dependency wait.");
dependencyReleased.SetResult();
await startingFirstReplicaTask.DefaultTimeout();
}
[Fact]
public async Task ResourceForcedOutOfWaitingStopsWaitingForItsDependency()
{
// The Start command forces a waiting resource to start by moving it from "Waiting" to "Starting"
// (see ApplicationOrchestrator.StartResourceAsync). BeforeResourceStartedEvent has to stop waiting
// when that happens, even though the dependency is still not ready.
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var blockerReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var blocker = AddSelfDrivenResource(builder, "blocker", blockerReleased.Task);
var waiter = AddSelfDrivenResource(builder, "waiter").WaitFor(blocker);
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Waiting, TestContext.Current.CancellationToken).DefaultTimeout();
// Stand in for the Start command.
await resourceNotificationService.PublishUpdateAsync(waiter.Resource, s => s with { State = KnownResourceStates.Starting }).DefaultTimeout();
await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
Assert.True(resourceNotificationService.TryGetCurrentState(blocker.Resource.Name, out var blockerEvent));
Assert.NotEqual(KnownResourceStates.Running, blockerEvent.Snapshot.State?.Text);
blockerReleased.SetResult();
}
[Fact]
public async Task ResourceForcedOutOfWaitingImmediatelyStopsWaitingForItsDependency()
{
// Regression test for a race between publishing "Waiting" and subscribing to state changes.
// WaitForInBeforeResourceStartedEvent detects a force-start by observing the resource leave "Waiting",
// and ResourceNotificationService.WatchAsync only replays the latest snapshot of each resource rather
// than the full history. If the Start command lands in the window between the wait publishing "Waiting"
// and the orchestrator subscribing, a replay-only view shows "Starting" without ever showing "Waiting",
// and the force-start signal is lost - the resource stays blocked on a dependency that never becomes
// ready. https://github.com/microsoft/aspire/pull/18930#discussion_r3677777200
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var blockerReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var blocker = AddSelfDrivenResource(builder, "blocker", blockerReleased.Task);
var waiter = AddSelfDrivenResource(builder, "waiter").WaitFor(blocker);
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
await appOrchestrator.RunApplicationAsync();
// Stand in for the Start command, firing as soon as the waiting state is stored. PublishUpdateAsync
// stores the snapshot synchronously, so a tight poll observes "Waiting" well before any watcher started
// afterwards could - which is exactly the window the fix has to cover. A delay-based poll or a watcher
// subscription would only see "Waiting" after the orchestrator had already subscribed, so the regression
// would go unnoticed. Losing the poll only ever produces a false pass, never a false failure.
var cancellationToken = TestContext.Current.CancellationToken;
var forceStartTask = Task.Run(async () =>
{
// Wait for the waiter's initial snapshot first so the tight poll below only spins across the short
// window in which the dependency wait is set up, rather than for the whole of application startup.
// "Waiting" is tolerated as well: if the poll is late the snapshot has already moved on, and the
// loop below exits immediately.
await resourceNotificationService.WaitForResourceAsync(
waiter.Resource.Name,
[KnownResourceStates.NotStarted, KnownResourceStates.Waiting],
cancellationToken);
while (!resourceNotificationService.TryGetCurrentState(waiter.Resource.Name, out var waiterEvent)
|| waiterEvent.Snapshot.State?.Text != KnownResourceStates.Waiting)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
}
await resourceNotificationService.PublishUpdateAsync(waiter.Resource, s => s with { State = KnownResourceStates.Starting });
}, cancellationToken);
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
await forceStartTask.DefaultTimeout();
// The waiter has to start even though the blocker is still inside its initialize callback.
await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Running, cancellationToken).DefaultTimeout();
Assert.True(resourceNotificationService.TryGetCurrentState(blocker.Resource.Name, out var blockerEvent));
Assert.NotEqual(KnownResourceStates.Running, blockerEvent.Snapshot.State?.Text);
blockerReleased.SetResult();
}
[Fact]
public async Task ConnectionStringResourceWaitsForReferencedResourceBeforeBecomingAvailable()
{
// AddConnectionString has the same shape as a third-party custom resource: it starts in "NotStarted"
// and publishes BeforeResourceStartedEvent from OnInitializeResource, and it implicitly adds a
// WaitForStart on every resource its expression references. It must not report a connection string
// before those resources have started. https://github.com/microsoft/aspire/issues/17453
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var referencedReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var referenced = AddSelfDrivenResource(builder, "referenced", referencedReleased.Task);
var connectionString = builder.AddConnectionString("cs", ReferenceExpression.Create($"Endpoint={referenced.Resource}"));
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
await resourceNotificationService.WaitForResourceAsync(connectionString.Resource.Name, KnownResourceStates.Waiting, TestContext.Current.CancellationToken).DefaultTimeout();
referencedReleased.SetResult();
await resourceNotificationService.WaitForResourceAsync(referenced.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
await resourceNotificationService.WaitForResourceAsync(connectionString.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
}
[Fact]
public async Task ExplicitStartResourceWithoutDcpInstancesReportsWaitingForItsDependency()
{
// Resources not managed by Aspire (no instances) should transition to "Waiting" directly from NotStarted,
// regardless whether they have ExplicitStartAnnotation or not. The annotation does not really makes a difference here
// because it is only used to control the behavior of the orchestrator when starting a resource
// and since the resource has no instances, it is not subject to the orchestrator's start logic.
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var blockerReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var blocker = AddSelfDrivenResource(builder, "blocker", blockerReleased.Task);
var waiter = builder.AddConnectionString("waiter", ReferenceExpression.Create($"Host=localhost;Port=5678"))
.WaitFor(blocker)
.WithExplicitStart();
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events, applicationEventing: builder.Eventing);
await appOrchestrator.RunApplicationAsync();
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
var waitingEvent = await resourceNotificationService.WaitForResourceAsync(
waiter.Resource.Name,
re => re.Snapshot.State?.Text == KnownResourceStates.Waiting,
TestContext.Current.CancellationToken).DefaultTimeout();
var waitingFor = waitingEvent.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.WaitingFor)?.Value;
Assert.Equal(new[] { blocker.Resource.Name }, Assert.IsAssignableFrom<IEnumerable<string>>(waitingFor));
blockerReleased.SetResult();
// The resource starts without anyone issuing a start command, which is what makes "Waiting" - rather than
// "NotStarted" - the honest label for the period above.
await resourceNotificationService.WaitForResourceAsync(blocker.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
await resourceNotificationService.WaitForResourceAsync(waiter.Resource.Name, KnownResourceStates.Running, TestContext.Current.CancellationToken).DefaultTimeout();
}
/// <summary>
/// Publishes <paramref name="state"/> for every DCP instance (replica) of <paramref name="resource"/>, as an
/// individual per-instance update rather than a model-level one.
/// </summary>
private static async Task PublishReplicaStatesAsync(ResourceNotificationService notificationService, IResource resource, string state)
{
Assert.True(resource.TryGetInstances(out var instances));
foreach (var instance in instances)
{
await notificationService.PublishUpdateAsync(resource, instance.Name, s => s with { State = state }).DefaultTimeout();
}
}
/// <summary>
/// Adds a resource shaped like a custom hosting integration that drives its own startup: it starts in
/// <see cref="KnownResourceStates.NotStarted"/> and publishes <see cref="BeforeResourceStartedEvent"/> itself
/// instead of being started by DCP.
/// </summary>
private static IResourceBuilder<CustomResourceWithWaitSupport> AddSelfDrivenResource(IDistributedApplicationBuilder builder, string name, Task? gate = null)
{
return builder.AddResource(new CustomResourceWithWaitSupport(name))
.WithInitialState(new CustomResourceSnapshot
{
ResourceType = "CustomResource",
State = KnownResourceStates.NotStarted,
Properties = []
})
.OnInitializeResource(async (resource, @event, ct) =>
{
// This is where waiting happens.
await @event.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, @event.Services), ct);
if (gate is not null)
{
await gate;
}
// ResourceHealthCheckService isn't running in this harness, so stand in for it and publish the
// ready event a resource with no health checks would otherwise get. Without it, WaitFor - which
// waits until healthy - could never observe the dependency as ready.
await @event.Notifications.PublishUpdateAsync(resource, s => s with
{
State = KnownResourceStates.Running,
ResourceReadyEvent = new EventSnapshot(Task.CompletedTask)
});
});
}
private ApplicationOrchestrator CreateOrchestrator(
DistributedApplicationModel distributedAppModel,
ResourceNotificationService notificationService,
DcpExecutorEvents? dcpEvents = null,
IDistributedApplicationEventing? applicationEventing = null,
ResourceLoggerService? resourceLoggerService = null,
DashboardOptions? dashboardOptions = null,
TestDcpExecutor? dcpExecutor = null)
{
var services = new ServiceCollection();
var configuration = new ConfigurationManager();
services.AddTestAndResourceLogging(testOutputHelper, configuration);
var serviceProvider = services.BuildServiceProvider();
resourceLoggerService ??= new ResourceLoggerService();
var executionContext = new DistributedApplicationExecutionContext(
new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { Services = serviceProvider });
return new ApplicationOrchestrator(
distributedAppModel,
dcpExecutor ?? new TestDcpExecutor(),
dcpEvents ?? new DcpExecutorEvents(),
[],
notificationService,
resourceLoggerService,
applicationEventing ?? new DistributedApplicationEventing(),
serviceProvider,
executionContext,
new ParameterProcessor(
notificationService,
resourceLoggerService,
CreateInteractionService(),
serviceProvider.GetRequiredService<ILogger<ParameterProcessor>>(),
executionContext,
deploymentStateManager: new MockDeploymentStateManager(),
userSecretsManager: UserSecrets.NoopUserSecretsManager.Instance),
Options.Create(dashboardOptions ?? new()),
serviceProvider.GetRequiredService<ILogger<ApplicationOrchestrator>>()
);
}
private static InteractionService CreateInteractionService(DistributedApplicationOptions? options = null)
{
return new InteractionService(
NullLogger<InteractionService>.Instance,
options ?? new DistributedApplicationOptions(),
new ServiceCollection().BuildServiceProvider(),
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
new TestInteractionFileUploadStore());
}
private sealed class MockDeploymentStateManager : IDeploymentStateManager
{
public string? StateFilePath => null;
public Task<DeploymentStateSection> AcquireSectionAsync(string sectionName, CancellationToken cancellationToken = default)
{
return Task.FromResult(new DeploymentStateSection(sectionName, [], 0));
}
public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task ClearAllStateAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
private sealed class CustomResource(string name) : Resource(name);
private sealed class CustomResourceWithWaitSupport(string name) : Resource(name), IResourceWithWaitSupport, IResourceWithConnectionString
{
public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"{Name}-connection-string");
}
private sealed class CustomChildResource(string name, IResource parent) : Resource(name), IResourceWithParent
{
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();
}
private abstract class ResourceWithConnectionString(string name)
: Resource(name), IResourceWithConnectionString
{
protected abstract ReferenceExpression ConnectionString { get; }
public ReferenceExpression ConnectionStringExpression
{
get
{
if (this.TryGetLastAnnotation<ConnectionStringRedirectAnnotation>(out var connectionStringAnnotation))
{
return connectionStringAnnotation.Resource.ConnectionStringExpression;
}
return ConnectionString;
}
}
}
private sealed class ParentResourceWithConnectionString(string name) : ResourceWithConnectionString(name)
{
protected override ReferenceExpression ConnectionString =>
ReferenceExpression.Create($"Server=localhost:8000");
}
private sealed class ChildResourceWithConnectionString(
string name,
Dictionary<string, string> kvConnectionString,
IResourceWithConnectionString parent
)
: ResourceWithConnectionString(name), IResourceWithParent
{
private string SubConnectionString =>
string.Join(';', kvConnectionString.Select(kv => $"{kv.Key}={kv.Value}"));
protected override ReferenceExpression ConnectionString =>
ReferenceExpression.Create($"{parent};{SubConnectionString}");
public IResource Parent { get; } = parent;
}
private sealed class TestResourceWithConnectionString(string name, string connectionString, string? databaseName = null, string? host = null, ReferenceExpression? unresolvedProperty = null)
: Resource(name), IResourceWithConnectionString
{
public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"{connectionString}");
public ValueTask<string?> GetConnectionStringAsync(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult<string?>(connectionString);
}
public IEnumerable<KeyValuePair<string, ReferenceExpression>> GetConnectionProperties()
{
if (databaseName is not null)
{
yield return new("DatabaseName", ReferenceExpression.Create($"{databaseName}"));
}
if (host is not null)
{
yield return new("Host", ReferenceExpression.Create($"{host}"));
}
if (unresolvedProperty is not null)
{
yield return new("Unavailable", unresolvedProperty);
}
}
}
[Fact]
public async Task ContainerChildResourcesWithOwnLifetimeDoNotReceiveParentStateChanges()
{
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var parentContainer = builder.AddContainer("parent-container", "parent-image");
var childContainer = builder.AddContainer("child-container", "child-image")
.WithParentRelationship(parentContainer);
var customChild = builder.AddResource(new CustomChildResource("custom-child", parentContainer.Resource));
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
await appOrchestrator.RunApplicationAsync();
// Initialize resources
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
// Simulate parent container state change
await events.PublishAsync(new OnResourceChangedContext(
CancellationToken.None,
KnownResourceTypes.Container,
parentContainer.Resource,
"parent-container-dcp",
new ResourceStatus(KnownResourceStates.FailedToStart, null, null),
snapshot => snapshot with { State = KnownResourceStates.FailedToStart }));
// Check final states
var parentState = resourceNotificationService.TryGetCurrentState("parent-container-dcp", out var parentEvent) ? parentEvent.Snapshot.State?.Text : null;
var childContainerState = resourceNotificationService.TryGetCurrentState(childContainer.Resource.Name, out var childContainerEvent) ? childContainerEvent.Snapshot.State?.Text : null;
var customChildState = resourceNotificationService.TryGetCurrentState(customChild.Resource.Name, out var customChildEvent) ? customChildEvent.Snapshot.State?.Text : null;
// 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);
}
[Fact]
public async Task ProjectChildResourcesWithOwnLifetimeDoNotReceiveParentStateChanges()
{
var builder = DistributedApplication.CreateBuilder();
builder.WithTestAndResourceLogging(testOutputHelper);
var parentContainer = builder.AddContainer("parent-container", "parent-image");
var childProject = builder.AddProject<ProjectA>("child-project")
.WithParentRelationship(parentContainer);
var customChild = builder.AddResource(new CustomChildResource("custom-child", parentContainer.Resource));
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var events = new DcpExecutorEvents();
var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
var appOrchestrator = CreateOrchestrator(distributedAppModel, notificationService: resourceNotificationService, dcpEvents: events);
await appOrchestrator.RunApplicationAsync();
// Initialize resources
await events.PublishAsync(new OnResourcesPreparedContext(CancellationToken.None));
// Simulate parent container state change
await events.PublishAsync(new OnResourceChangedContext(
CancellationToken.None,
KnownResourceTypes.Container,
parentContainer.Resource,
"parent-container-dcp",
new ResourceStatus(KnownResourceStates.FailedToStart, null, null),
snapshot => snapshot with { State = KnownResourceStates.FailedToStart }));
// Check final states
var parentState = resourceNotificationService.TryGetCurrentState("parent-container-dcp", out var parentEvent) ? parentEvent.Snapshot.State?.Text : null;
var childProjectState = resourceNotificationService.TryGetCurrentState(childProject.Resource.Name, out var childProjectEvent) ? childProjectEvent.Snapshot.State?.Text : null;
var customChildState = resourceNotificationService.TryGetCurrentState(customChild.Resource.Name, out var customChildEvent) ? customChildEvent.Snapshot.State?.Text : null;
// 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();
builder.WithTestAndResourceLogging(testOutputHelper);
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<DistributedApplicationModel>();
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();
builder.WithTestAndResourceLogging(testOutputHelper);
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<DistributedApplicationModel>();
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();
builder.WithTestAndResourceLogging(testOutputHelper);
var parentProject = builder.AddProject<ProjectA>("parent-project");
var childProject = builder.AddProject<ProjectB>("child-project");
parentProject.WithChildRelationship(childProject);
using var app = builder.Build();
var distributedAppModel = app.Services.GetRequiredService<DistributedApplicationModel>();
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);
}
private sealed class ThrowingValueProvider : IValueProvider, IManifestExpressionProvider
{
public string ValueExpression => "{throwing.value}";
public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
{
throw new InvalidOperationException("The connection property isn't available.");
}
}
}