File: DeclarativeApplicationTests.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 System.Text.Json.Nodes;
using Aspire.Hosting.Native.Api;
using Aspire.Hosting.Native.Core.Tests.TestServices;
using Aspire.Hosting.Native.Model;
using Aspire.Hosting.Native.Runtime;
using static Aspire.Hosting.Native.Core.Tests.TestServices.RpcTestClient;
 
namespace Aspire.Hosting.Native.Core.Tests;
 
public class DeclarativeApplicationTests
{
    [Fact]
    public void ConcurrentRpcWorkloadArgumentsReceiveDistinctSymbolicSlots()
    {
        using var rpc = new RpcTestClient(authenticate: true);
        var session = rpc.CreateSession();
        var composition = rpc.StartGeneration(session);
        var resource = Result(rpc.Invoke("addContainer", Arguments(("context", composition),
            ("name", JsonValue.Create("service")),
            ("image", JsonValue.Create("generic/service:1")))));
        var value = Result(rpc.Invoke("createLiteralValue", Arguments(("context", composition),
            ("value", JsonValue.Create("value")))));
        Parallel.For(0, 32, new ParallelOptions { MaxDegreeOfParallelism = 8 }, _ =>
            WorkspaceRpcTestContext.EnsureSuccess(rpc.Invoke("withArgument", Arguments(("context", resource), ("value", value)))));
        var configuration = Result(rpc.Invoke("inspectSymbolicConfiguration", Arguments(("context", resource))));
        Assert.Equal(Enumerable.Range(0, 32).Select(index => $"workload.arg.{index}").Order(),
            configuration["properties"]!.AsArray().Select(entry => entry!["name"]!.GetValue<string>()).Order());
        WorkspaceRpcTestContext.EnsureSuccess(rpc.Invoke("retireGeneration", Arguments(("context", session), ("composition", composition))));
    }
 
    [Fact]
    public async Task SealingWaitsForTheWholeWorkloadAndSymbolicMutation()
    {
        using var model = new ApplicationModel();
        var resource = model.AddResource("service", "hosting/container");
        model.SetWorkload(resource, DeclaredWorkload.Create(new ContainerWorkload("generic/service:1", null, [], [], [], [])));
        var value = SymbolicValue.Literal(model.GenerationId, "value");
        var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var sealing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var mutation = Task.Run(() => model.MutateWorkload(resource, current =>
        {
            entered.TrySetResult();
            release.Task.WaitAsync(TimeSpan.FromSeconds(15), TimeProvider.System).GetAwaiter().GetResult();
 
            return (current with
            {
                Bindings = new([new("VALUE", "workload.env.VALUE")], [])
            }, new("workload.env.VALUE", value));
        }));
        Task<ApplicationSnapshot> seal;
        try
        {
            await entered.Task.WaitAsync(TimeSpan.FromSeconds(15), TimeProvider.System);
            seal = Task.Run(() =>
            {
                sealing.TrySetResult();
 
                return model.Seal();
            });
            await sealing.Task.WaitAsync(TimeSpan.FromSeconds(15), TimeProvider.System);
            Assert.False(seal.IsCompleted);
        }
        finally
        {
            release.TrySetResult();
            await mutation.WaitAsync(TimeSpan.FromSeconds(15), TimeProvider.System);
        }
        var snapshot = Assert.Single((await seal.WaitAsync(TimeSpan.FromSeconds(15), TimeProvider.System)).Resources);
        Assert.Equal(new SymbolicEnvironmentBinding("VALUE", "workload.env.VALUE"), Assert.Single(snapshot.Workload!.Bindings.Environment));
        Assert.Same(value, Assert.Single(snapshot.SymbolicConfiguration).Value);
        Assert.Throws<InvalidOperationException>(() => model.MutateWorkload(resource, current => (current, null)));
        Assert.Same(snapshot.Workload, model.ReadResource(resource).Workload);
    }
 
