File: AzureSandboxEndpointPropertyValueProvider.cs
Web Access
Project: src\src\Aspire.Hosting.Azure.Sandboxes\Aspire.Hosting.Azure.Sandboxes.csproj (Aspire.Hosting.Azure.Sandboxes)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREPIPELINES002
#pragma warning disable ASPIREAZURE001
 
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.Azure;
 
internal sealed class AzureSandboxEndpointPropertyValueProvider : IValueProvider, IManifestExpressionProvider, IValueWithReferences
{
    private readonly AzureSandboxContainerResource _resource;
    private readonly EndpointReferenceExpression _endpointReferenceExpression;
    private readonly AzureSandboxContainerDeployment.SandboxEndpoint _sandboxEndpoint;
 
    public AzureSandboxEndpointPropertyValueProvider(
        AzureSandboxContainerResource resource,
        EndpointReferenceExpression endpointReferenceExpression)
    {
        ArgumentNullException.ThrowIfNull(resource);
        ArgumentNullException.ThrowIfNull(endpointReferenceExpression);
 
        var endpoint = endpointReferenceExpression.Endpoint.EndpointAnnotation;
        if (!endpoint.IsExternal)
        {
            throw new InvalidOperationException($"Endpoint '{endpoint.Name}' on resource '{resource.TargetResource.Name}' is not external. Azure sandbox endpoint references are only supported for external HTTP endpoints because ADC only assigns public URLs to exposed ports.");
        }
 
        _resource = resource;
        _endpointReferenceExpression = endpointReferenceExpression;
        _sandboxEndpoint = ResolveSandboxEndpoint(resource, endpointReferenceExpression.Endpoint);
    }
 
    public string ValueExpression =>
        $"{{{_resource.Name}.endpoints.{_endpointReferenceExpression.Endpoint.EndpointName}.{_endpointReferenceExpression.Property.ToString().ToLowerInvariant()}}}";
 
    public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
    {
        return new(GetKnownValueWithoutDeploymentState() ?? throw CreateUnresolvedEndpointException());
    }
 
