File: MilestoneDependencyTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Native.Core.Tests\Aspire.Hosting.Native.Core.Tests.csproj (Aspire.Hosting.Native.Core.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.Native.Api;
using Aspire.Hosting.Native.Core.Tests.TestServices;
using Aspire.Hosting.Native.Model;
using Aspire.Hosting.Native.Runtime;
 
namespace Aspire.Hosting.Native.Core.Tests;
 
public class MilestoneDependencyTests
{
    [Fact]
    public async Task MutualAndSelfEndpointReferencesConfigureBeforeEitherWorkloadStarts()
    {
        var executor = new WorkloadTestExecutor();
        executor.ReleaseCleanup();
        var session = new NativeApplicationServer(executor).OpenApplication();
        var composition = session.StartGeneration();
        var first = Declare(composition, "first");
        var second = Declare(composition, "second");
        first.WithEnvironment("PEER", composition.CreateEndpointValue(second, "http", "url"));
        first.WithEnvironment("SELF", composition.CreateEndpointValue(first, "http", "url"));
        second.WithEnvironment("PEER", composition.CreateEndpointValue(first, "http", "url"));
        Assert.Empty(first.InspectResource().Dependencies);
        Assert.Empty(second.InspectResource().Dependencies);
        WorkloadExecutionPlan.Validate(composition.Model.Inspect().Resources, allocationBeforeStart: true);
        try
        {
            await composition.StartApplication().WaitApplicationReady(15000);
            Assert.Equal(2, executor.Prepared.Count);
            Assert.Equal(2, executor.Plans.Count);
            Assert.All(executor.Plans, plan => Assert.All(plan.Environment, variable =>
                Assert.Equal("http://127.0.0.1:5050", variable.Value)));
        }
        finally
        {
            await session.RetireGeneration(composition);
        }
        Assert.Equal(2, executor.Removed.Count);
        Assert.Equal(2, executor.ReleasedPreparations.Count);
    }
 
    [Fact]
    public async Task ConsumerDoesNotStartUntilReferencedAllocationExists()
    {
        var allocated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        Guid producerId = default;
        var executor = new WorkloadTestExecutor
        {
            BeforePrepare = (identity, token) =>
            {
                if (identity.ResourceId != producerId)
                {
                    return Task.CompletedTask;
                }
                allocated.TrySetResult();
 
                return release.Task.WaitAsync(token);
            }
        };
        executor.ReleaseCleanup();
        var session = new NativeApplicationServer(executor).OpenApplication();
        var composition = session.StartGeneration();
        var consumer = Declare(composition, "a-consumer");
        var producer = Declare(composition, "z-producer");
        producerId = producer.Handle.ResourceId;
        consumer.WithEnvironment("PEER", composition.CreateEndpointValue(producer, "http", "url"));
        var execution = composition.StartApplication();
        try
        {
            await allocated.Task.WaitAsync(TimeSpan.FromSeconds(10), TimeProvider.System);
            Assert.Empty(executor.Started);
            Assert.Single(executor.Prepared);
            release.TrySetResult();
            await execution.WaitApplicationReady(15000);
            Assert.Equal(2, executor.Started.Count);
        }
        finally
        {
            release.TrySetResult();
            await session.RetireGeneration(composition);
        }
    }
 
    [Fact]
    public async Task ExplicitReadinessWaitRemainsDistinctFromAllocationReferences()
    {
        var blocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var executor = new WorkloadTestExecutor
        {
            BeforeStart = token =>
            {
                blocked.TrySetResult();
 
                return release.Task.WaitAsync(token);
            }
        };
        executor.ReleaseCleanup();
        var session = new NativeApplicationServer(executor).OpenApplication();
        var composition = session.StartGeneration();
        var source = Declare(composition, "source");
        var target = Declare(composition, "target");
        target.WithEnvironment("PEER", composition.CreateEndpointValue(source, "http", "url"));
        target.WaitFor(source);
        var execution = composition.StartApplication();
        try
        {
            await blocked.Task.WaitAsync(TimeSpan.FromSeconds(10), TimeProvider.System);
            Assert.Equal(2, executor.Prepared.Count);
            Assert.Empty(executor.Plans);
            release.TrySetResult();
            await execution.WaitApplicationReady(15000);
            Assert.Equal([source.Handle.ResourceId, target.Handle.ResourceId],
                executor.Started.Select(identity => identity.ResourceId));
        }
        finally
        {
            release.TrySetResult();
            await session.RetireGeneration(composition);
        }
    }
 
    [Fact]
    public async Task ConsumerCanStartWhileItsAllocatedProducerHasNotStarted()
    {
        var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var consumerStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        Guid producerId = default;
        var executor = new WorkloadTestExecutor
        {
            BeforeStartResource = (identity, token) =>
            {
                if (identity.ResourceId == producerId)
                {
                    entered.TrySetResult();
 
                    return release.Task.WaitAsync(token);
                }
                return Task.CompletedTask;
            },
            BeforeReadStatus = _ =>
            {
                consumerStarted.TrySetResult();
 
                return Task.CompletedTask;
            }
        };
        executor.ReleaseCleanup();
        var session = new NativeApplicationServer(executor).OpenApplication();
        var composition = session.StartGeneration();
        var producer = Declare(composition, "producer");
        producerId = producer.Handle.ResourceId;
        var consumer = Declare(composition, "consumer");
        consumer.WithEnvironment("PEER", composition.CreateEndpointValue(producer, "http", "url"));
        var execution = composition.StartApplication();
        try
        {
            await Task.WhenAll(entered.Task, consumerStarted.Task).WaitAsync(TimeSpan.FromSeconds(10), TimeProvider.System);
            Assert.Equal(consumer.Handle.ResourceId, Assert.Single(executor.Started).ResourceId);
            Assert.Equal("http://127.0.0.1:5050", Assert.Single(Assert.Single(executor.Plans).Environment).Value);
            Assert.False(execution.ReadApplicationState().Resources.Single(resource => resource.Name == "producer").Healthy);
            release.TrySetResult();
            await execution.WaitApplicationReady(15000);
        }
        finally
        {
            release.TrySetResult();
            await session.RetireGeneration(composition);
        }
    }
 
    [Fact]
    public async Task FailedPeerAllocationFailsConsumersAndReleasesTheirPreparedAddresses()
    {
        Guid failingId = default;
        var executor = new WorkloadTestExecutor
        {
            BeforePrepare = (identity, _) => identity.ResourceId == failingId
                ? Task.FromException(new InvalidOperationException("Allocation failed."))
                : Task.CompletedTask
        };
        executor.ReleaseCleanup();
        var session = new NativeApplicationServer(executor).OpenApplication();
        var composition = session.StartGeneration();
        var consumer = Declare(composition, "a-consumer");
        var producer = Declare(composition, "z-producer");
        failingId = producer.Handle.ResourceId;
        consumer.WithEnvironment("PEER", composition.CreateEndpointValue(producer, "http", "url"));
        var execution = composition.StartApplication();
        await Assert.ThrowsAnyAsync<InvalidOperationException>(() =>
            execution.WaitApplicationReady(15000));
        await session.RetireGeneration(composition);
        Assert.Empty(executor.Started);
        Assert.Single(executor.ReleasedPreparations);
    }
 
    [Fact]
    public async Task WorkspaceRevisionsRestartReferenceCyclesWithoutRetainingOldConfiguration()
    {
        var executor = new WorkloadTestExecutor();
        executor.ReleaseCleanup();
        var workspace = new NativeApplicationServer(executor).CreateApplicationWorkspace();
        Composition Revision(string version)
        {
            var composition = workspace.BeginRevision();
            var first = Declare(composition, "first");
            var second = Declare(composition, "second");
            first.WithEnvironment("PEER", composition.CreateEndpointValue(second, "http", "url"));
            first.WithEnvironment("VERSION", composition.CreateLiteralValue(version));
            second.WithEnvironment("PEER", composition.CreateEndpointValue(first, "http", "url"));
 
            return composition;
        }
        try
        {
            var first = Revision("one");
            await workspace.CommitRevision(first);
            await first.StartApplication().WaitApplicationReady(15000);
            await workspace.CommitRevision(Revision("two"));
            Assert.Equal(4, executor.Started.Count);
            Assert.Equal(2, executor.Removed.Count);
            Assert.Equal(["one", "two"], executor.Plans.SelectMany(plan => plan.Environment)
                .Where(variable => variable.Name == "VERSION").Select(variable => variable.Value));
        }
        finally
        {
            await workspace.RetireApplicationWorkspace();
        }
        Assert.Equal(4, executor.Removed.Count);
        Assert.Equal(4, executor.ReleasedPreparations.Count);
    }
 
    [Fact]
    public async Task FailedReferenceCycleRevisionRetainsCleanupOwnershipForRetirement()
    {
        var failCleanup = false;
        var executor = new WorkloadTestExecutor
        {
            BeforeCleanup = _ => Volatile.Read(ref failCleanup)
                ? Task.FromException(new InvalidOperationException("Cleanup failed."))
                : Task.CompletedTask
        };
        executor.ReleaseCleanup();
        var workspace = new NativeApplicationServer(executor).CreateApplicationWorkspace();
        Composition Revision(string version)
        {
            var composition = workspace.BeginRevision();
            var first = Declare(composition, "first");
            var second = Declare(composition, "second");
            first.WithEnvironment("PEER", composition.CreateEndpointValue(second, "http", "url"));
            first.WithEnvironment("VERSION", composition.CreateLiteralValue(version));
            second.WithEnvironment("PEER", composition.CreateEndpointValue(first, "http", "url"));
 
            return composition;
        }
        try
        {
            var initial = Revision("one");
            await workspace.CommitRevision(initial);
            await initial.StartApplication().WaitApplicationReady(15000);
            Volatile.Write(ref failCleanup, true);
            await Assert.ThrowsAnyAsync<InvalidOperationException>(() => workspace.CommitRevision(Revision("two")));
            Assert.Equal(2, executor.Started.Count);
            Assert.Empty(executor.Removed);
        }
        finally
        {
            Volatile.Write(ref failCleanup, false);
            await workspace.RetireApplicationWorkspace();
        }
        Assert.Equal(2, executor.Removed.Count);
        Assert.Equal(2, executor.ReleasedPreparations.Count);
    }
 
    [Fact]
    public async Task RejectedExecutionRevisionLeavesTheRunningGraphUnchanged()
    {
        var workspace = new NativeApplicationServer().CreateApplicationWorkspace();
        try
        {
            var initial = workspace.BeginRevision();
            initial.AddResource("existing", "example/external");
            await workspace.CommitRevision(initial);
            var execution = initial.StartApplication();
            await execution.WaitApplicationReady(15000);
 
            var revision = workspace.BeginRevision();
            revision.AddResource("existing", "example/external");
            var added = Declare(revision, "added");
            added.WithEnvironment("SELF", revision.CreateEndpointValue(added, "http", "url"));
            await Assert.ThrowsAsync<ExecutionPlanException>(() => workspace.CommitRevision(revision));
            Assert.Equal("existing", Assert.Single(execution.ReadApplicationState().Resources).Name);
        }
        finally
        {
            await workspace.RetireApplicationWorkspace();
        }
    }
 
    [Fact]
    public void MutualReadinessWaitReportsItsMilestoneCycleBeforeAnyExecution()
    {
        using var model = new ApplicationModel();
        var first = model.AddResource("first", "hosting/executable");
        var second = model.AddResource("second", "hosting/executable");
        model.AddDependency(first, second);
        var error = Assert.Throws<ExecutionPlanException>(() => model.AddDependency(second, first));
        Assert.Equal("Execution prerequisite cycle: second.Start -> first.Ready -> first.Start -> second.Ready -> second.Start",
            error.Message);
        Assert.Empty(model.ReadResource(second).Dependencies);
    }
 
    [Fact]
    public void ReferenceCycleRequiresAnAllocationBeforeStartExecutor()
    {
        var session = new NativeApplicationServer().OpenApplication();
        var composition = session.StartGeneration();
        var first = Declare(composition, "first");
        var second = Declare(composition, "second");
        first.WithEnvironment("PEER", composition.CreateEndpointValue(second, "http", "url"));
        second.WithEnvironment("PEER", composition.CreateEndpointValue(first, "http", "url"));
        var failure = Assert.Throws<ExecutionPlanException>(composition.StartApplication);
        Assert.StartsWith("Execution prerequisite cycle: ", failure.Message);
        Assert.Contains(".Allocated", failure.Message);
        Assert.Contains(".Configured", failure.Message);
        Assert.Contains(".Start", failure.Message);
        session.Close();
    }
 
    [Fact]
    public async Task RetirementWhileWaitingForPeerAllocationReleasesPreparedOwnership()
    {
        var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        Guid delayedId = default;
        var executor = new WorkloadTestExecutor
        {
            BeforePrepare = async (identity, token) =>
            {
                if (identity.ResourceId == delayedId)
                {
                    entered.TrySetResult();
                    await Task.Delay(Timeout.InfiniteTimeSpan, token);
                }
            }
        };
        executor.ReleaseCleanup();
        var session = new NativeApplicationServer(executor).OpenApplication();
        var composition = session.StartGeneration();
        var first = Declare(composition, "first");
        var second = Declare(composition, "second");
        delayedId = second.Handle.ResourceId;
        first.WithEnvironment("PEER", composition.CreateEndpointValue(second, "http", "url"));
        composition.StartApplication();
        await entered.Task.WaitAsync(TimeSpan.FromSeconds(10), TimeProvider.System);
        await session.RetireGeneration(composition).WaitAsync(TimeSpan.FromSeconds(10), TimeProvider.System);
        Assert.Empty(executor.Started);
        Assert.Single(executor.ReleasedPreparations);
    }
 
    private static DeclaredResource Declare(Composition composition, string name)
    {
        var resource = composition.AddExecutable(name, Environment.ProcessPath!, AppContext.BaseDirectory);
        resource.WithEndpoint(new()
        {
            Name = "http", Protocol = "TCP", UriScheme = "http", TargetPort = 0, HostPort = 0, EnvironmentVariable = "PORT"
        });
 
        return resource;
    }
}