    [Fact]
    public void FailedWorkloadMutationLeavesPlanSymbolicValuesAndDependenciesUnchanged()
    {
        using var model = new ApplicationModel();
        var resource = model.AddResource("service", "hosting/container");
        var dependency = model.AddResource("dependency", "hosting/container");
        var original = DeclaredWorkload.Create(new ContainerWorkload("generic/service:1", null, [], [], [], []));
        model.SetWorkload(resource, original);
        var value = SymbolicValue.Endpoint(dependency, "tcp", EndpointValueProperty.Host);
        Assert.Throws<ArgumentException>(() => model.MutateWorkload(resource, current => (current with
        {
            Plan = new ContainerWorkload("invalid image", null, [], [], [], [])
        }, new("workload.env.VALUE", value))));
        Assert.Same(original, model.ReadResource(resource).Workload);
        Assert.Empty(model.ReadResource(resource).SymbolicConfiguration);
        Assert.Empty(model.ReadResource(resource).Dependencies);
 
        model.AddDependency(dependency, resource);
        using var foreign = new ApplicationModel();
        var foreignResource = foreign.AddResource("foreign", "hosting/container");
        var foreignValue = SymbolicValue.Endpoint(foreignResource, "tcp", EndpointValueProperty.Host);
        Assert.Throws<ArgumentException>(() => model.MutateWorkload(resource, current => (current with
        {
            Bindings = new([new("VALUE", "workload.env.VALUE")], [])
        }, new("workload.env.VALUE", foreignValue))));
        Assert.Same(original, model.ReadResource(resource).Workload);
        Assert.Empty(model.ReadResource(resource).SymbolicConfiguration);
        Assert.Empty(model.ReadResource(resource).Dependencies);
    }
 
    [Fact]
    public async Task ConcurrentWorkloadMutatorsPreserveEveryDeclarationAndSymbolicBinding()
    {
        var session = new NativeApplicationServer().OpenApplication();
        var composition = session.StartGeneration();
        for (var iteration = 0; iteration < 20; iteration++)
        {
            var resource = composition.AddContainer($"service-{iteration}", "generic/service:1");
            var value = composition.CreateLiteralValue("value");
            Parallel.For(0, 16, new ParallelOptions { MaxDegreeOfParallelism = 8 }, index =>
            {
                resource.WithArgument(value);
                resource.WithEnvironment($"VALUE_{index}", value);
                resource.WithEndpoint(new()
                {
                    Name = $"port-{index}",
                    Protocol = "TCP",
                    UriScheme = "tcp",
                    TargetPort = 6000 + index,
                    HostPort = 0,
                    EnvironmentVariable = ""
                });
                resource.WithVolume($"data-{index}", $"/data/{index}", false);
                resource.WithEntrypoint("service");
                resource.WithLifecycleExecutable("health", new()
                {
                    ExecutablePath = Environment.ProcessPath!,
                    WorkingDirectory = AppContext.BaseDirectory,
                    Arguments = ["health"],
                    Environment = [],
                    Endpoints = []
                }, new() { Arguments = [], Environment = [] });
            });
            var snapshot = composition.Model.ReadResource(resource.Handle);
            var workload = Assert.IsType<DeclaredWorkload>(snapshot.Workload);
            Assert.Equal(16, workload.Plan.Arguments.Length);
            Assert.Equal(Enumerable.Range(0, 16), workload.Bindings.Arguments.Select(binding => binding.Index).Order());
            Assert.Equal(Enumerable.Range(0, 16).Select(index => $"workload.arg.{index}").Order(),
                workload.Bindings.Arguments.Select(binding => binding.ConfigurationKey).Order());
            Assert.Equal(Enumerable.Range(0, 16).Select(index => $"VALUE_{index}").Order(),
                workload.Bindings.Environment.Select(binding => binding.Name).Order());
            Assert.Equal(Enumerable.Range(0, 16).Select(index => $"port-{index}").Order(),
                workload.Plan.Endpoints.Select(port => port.Name).Order());
            var container = Assert.IsType<ContainerWorkload>(workload.Plan);
            Assert.Equal(Enumerable.Range(0, 16).Select(index => $"/data/{index}").Order(),
                container.Mounts.Select(mount => mount.Target).Order());
            Assert.Equal("service", container.Command);
            Assert.Equal("health", Assert.Single(workload.Lifecycle).Phase);
            Assert.Equal(workload.Bindings.Arguments.Select(binding => binding.ConfigurationKey)
                .Concat(workload.Bindings.Environment.Select(binding => binding.ConfigurationKey)).Order(),
                snapshot.SymbolicConfiguration.Select(entry => entry.Name).Order());
            Assert.All(snapshot.SymbolicConfiguration, entry => Assert.Same(value.Value, entry.Value));
        }
        await session.RetireGeneration(composition);
    }
 
