File: Dcp\ConfigureDefaultDcpOptionsTests.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 Aspire.Hosting.Dcp;
using Microsoft.Extensions.Configuration;
 
namespace Aspire.Hosting.Tests.Dcp;
 
public class ConfigureDefaultDcpOptionsTests
{
    [Fact]
    public void ExplicitEmptyInvocationOverrideClearsLegacyDispatcherArguments()
    {
        var options = ConfigureWithDcpPublisher(new()
        {
            ["ASPIRE_TERMINAL_HOST_PATH"] = "/native/Aspire.TerminalHost",
            ["ASPIRE_TERMINAL_HOST_INVOCATION_ARGS"] = string.Empty,
            ["DcpPublisher:TerminalHostInvocationArgs"] = "terminalhost"
        });
 
        Assert.Equal("/native/Aspire.TerminalHost", options.TerminalHostPath);
        Assert.Equal(string.Empty, options.TerminalHostInvocationArgs);
    }
 
    [Fact]
    public void KubernetesApiTimeoutDefaultsToFortySecondsWithTwentyAdditionalInitializationSeconds()
    {
        var options = ConfigureWithDcpPublisher([]);
 
        Assert.Equal(TimeSpan.FromSeconds(20), options.KubernetesInitializationAdditionalTimeout);
        Assert.Equal(TimeSpan.FromSeconds(40), options.KubernetesApiTimeout);
        Assert.Equal(TimeSpan.FromSeconds(60), options.KubernetesApiTimeout + options.KubernetesInitializationAdditionalTimeout);
    }
 
