File: DistributedApplicationTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.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.Globalization;
using System.Text.RegularExpressions;
using Aspire.Components.Common.Tests;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Dcp.Model;
using Aspire.Hosting.Lifecycle;
using Aspire.Hosting.Testing;
using Aspire.Hosting.Testing.Tests;
using Aspire.Hosting.Tests.Helpers;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using k8s.Models;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;
using TestConstants = Microsoft.AspNetCore.InternalTesting.TestConstants;
 
namespace Aspire.Hosting.Tests;
 
public class DistributedApplicationTests
{
    private readonly ITestOutputHelper _testOutputHelper;
 
    private const string ReplicaIdRegex = @"[\w]+"; // Matches a replica ID that is part of a resource name.
 
    public DistributedApplicationTests(ITestOutputHelper testOutputHelper)
    {
        _testOutputHelper = testOutputHelper;
    }
 
    [Fact]
    public async Task RegisteredLifecycleHookIsExecutedWhenRunAsynchronously()
    {
        var exceptionMessage = "Exception from lifecycle hook to prove it ran!";
 
        using var testProgram = CreateTestProgram();
        testProgram.AppBuilder.Services.AddLifecycleHook((sp) =>
        {
            return new CallbackLifecycleHook((appModel, cancellationToken) =>
            {
                Assert.NotNull(appModel);
 
                throw new DistributedApplicationException(exceptionMessage);
            });
        });
 
        var ex = await Assert.ThrowsAsync<DistributedApplicationException>(async () =>
        {
            var cts = new CancellationTokenSource();
            cts.CancelAfter(TimeSpan.FromMinutes(1));
            await testProgram.RunAsync(cts.Token);
        }).DefaultTimeout();
 
        Assert.Equal(exceptionMessage, ex.Message);
    }
 
    [Fact]
    public async Task MultipleRegisteredLifecycleHooksAreExecuted()
    {
        var exceptionMessage = "Exception from lifecycle hook to prove it ran!";
 
        var signal = (FirstHookExecuted: false, SecondHookExecuted: false);
 
        using var testProgram = CreateTestProgram();
 
        // Lifecycle hook 1
        testProgram.AppBuilder.Services.AddLifecycleHook((sp) =>
        {
            return new CallbackLifecycleHook((app, cancellationToken) =>
            {
                signal.FirstHookExecuted = true;
                return Task.CompletedTask;
            });
        });
 
        // Lifecycle hook 2
        testProgram.AppBuilder.Services.AddLifecycleHook((sp) =>
        {
            return new CallbackLifecycleHook((app, cancellationToken) =>
            {
                signal.SecondHookExecuted = true;
 
                // We still want to throw on the second one to block startup.
                throw new DistributedApplicationException(exceptionMessage);
            });
        });
 
        var ex = await Assert.ThrowsAsync<DistributedApplicationException>(async () =>
        {
            var cts = new CancellationTokenSource();
            cts.CancelAfter(TimeSpan.FromMinutes(1));
            await testProgram.RunAsync(cts.Token);
        }).DefaultTimeout();
 
        Assert.Equal(exceptionMessage, ex.Message);
        Assert.True(signal.FirstHookExecuted);
        Assert.True(signal.SecondHookExecuted);
    }
 
    [Fact]
    public void RegisteredLifecycleHookIsExecutedWhenRunSynchronously()
    {
        var exceptionMessage = "Exception from lifecycle hook to prove it ran!";
 
        using var testProgram = CreateTestProgram();
        testProgram.AppBuilder.Services.AddLifecycleHook((sp) =>
        {
            return new CallbackLifecycleHook((appModel, cancellationToken) =>
            {
                Assert.NotNull(appModel);
 
                throw new DistributedApplicationException(exceptionMessage);
            });
        });
 
        var ex = Assert.Throws<AggregateException>(testProgram.Run);
        Assert.IsType<DistributedApplicationException>(ex.InnerExceptions.First());
        Assert.Equal(exceptionMessage, ex.InnerExceptions.First().Message);
    }
 