    public async ValueTask<string?> GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default)
    {
        if (GetKnownValueWithoutDeploymentState() is { } knownValue)
        {
            return knownValue;
        }
 
        if (context.ExecutionContext is null)
        {
            throw CreateUnresolvedEndpointException();
        }
 
        var deploymentStateManager = context.ExecutionContext.Services.GetRequiredService<IDeploymentStateManager>();
        var stateSection = await deploymentStateManager
            .AcquireSectionAsync(AzureSandboxContainerDeployment.GetStateSectionName(_resource), cancellationToken)
            .ConfigureAwait(false);
 
        if (TryGetUrl(stateSection, out var url))
        {
            return GetValueFromUrl(url);
        }
 
        throw CreateUnresolvedEndpointException();
    }
 
    IEnumerable<object> IValueWithReferences.References =>
    [
        _resource,
        _endpointReferenceExpression.Endpoint.Resource
    ];
 
    private string? GetKnownValueWithoutDeploymentState()
    {
        return _endpointReferenceExpression.Property switch
        {
            EndpointProperty.TargetPort => _sandboxEndpoint.TargetPort.ToString(CultureInfo.InvariantCulture),
            EndpointProperty.Scheme => Uri.UriSchemeHttps,
            EndpointProperty.Port => "443",
            EndpointProperty.TlsEnabled => bool.TrueString,
            _ => null
        };
    }
 
    private string GetValueFromUrl(string url)
    {
        if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
            string.IsNullOrWhiteSpace(uri.Host))
        {
            throw new InvalidOperationException($"Azure sandbox deployment state for resource '{_resource.TargetResource.Name}' endpoint '{_endpointReferenceExpression.Endpoint.EndpointName}' contains invalid URL '{url}'.");
        }
 
        return _endpointReferenceExpression.Property switch
        {
            EndpointProperty.Url => url,
            EndpointProperty.Host or EndpointProperty.IPV4Host => uri.Host,
            EndpointProperty.Port => uri.Port.ToString(CultureInfo.InvariantCulture),
            EndpointProperty.TargetPort => _sandboxEndpoint.TargetPort.ToString(CultureInfo.InvariantCulture),
            EndpointProperty.Scheme => uri.Scheme,
            EndpointProperty.HostAndPort => uri.IsDefaultPort ? uri.Host : uri.Authority,
            EndpointProperty.TlsEnabled => string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ? bool.TrueString : bool.FalseString,
            _ => throw new InvalidOperationException($"The property '{_endpointReferenceExpression.Property}' is not supported for the endpoint '{_endpointReferenceExpression.Endpoint.EndpointName}'.")
        };
    }
 
    private InvalidOperationException CreateUnresolvedEndpointException() =>
        new($"Azure sandbox endpoint '{_endpointReferenceExpression.Endpoint.EndpointName}' on resource '{_resource.TargetResource.Name}' does not have a deployed URL yet. Runtime sandbox URLs cannot be used as first-pass Azure provisioning values; deploy the producing sandbox before resolving this reference.");
 
    private bool TryGetUrl(DeploymentStateSection stateSection, [NotNullWhen(true)] out string? url)
    {
        url = null;
 
        // Sandbox deployment state stores exposed ADC ports as:
        //   { "Ports": [{ "Name": "http", "Port": 8080, "Url": "https://<sandbox-id>--8080.<region>.adcproxy.io/" }] }
        // Multiple Aspire endpoints can share the same target port, so fall back to target-port
        // matching when the persisted representative name differs from the requested endpoint name.
        if (stateSection.Data["Ports"] is not JsonArray ports)
        {
            return false;
        }
 
        JsonObject? fallbackPort = null;
        foreach (var port in ports.OfType<JsonObject>())
        {
            if (port["Port"]?.GetValue<int>() == _sandboxEndpoint.TargetPort)
            {
                fallbackPort ??= port;
            }
 
            if (string.Equals(port["Name"]?.GetValue<string>(), _sandboxEndpoint.Name, StringComparison.Ordinal))
            {
                return TryGetUrl(port, out url);
            }
        }
 
        return fallbackPort is not null && TryGetUrl(fallbackPort, out url);
    }
 
    private static bool TryGetUrl(JsonObject port, [NotNullWhen(true)] out string? url)
    {
        url = port["Url"]?.GetValue<string>();
        return !string.IsNullOrWhiteSpace(url);
    }
 
    private static AzureSandboxContainerDeployment.SandboxEndpoint ResolveSandboxEndpoint(AzureSandboxContainerResource resource, EndpointReference endpointReference)
    {
        var endpoints = AzureSandboxContainerDeployment.ResolveSandboxEndpoints(resource);
        var endpointName = endpointReference.EndpointName;
        var resolvedTargetPort = ResolveEndpointTargetPort(resource, endpointName) ?? endpointReference.EndpointAnnotation.TargetPort;
        AzureSandboxContainerDeployment.SandboxEndpoint? fallbackEndpoint = null;
 
        foreach (var endpoint in endpoints)
        {
            if (resolvedTargetPort is int targetPort &&
                endpoint.TargetPort == targetPort)
            {
                fallbackEndpoint ??= endpoint;
            }
 
            if (string.Equals(endpoint.Name, endpointName, StringComparison.Ordinal))
            {
                return endpoint;
            }
        }
 
        if (fallbackEndpoint is { } matchedEndpoint)
        {
            return matchedEndpoint;
        }
 
        throw new InvalidOperationException($"Endpoint '{endpointName}' on resource '{resource.TargetResource.Name}' is not exposed by the Azure sandbox deployment target. Configure it as an external HTTP endpoint before referencing its sandbox URL.");
    }
 
    private static int? ResolveEndpointTargetPort(AzureSandboxContainerResource resource, string endpointName)
    {
        foreach (var resolvedEndpoint in resource.TargetResource.ResolveEndpoints())
        {
            if (string.Equals(resolvedEndpoint.Endpoint.Name, endpointName, StringComparison.Ordinal) &&
                resolvedEndpoint.TargetPort.Value is int targetPort)
            {
                return targetPort;
            }
        }
 
        return null;
    }
}