    [Fact]
    public async Task WorkspaceRejectsRevisionWhileDeclaredStartupStillOwnsItsDesiredPlan()
    {
        var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var executor = new WorkloadTestExecutor
        {
            BeforeStart = token =>
            {
                entered.TrySetResult();
                return release.Task.WaitAsync(token);
            }
        };
        executor.ReleaseCleanup();
        var workspace = new NativeApplicationServer(executor).CreateApplicationWorkspace();
        var first = workspace.BeginRevision();
        first.AddContainer("service", "generic/service:1");
        await workspace.CommitRevision(first);
        var application = first.StartApplication();
        await entered.Task.WaitAsync(TimeSpan.FromSeconds(15), TimeProvider.System);
        var next = workspace.BeginRevision();
        next.AddContainer("service", "generic/service:2");
        var failure = await Assert.ThrowsAsync<InvalidOperationException>(() => workspace.CommitRevision(next));
        Assert.Equal("The declared application is still activating.", failure.Message);
        workspace.AbortRevision(next);
        release.TrySetResult();
        await application.WaitApplicationReady(15000);
        Assert.False(first.IsRetired);
        await workspace.RetireApplicationWorkspace();
    }
 
    [Fact]
    public async Task DeclaredRetirementStopsConsumersBeforeProducersRegardlessOfNameOrder()
    {
        var executor = new WorkloadTestExecutor();
        executor.ReleaseCleanup();
        var session = new NativeApplicationServer(executor).OpenApplication();
        var composition = session.StartGeneration();
        var producer = composition.AddContainer("a-producer", "generic/service:1");
        var consumer = composition.AddContainer("z-consumer", "generic/consumer:1");
        consumer.WaitFor(producer);
        await composition.StartApplication().WaitApplicationReady(15000);
        await session.RetireGeneration(composition);
        Assert.Equal([consumer.Handle.ResourceId, producer.Handle.ResourceId], executor.Removed.Select(identity => identity.ResourceId));
    }
 
    [Fact]
    public async Task RemovedDeclaredWorkloadsRetireUsingThePreviousDependencyGraph()
    {
        var executor = new WorkloadTestExecutor();
        executor.ReleaseCleanup();
        var workspace = new NativeApplicationServer(executor).CreateApplicationWorkspace();
        var first = workspace.BeginRevision();
        var producer = first.AddContainer("a-producer", "generic/service:1");
        var consumer = first.AddContainer("z-consumer", "generic/consumer:1");
        consumer.WaitFor(producer);
        await workspace.CommitRevision(first);
        await first.StartApplication().WaitApplicationReady(15000);
 
        await workspace.CommitRevision(workspace.BeginRevision());
        Assert.Equal([consumer.Handle.ResourceId, producer.Handle.ResourceId],
            executor.Removed.Select(identity => identity.ResourceId));
        await workspace.RetireApplicationWorkspace();
        Assert.Equal(2, executor.Removed.Count);
    }
 
    [Fact]
    public async Task InitializationFailureRunsDeclaredCleanupEvenWhenMainWorkloadNeverStarted()
    {
        var executor = new WorkloadTestExecutor
        {
            StatusForPlan = plan => new(WorkloadPhase.Exited, plan.Arguments.SequenceEqual(["initialize"]) ? 1 : 0)
        };
        executor.ReleaseCleanup();
        var session = new NativeApplicationServer(executor).OpenApplication();
        var composition = session.StartGeneration();
        var resource = composition.AddContainer("service", "generic/service:1");
        foreach (var phase in new[] { "initialize", "retire" })
        {
            resource.WithLifecycleExecutable(phase, new()
            {
                ExecutablePath = Environment.ProcessPath!,
                WorkingDirectory = AppContext.BaseDirectory,
                Arguments = [phase],
                Environment = [],
                Endpoints = []
            }, new() { Environment = [], Arguments = [] });
        }
        var application = composition.StartApplication();
        await Assert.ThrowsAsync<InvalidOperationException>(() => application.WaitApplicationReady(15000));
        await session.RetireGeneration(composition);
        Assert.Equal(["initialize", "retire"], executor.Plans.Select(plan => Assert.Single(plan.Arguments)));
        Assert.Equal(2, executor.Removed.Count);
    }
 