    [Fact]
    public void TryAddWillNotAddTheSameLifecycleHook()
    {
        using var testProgram = CreateTestProgram();
 
        var callback1 = (IServiceProvider sp) => new DummyLifecycleHook();
        testProgram.AppBuilder.Services.TryAddLifecycleHook(callback1);
 
        var callback2 = (IServiceProvider sp) => new DummyLifecycleHook();
        testProgram.AppBuilder.Services.TryAddLifecycleHook(callback2);
 
        var lifecycleHookDescriptors = testProgram.AppBuilder.Services.Where(sd => sd.ServiceType == typeof(IDistributedApplicationLifecycleHook));
 
        Assert.Single(lifecycleHookDescriptors.Where(sd => sd.ImplementationFactory == callback1));
        Assert.DoesNotContain(lifecycleHookDescriptors, sd => sd.ImplementationFactory == callback2);
    }
 
    [Fact]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task AllocatedPortsAssignedAfterHookRuns()
    {
        using var testProgram = CreateTestProgram();
        var tcs = new TaskCompletionSource<DistributedApplicationModel>(TaskCreationOptions.RunContinuationsAsynchronously);
        testProgram.AppBuilder.Services.AddLifecycleHook(sp => new CheckAllocatedEndpointsLifecycleHook(tcs));
 
        await using var app = testProgram.Build();
 
        await app.StartAsync().DefaultTimeout();
 
        var appModel = await tcs.Task.DefaultTimeout();
 
        foreach (var item in appModel.Resources)
        {
            if (item is IResourceWithEndpoints resourceWithEndpoints)
            {
                Assert.True(resourceWithEndpoints.GetEndpoints().All(e => e.IsAllocated));
            }
        }
    }
 
    private sealed class CheckAllocatedEndpointsLifecycleHook(TaskCompletionSource<DistributedApplicationModel> tcs) : IDistributedApplicationLifecycleHook
    {
        public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
        {
            tcs.TrySetResult(appModel);
 
            return Task.CompletedTask;
        }
    }
 
    [Fact]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task TestServicesWithMultipleReplicas()
    {
        var replicaCount = 3;
 
        using var testProgram = CreateTestProgram();
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        testProgram.ServiceBBuilder.WithReplicas(replicaCount);
 
        await using var app = testProgram.Build();
 
        var logger = app.Services.GetRequiredService<ILogger<DistributedApplicationTests>>();
 
        await app.StartAsync().DefaultTimeout();
 
        logger.LogInformation("Make sure services A and C are running");
        using var clientA = app.CreateHttpClient(testProgram.ServiceABuilder.Resource.Name, "http");
        using var clientC = app.CreateHttpClient(testProgram.ServiceCBuilder.Resource.Name, "http");
 
        await Task.WhenAll(clientA.GetStringAsync("/pid"), clientC.GetStringAsync("/pid")).DefaultTimeout(TestConstants.LongTimeoutDuration);
 
        // We should get 3 distinct PIDs from service B
        Dictionary<int, bool> pids = [];
 
        var uri = app.GetEndpoint(testProgram.ServiceBBuilder.Resource.Name, "http");
 
        var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource();
        while (!cts.IsCancellationRequested)
        {
            using var clientB = new HttpClient();
            var url = $"{uri}pid";
            logger.LogInformation("Calling PID API at {Url}", url);
            var pidText = await clientB.GetStringAsync(url).DefaultTimeout();
            if (!string.IsNullOrEmpty(pidText))
            {
                var pid = int.Parse(pidText, CultureInfo.InvariantCulture);
                if (pids.TryAdd(pid, true))
                {
                    logger.LogInformation("PID API returned new value: {PID}", pid);
 
                    if (pids.Count == replicaCount)
                    {
                        logger.LogInformation("Success! We heard from all {ReplicaCount} replicas.", replicaCount);
                        break;
                    }
                }
            }
 
            await Task.Delay(100);
        }
 
        Assert.Equal(3, pids.Count);
    }
 
