File: ExpressionResolverTests.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 Aspire.Hosting.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Tests;
 
public class ExpressionResolverTests
{
    [Theory]
    [MemberData(nameof(ResolveInternalAsync_ResolvesCorrectly_MemberData))]
    public async Task ResolveInternalAsync_ResolvesCorrectly(ExpressionResolverTestData testData, Type? exceptionType, (string Value, bool IsSensitive)? expectedValue)
    {
        ValueProviderContext context = new ValueProviderContext()
        {
            Network = testData.SourceIsContainer ? KnownNetworkIdentifiers.DefaultAspireContainerNetwork : KnownNetworkIdentifiers.LocalhostNetwork
        };
        if (exceptionType is not null)
        {
            await Assert.ThrowsAsync(exceptionType, ResolveAsync);
        }
        else
        {
            var resolvedValue = await ExpressionResolver.ResolveAsync(testData.ValueProvider, context, CancellationToken.None);
 
            Assert.Equal(expectedValue?.Value, resolvedValue.Value);
            Assert.Equal(expectedValue?.IsSensitive, resolvedValue.IsSensitive);
        }
 
        async Task<ResolvedValue> ResolveAsync() => await ExpressionResolver.ResolveAsync(testData.ValueProvider, context, CancellationToken.None);
    }
 
    public static TheoryData<ExpressionResolverTestData, Type?, (string? Value, bool IsSensitive)?> ResolveInternalAsync_ResolvesCorrectly_MemberData()
    {
        var data = new TheoryData<ExpressionResolverTestData, Type?, (string? Value, bool IsSensitive)?>();
 
        // doesn't differ by sourceIsContainer
        data.Add(new ExpressionResolverTestData(false, new ConnectionStringReference(new TestExpressionResolverResource("Empty"), false)), typeof(DistributedApplicationException), null);
        data.Add(new ExpressionResolverTestData(false, new ConnectionStringReference(new TestExpressionResolverResource("Empty"), true)), null, (null, false));
        data.Add(new ExpressionResolverTestData(true, new ConnectionStringReference(new TestExpressionResolverResource("String"), true)), null, ("String", false));
        data.Add(new ExpressionResolverTestData(true, new ConnectionStringReference(new TestExpressionResolverResource("SecretParameter"), false)), null, ("SecretParameter", true));
 
        // IResourceWithConnectionString resolves differently for ConnectionStringParameterResource (as a secret parameter)
        data.Add(new ExpressionResolverTestData(false, new ConnectionStringParameterResource("SurrogateResource", _ => "SurrogateResource", null)), null, ("SurrogateResource", true));
        data.Add(new ExpressionResolverTestData(false, new TestExpressionResolverResource("String")), null, ("String", false));
 
        data.Add(new ExpressionResolverTestData(false, new ParameterResource("SecretParameter", _ => "SecretParameter", secret: true)), null, ("SecretParameter", true));
        data.Add(new ExpressionResolverTestData(false, new ParameterResource("NonSecretParameter", _ => "NonSecretParameter", secret: false)), null, ("NonSecretParameter", false));
 
        // ExpressionResolverGeneratesCorrectEndpointStrings separately tests EndpointReference and EndpointReferenceExpression
 
        return data;
    }
 
    public record ExpressionResolverTestData(bool SourceIsContainer, IValueProvider ValueProvider);
 