    [Fact]
    public void KubernetesApiTimeoutsCanBeConfigured()
    {
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:KubernetesInitializationAdditionalTimeout"] = "00:00:30",
            ["DcpPublisher:KubernetesApiTimeout"] = "00:00:50",
        });
 
        Assert.Equal(TimeSpan.FromSeconds(30), options.KubernetesInitializationAdditionalTimeout);
        Assert.Equal(TimeSpan.FromSeconds(50), options.KubernetesApiTimeout);
        Assert.Equal(TimeSpan.FromSeconds(80), options.KubernetesApiTimeout + options.KubernetesInitializationAdditionalTimeout);
    }
 
    [Fact]
    public void KubernetesApiTimeoutOverrideAlsoIncreasesInitializationBudget()
    {
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:KubernetesApiTimeout"] = "00:00:50",
        });
 
        Assert.Equal(TimeSpan.FromSeconds(20), options.KubernetesInitializationAdditionalTimeout);
        Assert.Equal(TimeSpan.FromSeconds(70), options.KubernetesApiTimeout + options.KubernetesInitializationAdditionalTimeout);
    }
 
    [Theory]
    [InlineData("00:00:00")]
    [InlineData("-00:00:01")]
    [InlineData("00:00:00.999")]
    [InlineData("00:10:00.001")]
    [InlineData("00:00:00.010")]
    [InlineData("1.00:00:00")]
    public void KubernetesApiTimeoutMustBeWithinSupportedRange(string configuredTimeout)
    {
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:KubernetesApiTimeout"] = configuredTimeout,
        });
        var validator = new ValidateDcpOptions(new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish));
 
        var result = validator.Validate(null, options);
 
        Assert.True(result.Failed);
        Assert.Equal("Property KubernetesApiTimeout: The Kubernetes API timeout must be between one second and ten minutes.", Assert.Single(result.Failures!));
    }
 
    [Theory]
    [InlineData("00:00:01", 1000)]
    [InlineData("00:10:00", 600000)]
    public void KubernetesApiTimeoutAcceptsSupportedBoundaries(string configuredTimeout, int expectedMilliseconds)
    {
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:KubernetesInitializationAdditionalTimeout"] = "00:00:00",
            ["DcpPublisher:KubernetesApiTimeout"] = configuredTimeout,
        });
        var validator = new ValidateDcpOptions(new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish));
 
        var result = validator.Validate(null, options);
 
        Assert.True(result.Succeeded);
        Assert.Equal(TimeSpan.Zero, options.KubernetesInitializationAdditionalTimeout);
        Assert.Equal(TimeSpan.FromMilliseconds(expectedMilliseconds), options.KubernetesApiTimeout);
    }
 
    [Theory]
    [InlineData("00:00:40", "00:00:00", 40000)]
    [InlineData("00:00:40", "00:09:20", 600000)]
    [InlineData("00:00:01", "00:09:59", 600000)]
    public void KubernetesInitializationAdditionalTimeoutAcceptsSupportedBoundaries(string apiTimeout, string additionalTimeout, int expectedMilliseconds)
    {
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:KubernetesApiTimeout"] = apiTimeout,
            ["DcpPublisher:KubernetesInitializationAdditionalTimeout"] = additionalTimeout,
        });
        var validator = new ValidateDcpOptions(new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish));
 
        var result = validator.Validate(null, options);
 
        Assert.True(result.Succeeded);
        Assert.Equal(TimeSpan.FromMilliseconds(expectedMilliseconds), options.KubernetesApiTimeout + options.KubernetesInitializationAdditionalTimeout);
    }
 
    [Theory]
    [InlineData("-00:00:00.001")]
    [InlineData("-00:00:01")]
    public void KubernetesInitializationAdditionalTimeoutMustBeNonNegative(string configuredTimeout)
    {
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:KubernetesInitializationAdditionalTimeout"] = configuredTimeout,
        });
        var validator = new ValidateDcpOptions(new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish));
 
        var result = validator.Validate(null, options);
 
        Assert.True(result.Failed);
        Assert.Equal("Property KubernetesInitializationAdditionalTimeout: The Kubernetes additional initialization timeout must be non-negative.", Assert.Single(result.Failures!));
    }
 
    [Theory]
    [InlineData("00:00:40", "00:09:20.001")]
    [InlineData("00:10:00", "00:00:00.001")]
    [InlineData("00:00:01", "00:10:00")]
    public void KubernetesInitializationBudgetMustNotExceedTenMinutes(string apiTimeout, string additionalTimeout)
    {
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:KubernetesApiTimeout"] = apiTimeout,
            ["DcpPublisher:KubernetesInitializationAdditionalTimeout"] = additionalTimeout,
        });
        var validator = new ValidateDcpOptions(new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish));
 
        var result = validator.Validate(null, options);
 
        Assert.True(result.Failed);
        Assert.Equal("Property KubernetesInitializationAdditionalTimeout: The combined Kubernetes API and additional initialization timeouts must not exceed ten minutes.", Assert.Single(result.Failures!));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void KubernetesInitializationBudgetValidationDoesNotOverflow(bool apiTimeoutAlsoOverflows)
    {
        var options = ConfigureWithDcpPublisher([]);
        options.KubernetesInitializationAdditionalTimeout = TimeSpan.MaxValue;
        if (apiTimeoutAlsoOverflows)
        {
            options.KubernetesApiTimeout = TimeSpan.MaxValue;
        }
        var validator = new ValidateDcpOptions(new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish));
 
        var result = validator.Validate(null, options);
 
        Assert.True(result.Failed);
        Assert.Equal(apiTimeoutAlsoOverflows
            ? "Property KubernetesApiTimeout: The Kubernetes API timeout must be between one second and ten minutes."
            : "Property KubernetesInitializationAdditionalTimeout: The combined Kubernetes API and additional initialization timeouts must not exceed ten minutes.",
            Assert.Single(result.Failures!));
    }
 
    [Fact]
    public void KubernetesCreateRecoveryTimeoutDefaultsToTwoMinutes()
    {
        Assert.Equal(TimeSpan.FromMinutes(2), ConfigureWithDcpPublisher([]).KubernetesCreateRecoveryTimeout);
    }
 
    [Fact]
    public void KubernetesCreateRecoveryTimeoutCanBeConfigured()
    {
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:KubernetesCreateRecoveryTimeout"] = "00:03:00",
        });
 
        Assert.Equal(TimeSpan.FromMinutes(3), options.KubernetesCreateRecoveryTimeout);
    }
 
    [Theory]
    [InlineData("00:00:00")]
    [InlineData("-00:00:01")]
    [InlineData("00:00:00.999")]
    [InlineData("00:10:00.001")]
    [InlineData("00:00:00.010")]
    [InlineData("1.00:00:00")]
    public void KubernetesCreateRecoveryTimeoutMustBeWithinSupportedRange(string configuredTimeout)
    {
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:KubernetesCreateRecoveryTimeout"] = configuredTimeout,
        });
        var validator = new ValidateDcpOptions(new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish));
 
        var result = validator.Validate(null, options);
 
        Assert.True(result.Failed);
        Assert.Equal("Property KubernetesCreateRecoveryTimeout: The Kubernetes create recovery timeout must be between one second and ten minutes.", Assert.Single(result.Failures!));
    }
 
    [Theory]
    [InlineData("00:00:01", 1000)]
    [InlineData("00:10:00", 600000)]
    public void KubernetesCreateRecoveryTimeoutAcceptsSupportedBoundaries(string configuredTimeout, int expectedMilliseconds)
    {
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:KubernetesCreateRecoveryTimeout"] = configuredTimeout,
        });
        var validator = new ValidateDcpOptions(new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish));
 
        var result = validator.Validate(null, options);
 
        Assert.True(result.Succeeded);
        Assert.Equal(TimeSpan.FromMilliseconds(expectedMilliseconds), options.KubernetesCreateRecoveryTimeout);
    }
 
    [Fact]
    public void TerminalHostFallsBackToAspireManagedDashboardPath()
    {
        // The CLI bundle launcher (PrebuiltAppHostServer/DotNetAppHostProject) sets
        // ASPIRE_DASHBOARD_PATH to point at the multi-mode aspire-managed exe but doesn't
        // set ASPIRE_TERMINAL_HOST_PATH. This regression test pins the fallback that
        // reuses the aspire-managed binary as the terminal host with "terminalhost" as
        // its dispatcher arg so .WithTerminal() works in TS-based and other prebuilt
        // AppHost scenarios.
        var managedExe = OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed";
        var managedPath = Path.Combine(Path.GetTempPath(), "aspire-fake-bundle", "managed", managedExe);
 
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:DashboardPath"] = managedPath,
        });
 
        Assert.Equal(managedPath, options.DashboardPath);
        Assert.Equal(managedPath, options.TerminalHostPath);
        Assert.Equal("terminalhost", options.TerminalHostInvocationArgs);
    }
    [Fact]
    public void TerminalHostFallbackDoesNotApplyWhenDashboardPathIsNotAspireManaged()
    {
        // Standalone NuGet-package scenario: DashboardPath points at a per-RID dashboard
        // binary. The fallback must not hijack TerminalHostPath in that case (the standalone
        // terminal host nupkg supplies its own aspireterminalhostpath assembly metadata).
        var dashboardDll = Path.Combine(Path.GetTempPath(), "fake-dashboard", "Aspire.Dashboard.dll");
 
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:DashboardPath"] = dashboardDll,
            // Neutralize any ambient aspireterminalhostpath assembly metadata in the test
            // assembly so we can observe the fallback's null behaviour cleanly.
            ["DcpPublisher:TerminalHostPath"] = "  ",
        });
 
        Assert.Equal(dashboardDll, options.DashboardPath);
        // Either the explicit whitespace value falls through to assembly metadata (dev-only,
        // varies by environment) or stays empty. The point is the fallback didn't fire — i.e.
        // TerminalHostPath !== DashboardPath and InvocationArgs wasn't auto-set to "terminalhost".
        Assert.NotEqual(dashboardDll, options.TerminalHostPath);
        Assert.NotEqual("terminalhost", options.TerminalHostInvocationArgs);
    }
 
    [Fact]
    public void TerminalHostFallbackDoesNotOverrideExplicitTerminalHostPath()
    {
        // If both paths are explicitly set (e.g. an integrator points at a separate
        // terminal host binary while still using bundled aspire-managed for the dashboard),
        // honour the explicit TerminalHostPath and don't auto-rewrite it.
        var managedExe = OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed";
        var managedPath = Path.Combine(Path.GetTempPath(), "aspire-fake-bundle", "managed", managedExe);
        var explicitTerminalHostPath = Path.Combine(Path.GetTempPath(), "custom-terminalhost", "Aspire.TerminalHost.exe");
 
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:DashboardPath"] = managedPath,
            ["DcpPublisher:TerminalHostPath"] = explicitTerminalHostPath,
        });
 
        Assert.Equal(managedPath, options.DashboardPath);
        Assert.Equal(explicitTerminalHostPath, options.TerminalHostPath);
        // InvocationArgs left untouched (the explicit path may or may not be aspire-managed;
        // the consumer is responsible for supplying its own dispatcher arg if needed).
        Assert.NotEqual("terminalhost", options.TerminalHostInvocationArgs);
    }
 
    [Fact]
    public void TerminalHostFallbackHonoursExplicitInvocationArgs()
    {
        // If the consumer explicitly sets the InvocationArgs, the bundle fallback must NOT
        // clobber it — the explicit value wins even when we synthesise the path from the
        // dashboard.
        var managedExe = OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed";
        var managedPath = Path.Combine(Path.GetTempPath(), "aspire-fake-bundle", "managed", managedExe);
 
        var options = ConfigureWithDcpPublisher(new()
        {
            ["DcpPublisher:DashboardPath"] = managedPath,
            ["DcpPublisher:TerminalHostInvocationArgs"] = "custom-subcommand",
        });
 
        Assert.Equal(managedPath, options.TerminalHostPath);
        Assert.Equal("custom-subcommand", options.TerminalHostInvocationArgs);
    }
 
    private static DcpOptions ConfigureWithDcpPublisher(Dictionary<string, string?> dcpPublisherSettings)
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(dcpPublisherSettings)
            .Build();
 
        // Point AssemblyName at Aspire.Hosting itself so the resolver picks up an assembly
        // that has no aspire{dashboard,terminalhost}path metadata — without this, the test
        // runner's own assembly metadata (added by the build for inner-loop dev) leaks in
        // and short-circuits the explicit configuration.
        var appOptions = new DistributedApplicationOptions
        {
            AssemblyName = typeof(DcpOptions).Assembly.GetName().Name,
        };
        var configurer = new ConfigureDefaultDcpOptions(appOptions, configuration);
        var options = new DcpOptions();
        configurer.Configure(options);
 
        return options;
    }
}