    [Fact]
    public async Task DeclaredWorkspaceRevisionsRetainUnchangedWorkloadsAndRestartAffectedConsumers()
    {
        var executor = new WorkloadTestExecutor();
        executor.ReleaseCleanup();
        var workspace = new NativeApplicationServer(executor).CreateApplicationWorkspace();
        Composition Declare(string image)
        {
            var revision = workspace.BeginRevision();
            var producer = revision.AddContainer("producer", image);
            producer.WithEndpoint(new() { Name = "tcp", Protocol = "TCP", UriScheme = "tcp", TargetPort = 6379, HostPort = 0, EnvironmentVariable = "" });
            var consumer = revision.AddExecutable("consumer", Environment.ProcessPath!, AppContext.BaseDirectory);
            consumer.WithReference("HOST", revision.CreateEndpointValue(producer, "tcp", "host"));
            return revision;
        }
        var first = Declare("generic/service:1");
        await workspace.CommitRevision(first);
        await first.StartApplication().WaitApplicationReady(15000);
        Assert.Equal(2, executor.Started.Count);
        await workspace.CommitRevision(Declare("generic/service:1"));
        Assert.Equal(2, executor.Started.Count);
        await workspace.CommitRevision(Declare("generic/service:2"));
        Assert.Equal(4, executor.Started.Count);
        Assert.Equal(2, executor.Removed.Count);
        Assert.True(first.IsRetired);
        await workspace.RetireApplicationWorkspace();
        Assert.Equal(4, executor.Removed.Count);
    }
 
    [Fact]
    public async Task FailedProtocolHealthUsesInjectedClockAndCleansContainerWithoutStartingDependents()
    {
        var probeWaiting = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var releaseRetirement = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var delayedCleanupResource = Guid.Empty;
        var clock = new AdjustableFakeTimeProvider
        {
            TimerCreated = due => { if (due == TimeSpan.FromSeconds(1)) { probeWaiting.TrySetResult(); } }
        };
        var executor = new WorkloadTestExecutor
        {
            StatusForPlan = plan => new(plan is ContainerWorkload ? WorkloadPhase.Running : WorkloadPhase.Exited, 1),
            BeforeCleanup = identity => identity.ResourceId == delayedCleanupResource ? releaseRetirement.Task : Task.CompletedTask
        };
        executor.ReleaseCleanup();
        var options = new NativeRuntimeOptions
        {
            TimeProvider = clock,
            WorkloadStartupTimeout = TimeSpan.FromSeconds(10),
            ObservationInterval = TimeSpan.FromSeconds(1)
        };
        var session = new NativeApplicationServer(executor, options).OpenApplication();
        var composition = session.StartGeneration();
        var service = composition.AddContainer("service", "generic/service:1");
        service.WithLifecycleExecutable("health", new()
        {
            ExecutablePath = Environment.ProcessPath!,
            WorkingDirectory = AppContext.BaseDirectory,
            Arguments = ["reject-authentication"],
            Environment = [],
            Endpoints = []
        }, new() { Arguments = [], Environment = [] });
        var consumer = composition.AddExecutable("consumer", Environment.ProcessPath!, AppContext.BaseDirectory);
        consumer.WaitFor(service);
        // Hold an independent leaf so retirement is observed before it reaches
        // the failed producer, rather than depending on continuation scheduling.
        delayedCleanupResource = composition.AddContainer("independent", "generic/service:1").Handle.ResourceId;
        var application = composition.StartApplication();
        Task retirement;
        try
        {
            var readiness = application.WaitApplicationReady(15000);
            await probeWaiting.Task.WaitAsync(TimeSpan.FromSeconds(15), TimeProvider.System);
            clock.Advance(TimeSpan.FromSeconds(10));
            var failure = await Record.ExceptionAsync(() => readiness);
            Assert.True(failure is OperationCanceledException or InvalidOperationException);
            retirement = session.RetireGeneration(composition);
            Assert.False(retirement.IsCompleted);
        }
        finally
        {
            releaseRetirement.TrySetResult();
        }
        await retirement.WaitAsync(TimeSpan.FromSeconds(15), TimeProvider.System);
        Assert.All(executor.Plans.OfType<ExecutableWorkload>(), plan => Assert.Equal(["reject-authentication"], plan.Arguments));
        Assert.Equal(executor.Started.Count, executor.Removed.Count);
    }
 