    [Theory]
    [InlineData("TwoFullEndpoints", false, false, "Test1=http://127.0.0.1:12345/;Test2=https://localhost:12346/;")]
    [InlineData("TwoFullEndpoints", false, true, "Test1=http://127.0.0.1:12345/;Test2=https://localhost:12346/;")]
    [InlineData("TwoFullEndpoints", true, false, "Test1=http://aspire.dev.internal:22345/;Test2=https://aspire.dev.internal:22346/;")]
    [InlineData("TwoFullEndpoints", true, true, "Test1=http://testresource:22345/;Test2=https://testresource:22346/;")]
    [InlineData("Url", false, false, "Url=http://localhost:12345;")]
    [InlineData("Url", false, true, "Url=http://localhost:12345;")]
    [InlineData("Url", true, false, "Url=http://aspire.dev.internal:22345;")]
    [InlineData("Url", true, true, "Url=http://testresource:22345;")]
    [InlineData("Url2", true, false, "Url=http://aspire.dev.internal:22345;")]
    [InlineData("Url2", true, true, "Url=http://testresource:22345;")]
    [InlineData("OnlyHost", true, false, "Host=aspire.dev.internal;")]
    [InlineData("OnlyHost", true, true, "Host=testresource;")]
    [InlineData("OnlyPort", true, false, "Port=22345;")]
    [InlineData("OnlyPort", true, true, "Port=22345;")]
    [InlineData("HostAndPort", true, false, "HostPort=aspire.dev.internal:22345")]
    [InlineData("HostAndPort", true, true, "HostPort=testresource:22345")]
    [InlineData("PortBeforeHost", true, false, "Port=22345;Host=aspire.dev.internal;")]
    [InlineData("PortBeforeHost", true, true, "Port=22345;Host=testresource;")]
    [InlineData("FullAndPartial", true, false, "Test1=http://aspire.dev.internal:22345/;Test2=https://localhost:22346/;")]
    [InlineData("FullAndPartial", true, true, "Test1=http://testresource:22345/;Test2=https://localhost:22346/;")]
    [InlineData("UrlEncodedHost", false, false, "Host=host%20with%20space;")]
    public async Task ExpressionResolverGeneratesCorrectEndpointStrings(string exprName, bool sourceIsContainer, bool targetIsContainer, string expectedConnectionString)
    {
        var builder = DistributedApplication.CreateBuilder();
 
        var target = builder.AddResource(new TestExpressionResolverResource(exprName))
            .WithEndpoint("endpoint1", e =>
            {
                e.UriScheme = "http";
                e.AllocatedEndpoint = new(e, "localhost", 12345, targetPortExpression: "10000");
                if (sourceIsContainer)
                {
                    // Note: on the container network side the port and target port are always the same for AllocatedEndpoint.
                    var ae = new AllocatedEndpoint(e, KnownHostNames.DefaultContainerTunnelHostName, 22345, EndpointBindingMode.SingleAddress, targetPortExpression: "22345", KnownNetworkIdentifiers.DefaultAspireContainerNetwork);
                    var snapshot = new ValueSnapshot<AllocatedEndpoint>();
                    snapshot.SetValue(ae);
                    e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot);
                }
            })
            .WithEndpoint("endpoint2", e =>
             {
                 e.UriScheme = "https";
                 e.AllocatedEndpoint = new(e, "localhost", 12346, targetPortExpression: "10001");
                 if (sourceIsContainer)
                 {
                     var ae = new AllocatedEndpoint(e, KnownHostNames.DefaultContainerTunnelHostName, 22346, EndpointBindingMode.SingleAddress, targetPortExpression: "22346", KnownNetworkIdentifiers.DefaultAspireContainerNetwork);
                     var snapshot = new ValueSnapshot<AllocatedEndpoint>();
                     snapshot.SetValue(ae);
                     e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot);
                 }
             })
             .WithEndpoint("endpoint3", e =>
             {
                 e.UriScheme = "https";
                 e.AllocatedEndpoint = new(e, "host with space", 12347);
                 if (sourceIsContainer)
                 {
                     var ae = new AllocatedEndpoint(e, KnownHostNames.DefaultContainerTunnelHostName, 22347, EndpointBindingMode.SingleAddress, targetPortExpression: "22346", KnownNetworkIdentifiers.DefaultAspireContainerNetwork);
                     var snapshot = new ValueSnapshot<AllocatedEndpoint>();
                     snapshot.SetValue(ae);
                     e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot);
                 }
             });
 
        if (targetIsContainer)
        {
            target = target.WithImage("someimage");
        }
 
        // First test ExpressionResolver directly
        var csRef = new ConnectionStringReference(target.Resource, false);
        var context = new ValueProviderContext()
        {
            Network = sourceIsContainer ? KnownNetworkIdentifiers.DefaultAspireContainerNetwork : KnownNetworkIdentifiers.LocalhostNetwork
        };
        var connectionString = await ExpressionResolver.ResolveAsync(csRef, context, CancellationToken.None).DefaultTimeout();
        Assert.Equal(expectedConnectionString, connectionString.Value);
 
        // Then test it indirectly with a resource reference, which exercises a more complete code path
        var source = builder.AddResource(new ContainerResource("testSource"))
            .WithReference(target);
        if (sourceIsContainer)
        {
            source = source.WithImage("someimage");
        }
 
        var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(source.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
        Assert.Equal(expectedConnectionString, config["ConnectionStrings__testresource"]);
    }
 
    [Theory]
    [InlineData(false, true, "http://localhost:18889", "http://localhost:18889")]
    [InlineData(true, true, "http://localhost:18889", "http://aspire.dev.internal:18889")]
    [InlineData(false, true, "http://127.0.0.1:18889", "http://127.0.0.1:18889")]
    [InlineData(true, true, "http://127.0.0.1:18889", "http://aspire.dev.internal:18889")]
    [InlineData(false, true, "http://[::1]:18889", "http://[::1]:18889")]
    [InlineData(true, true, "http://[::1]:18889", "http://aspire.dev.internal:18889")]
    [InlineData(false, false, "http://localhost:18889", "http://localhost:18889")]
    [InlineData(true, false, "http://localhost:18889", "http://host.docker.internal:18889")]
    [InlineData(false, false, "http://127.0.0.1:18889", "http://127.0.0.1:18889")]
    [InlineData(true, false, "http://127.0.0.1:18889", "http://host.docker.internal:18889")]
    [InlineData(false, false, "http://[::1]:18889", "http://[::1]:18889")]
    [InlineData(true, false, "http://[::1]:18889", "http://host.docker.internal:18889")]
    public async Task HostUrlPropertyGetsResolved(bool targetIsContainer, bool withTunnel, string hostUrlVal, string expectedValue)
    {
        var builder = DistributedApplication.CreateBuilder();
 
        var test = builder.AddResource(new ContainerResource("testSource"))
            .WithEnvironment(env =>
            {
                Assert.NotNull(env.Resource);
 
                env.EnvironmentVariables["envname"] = new HostUrl(hostUrlVal);
            });
 
        if (targetIsContainer)
        {
            test = test.WithImage("someimage");
        }
 
        var testServiceProvider = new TestServiceProvider();
        testServiceProvider.AddService(Options.Create(new DcpOptions() { EnableAspireContainerTunnel = withTunnel }));
        testServiceProvider.AddService(new DistributedApplicationModel(builder.Resources));
 
        var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(test.Resource, DistributedApplicationOperation.Run, testServiceProvider).DefaultTimeout();
        Assert.Equal(expectedValue, config["envname"]);
    }
 
    [Theory]
    [InlineData(false, true, "http://localhost:18889")]
    [InlineData(true, true, "http://aspire.dev.internal:18889")]
    [InlineData(false, false, "http://localhost:18889")]
    [InlineData(true, false, "http://host.docker.internal:18889")]
    public async Task HostUrlPropertyGetsResolvedInOtlpExporterEndpoint(bool container, bool withTunnel, string expectedValue)
    {
        var builder = DistributedApplication.CreateBuilder();
 
        var test = builder.AddResource(new ContainerResource("testSource"))
            .WithOtlpExporter();
 
        if (container)
        {
            test = test.WithImage("someimage");
        }
 
        var testServiceProvider = new TestServiceProvider();
        testServiceProvider.AddService(Options.Create(new DcpOptions() { EnableAspireContainerTunnel = withTunnel }));
        testServiceProvider.AddService(new DistributedApplicationModel(builder.Resources));
 
        var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(test.Resource, DistributedApplicationOperation.Run, testServiceProvider).DefaultTimeout();
        Assert.Equal(expectedValue, config["OTEL_EXPORTER_OTLP_ENDPOINT"]);
    }
 
    [Fact]
    public async Task ContainerToContainerEndpointShouldResolve()
    {
        var builder = DistributedApplication.CreateBuilder();
 
        var connectionStringResource = builder.AddResource(new MyContainerResource("myContainer"))
           .WithImage("redis")
           .WithHttpEndpoint(targetPort: 8080)
           .WithEndpoint("http", e =>
           {
               e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8001, EndpointBindingMode.SingleAddress, "{{ targetPort }}", KnownNetworkIdentifiers.LocalhostNetwork);
           });
 
        var dep = builder.AddContainer("container", "redis")
           .WithReference(connectionStringResource)
           .WaitFor(connectionStringResource);
 
        var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dep.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
 
        Assert.Equal("http://myContainer:8080", config["ConnectionStrings__myContainer"]);
    }
}
 