    [Fact]
    [RequiresDocker]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task VerifyDockerAppWorks()
    {
        using var testProgram = CreateTestProgram();
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        testProgram.AppBuilder.AddContainer("redis-cli", "redis")
            .WithArgs("redis-cli", "-h", "host.docker.internal", "-p", "9999", "MONITOR")
            .WithContainerRuntimeArgs("--add-host", "testlocalhost:127.0.0.1");
 
        await using var app = testProgram.Build();
 
        await app.StartAsync().DefaultTimeout();
 
        var s = app.Services.GetRequiredService<IKubernetesService>();
        var list = await s.ListAsync<Container>().DefaultTimeout();
 
        Assert.Collection(list,
            item =>
            {
                Assert.Equal("redis:latest", item.Spec.Image);
                Assert.Equal(["redis-cli", "-h", "host.docker.internal", "-p", "9999", "MONITOR"], item.Spec.Args);
                Assert.Equal(["--add-host", "testlocalhost:127.0.0.1"], item.Spec.RunArgs);
            });
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    [RequiresDocker]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task VerifyContainerStopStartWorks()
    {
        using var testProgram = CreateTestProgram(randomizePorts: false);
 
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        testProgram.AppBuilder.AddContainer("redis0", "redis")
            .WithEndpoint(targetPort: 6379, name: "tcp", env: "REDIS_PORT");
 
        await using var app = testProgram.Build();
 
        var kubernetes = app.Services.GetRequiredService<IKubernetesService>();
        var applicationExecutor = app.Services.GetRequiredService<ApplicationExecutor>();
        var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
 
        await app.StartAsync().DefaultTimeout();
 
        using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(TestConstants.LongTimeoutDuration);
        var token = cts.Token;
 
        var containerPattern = $"redis0-{ReplicaIdRegex}-{suffix}";
        var redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync<Container>(kubernetes, containerPattern, r => r.Status?.State == ContainerState.Running, token).DefaultTimeout(TestConstants.LongTimeoutDuration);
        Assert.NotNull(redisContainer);
 
        await applicationExecutor.StopResourceAsync(redisContainer.Metadata.Name, token).DefaultTimeout();
 
        redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync<Container>(kubernetes, containerPattern, r => r.Status?.State == ContainerState.Exited, token).DefaultTimeout(TestConstants.LongTimeoutDuration);
        Assert.NotNull(redisContainer);
 
        // TODO: Container start has issues in DCP. Waiting for fix.
        //await applicationExecutor.StartResourceAsync(redisContainer.Metadata.Name, token);
 
        //redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync<Container>(kubernetes, containerPattern, r => r.Status?.State == ContainerState.Running, token);
        //Assert.NotNull(redisContainer);
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task VerifyExecutableStopStartWorks()
    {
        using var testProgram = CreateTestProgram(randomizePorts: false);
 
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        await using var app = testProgram.Build();
 
        var kubernetes = app.Services.GetRequiredService<IKubernetesService>();
        var applicationExecutor = app.Services.GetRequiredService<ApplicationExecutor>();
        var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
 
        await app.StartAsync().DefaultTimeout();
 
        var executablePattern = $"servicea-{ReplicaIdRegex}-{suffix}";
        var serviceA = await KubernetesHelper.GetResourceByNameMatchAsync<Executable>(kubernetes, executablePattern, r => r.Status?.State == ExecutableState.Running).DefaultTimeout(TestConstants.LongTimeoutDuration);
        Assert.NotNull(serviceA);
 
        await applicationExecutor.StopResourceAsync(serviceA.Metadata.Name, CancellationToken.None).DefaultTimeout();
 
        serviceA = await KubernetesHelper.GetResourceByNameMatchAsync<Executable>(kubernetes, executablePattern, r => r.Status?.State == ExecutableState.Finished).DefaultTimeout(TestConstants.LongTimeoutDuration);
        Assert.NotNull(serviceA);
 
        await applicationExecutor.StartResourceAsync(serviceA.Metadata.Name, CancellationToken.None).DefaultTimeout();
 
        serviceA = await KubernetesHelper.GetResourceByNameMatchAsync<Executable>(kubernetes, executablePattern, r => r.Status?.State == ExecutableState.Running).DefaultTimeout(TestConstants.LongTimeoutDuration);
        Assert.NotNull(serviceA);
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    [RequiresDocker]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task SpecifyingEnvPortInEndpointFlowsToEnv()
    {
        using var testProgram = CreateTestProgram(randomizePorts: false);
 
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        testProgram.ServiceABuilder
            .WithHttpEndpoint(name: "http0", env: "PORT0");
 
        testProgram.AppBuilder.AddContainer("redis0", "redis")
            .WithEndpoint(targetPort: 6379, name: "tcp", env: "REDIS_PORT");
 
        testProgram.AppBuilder.AddNodeApp("nodeapp", "fakePath")
            .WithHttpEndpoint(port: 5031, env: "PORT");
 
        await using var app = testProgram.Build();
 
        var kubernetes = app.Services.GetRequiredService<IKubernetesService>();
 
        await app.StartAsync().DefaultTimeout();
 
        var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
        var redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync<Container>(kubernetes, $"redis0-{ReplicaIdRegex}-{suffix}", r => r.Status?.EffectiveEnv is not null).DefaultTimeout();
        Assert.NotNull(redisContainer);
 
        var serviceA = await KubernetesHelper.GetResourceByNameAsync<Executable>(kubernetes, "servicea", suffix!, r => r.Status?.EffectiveEnv is not null).DefaultTimeout();
        Assert.NotNull(serviceA);
 
        var nodeApp = await KubernetesHelper.GetResourceByNameMatchAsync<Executable>(kubernetes, $"nodeapp-{ReplicaIdRegex}-{suffix}", r => r.Status?.EffectiveEnv is not null).DefaultTimeout();
        Assert.NotNull(nodeApp);
 
        Assert.Equal("redis:latest", redisContainer.Spec.Image);
        Assert.Equal("6379", GetEnv(redisContainer.Spec.Env, "REDIS_PORT"));
        Assert.Equal("6379", GetEnv(redisContainer.Status!.EffectiveEnv, "REDIS_PORT"));
 
        Assert.Equal($"{{{{- portForServing \"servicea-http0-{suffix}\" -}}}}", GetEnv(serviceA.Spec.Env, "PORT0"));
        var serviceAPortValue = GetEnv(serviceA.Status!.EffectiveEnv, "PORT0");
        Assert.False(string.IsNullOrEmpty(serviceAPortValue));
        Assert.NotEqual(0, int.Parse(serviceAPortValue, CultureInfo.InvariantCulture));
 
        Assert.Equal($"{{{{- portForServing \"nodeapp-{suffix}\" -}}}}", GetEnv(nodeApp.Spec.Env, "PORT"));
        var nodeAppPortValue = GetEnv(nodeApp.Status!.EffectiveEnv, "PORT");
        Assert.False(string.IsNullOrEmpty(nodeAppPortValue));
        Assert.NotEqual(0, int.Parse(nodeAppPortValue, CultureInfo.InvariantCulture));
 
        await app.StopAsync().DefaultTimeout();
 
        static string? GetEnv(IEnumerable<EnvVar>? envVars, string name)
        {
            Assert.NotNull(envVars);
            return Assert.Single(envVars.Where(e => e.Name == name)).Value;
        }
    }
 
    [Fact]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task StartAsync_DashboardAuthConfig_PassedToDashboardProcess()
    {
        var browserToken = "ThisIsATestToken";
        var args = new string[] {
            "ASPNETCORE_URLS=http://localhost:0",
            "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL=http://localhost:0",
            $"DOTNET_DASHBOARD_FRONTEND_BROWSERTOKEN={browserToken}"
        };
        using var testProgram = CreateTestProgram(args: args, disableDashboard: false);
 
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        await using var app = testProgram.Build();
 
        var kubernetes = app.Services.GetRequiredService<IKubernetesService>();
 
        await app.StartAsync().DefaultTimeout();
 
        var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
        var aspireDashboard = await KubernetesHelper.GetResourceByNameMatchAsync<Executable>(kubernetes, $"aspire-dashboard-{ReplicaIdRegex}-{suffix}", r => r.Status?.EffectiveEnv is not null).DefaultTimeout();
        Assert.NotNull(aspireDashboard);
 
        Assert.Equal("BrowserToken", GetEnv(aspireDashboard.Spec.Env, "DASHBOARD__FRONTEND__AUTHMODE"));
        Assert.Equal("ThisIsATestToken", GetEnv(aspireDashboard.Spec.Env, "DASHBOARD__FRONTEND__BROWSERTOKEN"));
 
        Assert.Equal("ApiKey", GetEnv(aspireDashboard.Spec.Env, "DASHBOARD__OTLP__AUTHMODE"));
        var keyBytes = Convert.FromHexString(GetEnv(aspireDashboard.Spec.Env, "DASHBOARD__OTLP__PRIMARYAPIKEY")!);
        Assert.Equal(16, keyBytes.Length);
 
        await app.StopAsync().DefaultTimeout();
 
        static string? GetEnv(IEnumerable<EnvVar>? envVars, string name)
        {
            Assert.NotNull(envVars);
            return Assert.Single(envVars.Where(e => e.Name == name)).Value;
        }
    }
 
    [Fact]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task StartAsync_UnsecuredAllowAnonymous_PassedToDashboardProcess()
    {
        var args = new string[] {
            "ASPNETCORE_URLS=http://localhost:0",
            "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL=http://localhost:0",
            "DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true"
        };
        using var testProgram = CreateTestProgram(args: args, disableDashboard: false);
 
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        await using var app = testProgram.Build();
 
        var kubernetes = app.Services.GetRequiredService<IKubernetesService>();
 
        await app.StartAsync().DefaultTimeout();
 
        var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
        var aspireDashboard = await KubernetesHelper.GetResourceByNameMatchAsync<Executable>(kubernetes, $"aspire-dashboard-{ReplicaIdRegex}-{suffix}", r => r.Status?.EffectiveEnv is not null).DefaultTimeout();
        Assert.NotNull(aspireDashboard);
 
        Assert.Equal("Unsecured", GetEnv(aspireDashboard.Spec.Env, "DASHBOARD__FRONTEND__AUTHMODE"));
        Assert.Equal("Unsecured", GetEnv(aspireDashboard.Spec.Env, "DASHBOARD__OTLP__AUTHMODE"));
 
        await app.StopAsync().DefaultTimeout();
 
        static string? GetEnv(IEnumerable<EnvVar>? envVars, string name)
        {
            Assert.NotNull(envVars);
            return Assert.Single(envVars.Where(e => e.Name == name)).Value;
        }
    }
 
    [Fact]
    [RequiresDocker]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task VerifyDockerWithEntrypointWorks()
    {
        using var testProgram = CreateTestProgram();
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        testProgram.AppBuilder.AddContainer("redis-cli", "redis")
            .WithEntrypoint("bob");
 
        await using var app = testProgram.Build();
 
        await app.StartAsync().DefaultTimeout();
 
        var s = app.Services.GetRequiredService<IKubernetesService>();
 
        var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
        var redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync<Container>(s, $"redis-cli-{ReplicaIdRegex}-{suffix}",
            r => r.Status?.State == ContainerState.FailedToStart && (r.Status?.Message.Contains("bob") ?? false)).DefaultTimeout(TestConstants.LongTimeoutDuration);
 
        Assert.NotNull(redisContainer);
        Assert.Equal("redis:latest", redisContainer.Spec.Image);
        Assert.Equal("bob", redisContainer.Spec.Command);
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    [RequiresDocker]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task VerifyDockerWithBindMountWorksWithAbsolutePaths()
    {
        using var testProgram = CreateTestProgram();
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        var sourcePath = Path.GetFullPath("/etc/path-here");
        testProgram.AppBuilder.AddContainer("redis-cli", "redis")
            .WithBindMount(sourcePath, "path-here");
 
        await using var app = testProgram.Build();
 
        await app.StartAsync().DefaultTimeout();
 
        var s = app.Services.GetRequiredService<IKubernetesService>();
 
        var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
        var redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync<Container>(
                s,
                $"redis-cli-{ReplicaIdRegex}-{suffix}", r => r.Spec.VolumeMounts != null).DefaultTimeout();
 
        Assert.NotNull(redisContainer.Spec.VolumeMounts);
        Assert.NotEmpty(redisContainer.Spec.VolumeMounts);
        Assert.Equal(sourcePath, redisContainer.Spec.VolumeMounts[0].Source);
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    [RequiresDocker]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task VerifyDockerWithBindMountWorksWithRelativePaths()
    {
        using var testProgram = CreateTestProgram();
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        testProgram.AppBuilder.AddContainer("redis-cli", "redis")
            .WithBindMount("etc/path-here", "path-here");
 
        await using var app = testProgram.Build();
 
        await app.StartAsync().DefaultTimeout();
 
        var s = app.Services.GetRequiredService<IKubernetesService>();
 
        var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
        var redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync<Container>(
            s,
            $"redis-cli-{ReplicaIdRegex}-{suffix}", r => r.Spec.VolumeMounts != null).DefaultTimeout();
 
        Assert.NotNull(redisContainer.Spec.VolumeMounts);
        Assert.NotEmpty(redisContainer.Spec.VolumeMounts);
        Assert.NotEqual("etc/path-here", redisContainer.Spec.VolumeMounts[0].Source);
        Assert.True(Path.IsPathRooted(redisContainer.Spec.VolumeMounts[0].Source));
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    [RequiresDocker]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task VerifyDockerWithVolumeWorksWithName()
    {
        using var testProgram = CreateTestProgram();
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        testProgram.AppBuilder.AddContainer("redis-cli", "redis")
            .WithVolume("test-volume-name", "/path-here");
 
        await using var app = testProgram.Build();
 
        await app.StartAsync().DefaultTimeout();
 
        var s = app.Services.GetRequiredService<IKubernetesService>();
 
        var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
        var redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync<Container>(
                s,
                $"redis-cli-{ReplicaIdRegex}-{suffix}", r => r.Spec.VolumeMounts != null).DefaultTimeout();
 
        Assert.NotNull(redisContainer.Spec.VolumeMounts);
        Assert.NotEmpty(redisContainer.Spec.VolumeMounts);
        Assert.Equal("test-volume-name", redisContainer.Spec.VolumeMounts[0].Source);
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    [RequiresDocker]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task KubernetesHasResourceNameForContainersAndExes()
    {
        using var testProgram = CreateTestProgram(includeIntegrationServices: true);
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        await using var app = testProgram.Build();
 
        await app.StartAsync().DefaultTimeout();
 
        var s = app.Services.GetRequiredService<IKubernetesService>();
 
        var expectedExeResources = new HashSet<string>()
        {
            "servicea",
            "serviceb",
            "servicec",
            "workera",
            "integrationservicea"
        };
 
        var expectedContainerResources = new HashSet<string>()
        {
            "redis",
            "postgres"
        };
 
        await foreach (var resource in s.WatchAsync<Container>().DefaultTimeout())
        {
            Assert.True(resource.Item2.Metadata.Annotations.TryGetValue(Container.ResourceNameAnnotation, out var value));
            if (expectedContainerResources.Contains(value))
            {
                expectedContainerResources.Remove(value);
            }
 
            if (expectedContainerResources.Count == 0)
            {
                break;
            }
        }
 
        await foreach (var resource in s.WatchAsync<Executable>().DefaultTimeout())
        {
            Assert.True(resource.Item2.Metadata.Annotations.TryGetValue(Executable.ResourceNameAnnotation, out var value));
            if (expectedExeResources.Contains(value))
            {
                expectedExeResources.Remove(value);
            }
 
            if (expectedExeResources.Count == 0)
            {
                break;
            }
        }
    }
 
    [Fact]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task ReplicasAndProxylessEndpointThrows()
    {
        using var testProgram = CreateTestProgram();
        testProgram.ServiceABuilder.WithReplicas(2).WithEndpoint("http", endpoint =>
        {
            endpoint.IsProxied = false;
        });
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        await using var app = testProgram.Build();
 
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => app.StartAsync()).DefaultTimeout();
        var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
        Assert.Equal($"Resource 'servicea-{suffix}' uses multiple replicas and a proxy-less endpoint 'http'. These features do not work together.", ex.Message);
    }
 
    [Fact]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task ProxylessEndpointWithoutPortThrows()
    {
        using var testProgram = CreateTestProgram();
        testProgram.ServiceABuilder.WithEndpoint("http", endpoint =>
        {
            endpoint.Port = null;
            endpoint.IsProxied = false;
        });
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        await using var app = testProgram.Build();
 
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => app.StartAsync()).DefaultTimeout();
        var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
        Assert.Equal($"Service 'servicea-{suffix}' needs to specify a port for endpoint 'http' since it isn't using a proxy.", ex.Message);
    }
 
    [Fact]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task ProxylessEndpointWorks()
    {
        using var testProgram = CreateTestProgram();
 
        testProgram.ServiceABuilder
            .WithEndpoint("http", e =>
            {
                e.Port = 1234;
                e.TargetPort = 1234;
                e.IsProxied = false;
            });
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        await using var app = testProgram.Build();
        await app.StartAsync().DefaultTimeout();
 
        var client = app.CreateHttpClientWithResilience("servicea", "http");
 
        var result = await client.GetStringAsync("pid").DefaultTimeout(TestConstants.LongTimeoutDuration);
        Assert.NotNull(result);
 
        // Check that endpoint from launchsettings doesn't work
        await Assert.ThrowsAnyAsync<Exception>(async () =>
        {
            using var client2 = new HttpClient(new SocketsHttpHandler
            {
                // Provide a timeout to avoid long timeout while trying to connect.
                ConnectTimeout = TimeSpan.FromSeconds(2)
            });
            await client2.GetStringAsync("http://localhost:5156/pid");
        }).DefaultTimeout();
    }
 
    [Fact]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4599", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task ProxylessAndProxiedEndpointBothWorkOnSameResource()
    {
        using var testProgram = CreateTestProgram();
 
        testProgram.ServiceABuilder
            .WithEndpoint("http", e =>
            {
                e.Port = 1234;
                e.TargetPort = 1234;
                e.IsProxied = false;
            }, createIfNotExists: false)
            .WithEndpoint("https", e =>
            {
                e.UriScheme = "https";
                e.Port = 1543;
            }, createIfNotExists: true);
 
        testProgram.AppBuilder.Services.AddLogging(b => b.AddXunit(_testOutputHelper));
 
        await using var app = testProgram.Build();
 
        await app.StartAsync().DefaultTimeout();
 
        using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(TestConstants.LongTimeoutDuration);
        var token = cts.Token;
 
        var urls = string.Empty;
        var httpEndPoint = app.GetEndpoint(testProgram.ServiceABuilder.Resource.Name, endpointName: "http");
        while (true)
        {
            try
            {
                using var client = new HttpClient();
                urls = await client.GetStringAsync($"{httpEndPoint}urls", token);
                break;
            }
            catch
            {
                await Task.Delay(100, token);
            }
        }
 
        Assert.Contains(httpEndPoint.ToString().Trim('/'), urls);
 
        // https endpoint is proxied so app won't have this specific endpoint
        var httpsEndpoint = app.GetEndpoint(testProgram.ServiceABuilder.Resource.Name, endpointName: "https");
        Assert.DoesNotContain(httpsEndpoint.ToString().Trim('/'), urls);
 
        while (true)
        {
            try
            {
                using var client = new HttpClient();
                var value = await client.GetStringAsync($"{httpsEndpoint}urls", token).DefaultTimeout();
                Assert.Equal(urls, value);
                break;
            }
            catch (Exception ex) when (ex is not EqualException)
            {
                await Task.Delay(100, token);
            }
        }
    }
 
    [Fact]
    [RequiresDocker]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task ProxylessContainerCanBeReferenced()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var redis = builder.AddRedis("redis", 1234).WithEndpoint("tcp", endpoint =>
        {
            endpoint.IsProxied = false;
        });
 
        // Since port is not specified, this instance will use the container target port (6379) as the host port.
        var redisNoPort = builder.AddRedis("redisNoPort").WithEndpoint("tcp", endpoint =>
        {
            endpoint.IsProxied = false;
        });
        var servicea = builder.AddProject<Projects.ServiceA>("servicea")
            .WithReference(redis)
            .WithReference(redisNoPort);
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        // Wait for the application to be ready
        await app.WaitForTextAsync("Application started.").DefaultTimeout();
 
        // Wait until the service itself starts.
        using var clientA = app.CreateHttpClient(servicea.Resource.Name, "http");
        await clientA.GetStringAsync("/").DefaultTimeout();
 
        var s = app.Services.GetRequiredService<IKubernetesService>();
        var exeList = await s.ListAsync<Executable>().DefaultTimeout();
 
        var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
        Assert.NotNull(suffix);
        var service = Assert.Single(exeList.Where(c => "servicea".Equals(c.AppModelResourceName) && c.Name().Contains(suffix)));
        var env = Assert.Single(service.Spec.Env!.Where(e => e.Name == "ConnectionStrings__redis"));
        Assert.Equal("localhost:1234", env.Value);
 
        var list = await s.ListAsync<Container>().DefaultTimeout();
        var redisContainer = Assert.Single(list.Where(c => Regex.IsMatch(c.Name(),$"redis-{ReplicaIdRegex}-{suffix}"))) ;
        Assert.Equal(1234, Assert.Single(redisContainer.Spec.Ports!).HostPort);
 
        var otherRedisEnv = Assert.Single(service.Spec.Env!.Where(e => e.Name == "ConnectionStrings__redisNoPort"));
        Assert.Equal("localhost:6379", otherRedisEnv.Value);
 
        var otherRedisContainer = Assert.Single(list.Where(c => Regex.IsMatch(c.Name(), $"redisNoPort-{ReplicaIdRegex}-{suffix}")));
        Assert.Equal(6379, Assert.Single(otherRedisContainer.Spec.Ports!).HostPort);
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    [RequiresDocker]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task ProxylessContainerWithoutPortThrows()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var redis = builder.AddContainer("dummyRedis", "redis").WithEndpoint("tcp", endpoint =>
        {
            endpoint.IsProxied = false;
        });
 
        using var app = builder.Build();
 
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => app.StartAsync()).DefaultTimeout();
        var suffix = app.Services.GetRequiredService<IOptions<DcpOptions>>().Value.ResourceNameSuffix;
        Assert.Equal($"The endpoint 'tcp' for container resource 'dummyRedis-{suffix}' must specify the TargetPort value", ex.Message);
    }
 
    [Fact]
    [RequiresDocker]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/4651", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnCI))]
    public async Task AfterResourcesCreatedLifecycleHookWorks()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        builder.AddRedis("redis");
        builder.Services.TryAddLifecycleHook<KubernetesTestLifecycleHook>();
 
        using var app = builder.Build();
 
        var s = app.Services.GetRequiredService<IKubernetesService>();
        var lifecycles = app.Services.GetServices<IDistributedApplicationLifecycleHook>();
        var kubernetesLifecycle = (KubernetesTestLifecycleHook)lifecycles.Where(l => l.GetType() == typeof(KubernetesTestLifecycleHook)).First();
        kubernetesLifecycle.KubernetesService = s;
 
        await app.StartAsync().DefaultTimeout();
 
        await kubernetesLifecycle.HooksCompleted.DefaultTimeout();
    }
 
    private sealed class KubernetesTestLifecycleHook : IDistributedApplicationLifecycleHook
    {
        private readonly TaskCompletionSource _tcs = new();
 
        public IKubernetesService? KubernetesService { get; set; }
 
        public Task HooksCompleted => _tcs.Task;
 
        public async Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken)
        {
            Assert.Empty(await KubernetesService!.ListAsync<Container>(cancellationToken: cancellationToken));
        }
 
        public async Task AfterResourcesCreatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken)
        {
            Assert.NotEmpty(await KubernetesService!.ListAsync<Container>(cancellationToken: cancellationToken));
            _tcs.SetResult();
        }
    }
 
    private static TestProgram CreateTestProgram(
        string[]? args = null,
        bool includeIntegrationServices = false,
        bool disableDashboard = true,
        bool randomizePorts = true) =>
        TestProgram.Create<DistributedApplicationTests>(
            args,
            includeIntegrationServices: includeIntegrationServices,
            disableDashboard: disableDashboard,
            randomizePorts: randomizePorts);
}