    [Fact]
    public async Task NativeApplicationStartsDeclaredWorkloadsAndAuthenticatedLifecycleWithoutInvitations()
    {
        var executor = new WorkloadTestExecutor
        {
            StatusForPlan = plan => new(plan is ContainerWorkload ? WorkloadPhase.Running : WorkloadPhase.Exited, 0)
        };
        executor.ReleaseCleanup();
        var server = new NativeApplicationServer(executor);
        var session = server.OpenApplication();
        var composition = session.StartGeneration();
        var secret = composition.AddSecretParameter("password", "native-only-secret");
        var service = composition.AddContainer("service", "generic/service:1");
        foreach (var phase in new[] { "initialize", "ready" })
        {
            service.WithLifecycleExecutable(phase, new()
            {
                ExecutablePath = Environment.ProcessPath!,
                WorkingDirectory = AppContext.BaseDirectory,
                Environment = [],
                Arguments = [phase],
                Endpoints = []
            }, new() { Environment = [], Arguments = [] });
        }
        service.WithEnvironment("PASSWORD", secret);
        service.WithEndpoint(new() { Name = "tcp", Protocol = "TCP", UriScheme = "tcp", TargetPort = 6379, HostPort = 0, EnvironmentVariable = "" });
        service.SetSymbolicConfiguration("probe.host", composition.CreateEndpointValue(service, "tcp", "host"));
        service.SetSymbolicConfiguration("probe.password", secret);
        service.WithLifecycleExecutable("health", new()
        {
            ExecutablePath = Environment.ProcessPath!,
            WorkingDirectory = AppContext.BaseDirectory,
            Environment = [],
            Arguments = ["health"],
            Endpoints = []
        }, new()
        {
            Environment = [new() { Name = "HOST", ConfigurationKey = "probe.host" }, new() { Name = "PASSWORD", ConfigurationKey = "probe.password" }],
            Arguments = []
        });
        service.WithLifecycleExecutable("retire", new()
        {
            ExecutablePath = Environment.ProcessPath!,
            WorkingDirectory = AppContext.BaseDirectory,
            Environment = [],
            Arguments = ["retire"],
            Endpoints = []
        }, new() { Environment = [], Arguments = [] });
        var consumer = composition.AddExecutable("consumer", Environment.ProcessPath!, AppContext.BaseDirectory);
        consumer.WithReference("SERVICE_HOST", composition.CreateEndpointValue(service, "tcp", "host"));
        Assert.Empty(executor.Started);
        var application = composition.StartApplication();
        await application.WaitApplicationReady(15000);
        Assert.True(await application.WaitResourceCompletion(consumer, 15000));
        Assert.Equal(5, executor.Started.Count);
        var health = Assert.Single(executor.Plans.OfType<ExecutableWorkload>(), plan => plan.Arguments.SequenceEqual(["health"]));
        Assert.Collection(health.Environment, entry => Assert.Equal(new WorkloadEnvironment("HOST", "127.0.0.1"), entry),
            entry => Assert.Equal(new WorkloadEnvironment("PASSWORD", "native-only-secret"), entry));
        await session.RetireGeneration(composition);
        Assert.Equal(6, executor.Removed.Count);
        Assert.Single(executor.Plans.OfType<ExecutableWorkload>(), plan => plan.Arguments.SequenceEqual(["retire"]));
    }
 
    [Fact]
    public async Task ContainerReferenceRejectsUnsupportedNetworkContextInsteadOfUsingHostLoopback()
    {
        var executor = new WorkloadTestExecutor();
        executor.ReleaseCleanup();
        var session = new NativeApplicationServer(executor).OpenApplication();
        var composition = session.StartGeneration();
        var service = composition.AddContainer("service", "generic/service:1");
        var consumer = composition.AddContainer("consumer", "generic/consumer:1");
        consumer.WithReference("SERVICE", composition.CreateEndpointValue(service, "tcp", "host"));
        var application = composition.StartApplication();
        await Assert.ThrowsAsync<SymbolicValueException>(() => application.WaitApplicationReady(15000));
        await session.RetireGeneration(composition);
    }
}