sealed class MyContainerResource : ContainerResource, IResourceWithConnectionString
{
    public MyContainerResource(string name) : base(name)
    {
        PrimaryEndpoint = new(this, "http", KnownNetworkIdentifiers.LocalhostNetwork);
    }
 
    public EndpointReference PrimaryEndpoint { get; }
 
    public ReferenceExpression ConnectionStringExpression =>
       ReferenceExpression.Create($"{PrimaryEndpoint.Property(EndpointProperty.Url)}");
}
 
sealed class TestValueProviderResource(string name) : Resource(name), IValueProvider
{
    public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
    {
        return new ValueTask<string?>(base.Name);
    }
}
 
sealed class TestExpressionResolverResource : ContainerResource, IResourceWithEndpoints, IResourceWithConnectionString
{
    readonly string _exprName;
    EndpointReference Endpoint1 => new(this, "endpoint1");
    EndpointReference Endpoint2 => new(this, "endpoint2");
    EndpointReference Endpoint3 => new(this, "endpoint3");
    Dictionary<string, ReferenceExpression> Expressions { get; }
    public TestExpressionResolverResource(string exprName) : base("testresource")
    {
        _exprName = exprName;
 
        Expressions = new()
        {
            { "TwoFullEndpoints", ReferenceExpression.Create($"Test1={Endpoint1.Property(EndpointProperty.Scheme)}://{Endpoint1.Property(EndpointProperty.IPV4Host)}:{Endpoint1.Property(EndpointProperty.Port)}/;Test2={Endpoint2.Property(EndpointProperty.Scheme)}://{Endpoint2.Property(EndpointProperty.Host)}:{Endpoint2.Property(EndpointProperty.Port)}/;") },
            { "Url", ReferenceExpression.Create($"Url={Endpoint1.Property(EndpointProperty.Url)};") },
            { "Url2", ReferenceExpression.Create($"Url={Endpoint1};") },
            { "OnlyHost", ReferenceExpression.Create($"Host={Endpoint1.Property(EndpointProperty.Host)};") },
            { "OnlyPort", ReferenceExpression.Create($"Port={Endpoint1.Property(EndpointProperty.Port)};") },
            { "HostAndPort", ReferenceExpression.Create($"HostPort={Endpoint1.Property(EndpointProperty.HostAndPort)}") },
            { "PortBeforeHost", ReferenceExpression.Create($"Port={Endpoint1.Property(EndpointProperty.Port)};Host={Endpoint1.Property(EndpointProperty.Host)};") },
            { "FullAndPartial", ReferenceExpression.Create($"Test1={Endpoint1.Property(EndpointProperty.Scheme)}://{Endpoint1.Property(EndpointProperty.IPV4Host)}:{Endpoint1.Property(EndpointProperty.Port)}/;Test2={Endpoint2.Property(EndpointProperty.Scheme)}://localhost:{Endpoint2.Property(EndpointProperty.Port)}/;") },
            { "Empty", ReferenceExpression.Empty },
            { "String", ReferenceExpression.Create($"String") },
            { "SecretParameter", ReferenceExpression.Create("SecretParameter", [new ParameterResource("SecretParameter", _ => "SecretParameter", secret: true)], [], [null]) },
            { "NonSecretParameter", ReferenceExpression.Create("NonSecretParameter", [new ParameterResource("NonSecretParameter", _ => "NonSecretParameter", secret: false)], [], [null]) },
            { "UrlEncodedHost", ReferenceExpression.Create($"Host={Endpoint3.Property(EndpointProperty.Host):uri};") },
        };
    }
 
    public ReferenceExpression ConnectionStringExpression => Expressions[_exprName];
}