File: ApiManagementDeploymentTests.cs
Web Access
Project: src\tests\Aspire.Deployment.EndToEnd.Tests\Aspire.Deployment.EndToEnd.Tests.csproj (Aspire.Deployment.EndToEnd.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.Deployment.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Deployment.EndToEnd.Tests;
 
/// <summary>
/// End-to-end tests for routing an Azure Container App through Azure API Management.
/// </summary>
public sealed class ApiManagementDeploymentTests(ITestOutputHelper output)
{
    private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(90);
 
    [Fact]
    public async Task DeployStarterTemplateWithApiManagement()
    {
        using var cts = new CancellationTokenSource(s_testTimeout);
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            cts.Token, TestContext.Current.CancellationToken);
 
        await DeployStarterTemplateWithApiManagementCore(linkedCts.Token);
    }
 
    private async Task DeployStarterTemplateWithApiManagementCore(CancellationToken cancellationToken)
    {
        var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId();
        if (string.IsNullOrEmpty(subscriptionId))
        {
            Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION.");
        }
 
        if (!AzureAuthenticationHelpers.IsAzureAuthAvailable())
        {
            if (DeploymentE2ETestHelpers.IsRunningInCI)
            {
                Assert.Fail("Azure authentication not available in CI. Check OIDC configuration.");
            }
 
            Assert.Skip("Azure authentication not available. Run 'az login' to authenticate.");
        }
 
        const string projectName = "ApiManagementApp";
        var workspace = TemporaryWorkspace.Create(output);
        var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("apim");
        var deploymentUrls = new Dictionary<string, string>();
        var startTime = DateTime.UtcNow;
 
        try
        {
            using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
            var pendingRun = terminal.RunAsync(cancellationToken);
            var counter = new SequenceCounter();
            var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
 
            await auto.PrepareEnvironmentAsync(workspace, counter);
            await auto.InstallCurrentBuildAspireCliAsync(counter, output);
            await auto.AspireNewAsync(projectName, counter, useRedisCache: false);
 
            await auto.TypeAsync($"cd {projectName}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers");
            await auto.EnterAsync();
            await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromMinutes(3));
 
            await auto.TypeAsync("aspire add Aspire.Hosting.Azure.ApiManagement");
            await auto.EnterAsync();
            await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromMinutes(3));
 
            var appHostFilePath = Path.Combine(
                workspace.WorkspaceRoot.FullName,
                projectName,
                $"{projectName}.AppHost",
                "AppHost.cs");
            var content = File.ReadAllText(appHostFilePath);
            Assert.Contains("builder.Build().Run();", content);
            content = "using Aspire.Hosting.Azure;\nusing Azure.Provisioning.Network;\n" + content;
            content = content.Replace(
                "builder.Build().Run();",
                """
#pragma warning disable ASPIREAZURE003
 
var vnet = builder.AddAzureVirtualNetwork("vnet");
var containerAppsSubnet = vnet.AddSubnet("container-apps-subnet", "10.0.0.0/23");
var apiManagementSubnet = vnet.AddSubnet("apim-subnet", "10.0.2.0/24")
    .AllowInbound(port: "3443", from: "ApiManagement", protocol: SecurityRuleProtocol.Tcp)
    .AllowInbound(port: "6390", from: AzureServiceTags.AzureLoadBalancer, protocol: SecurityRuleProtocol.Tcp)
    .AllowInbound(port: "443", from: AzureServiceTags.Internet, protocol: SecurityRuleProtocol.Tcp);
 
var environment = builder.AddAzureContainerAppEnvironment("aca")
    .WithDelegatedSubnet(containerAppsSubnet)
    .WithInternalLoadBalancer(vnet);
 
apiService
    .WithComputeEnvironment(environment)
    .WithExternalHttpEndpoints();
 
var apim = builder.AddAzureApiManagement("apim", new()
{
    PublisherEmail = "api-owners@example.com",
    PublisherName = "Contoso APIs",
    Sku = AzureApiManagementSku.Developer,
}).WithClassicVirtualNetwork(
    apiManagementSubnet,
    AzureApiManagementVirtualNetworkMode.External);
 
apim.AddApi(
    "weather-api",
    apiService,
    path: "api",
    subscriptionRequired: false);
 
#pragma warning restore ASPIREAZURE003
 
builder.Build().Run();
""");
            File.WriteAllText(appHostFilePath, content);
 
            await auto.TypeAsync($"cd {projectName}.AppHost");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            await auto.TypeAsync(
                $"unset ASPIRE_PLAYGROUND && export AZURE__SUBSCRIPTIONID=\"{subscriptionId}\" && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // Fail promptly on AppHost build errors, which occur before the pipeline starts.
            // Classic VNet-injected APIM provisioning can take over an hour.
            await auto.RunCommandAsync("aspire deploy --clear-cache", counter, TimeSpan.FromMinutes(70));
 
            // DNS resolution alone does not prove public reachability. Verify that the backend's
            // environment has an internal load balancer. Azure API versions expose its ID as
            // either "environmentId" or the older "managedEnvironmentId".
            await auto.TypeAsync(
                $"GATEWAY=$(az apim list -g \"{resourceGroupName}\" --subscription \"{subscriptionId}\" --query \"[0].gatewayUrl\" -o tsv) && " +
                "[ -n \"$GATEWAY\" ] && " +
                $"ENVIRONMENT_ID=$(az containerapp show -g \"{resourceGroupName}\" -n apiservice --subscription \"{subscriptionId}\" " +
                "--query \"properties.environmentId || properties.managedEnvironmentId\" -o tsv) && " +
                "[ -n \"$ENVIRONMENT_ID\" ] && " +
                $"INTERNAL=$(az containerapp env show --ids \"$ENVIRONMENT_ID\" --subscription \"{subscriptionId}\" " +
                "--query properties.vnetConfiguration.internal -o json) && " +
                "[ \"$INTERNAL\" = \"true\" ] && " +
                "{ OK=0; for i in $(seq 1 24); do " +
                "STATUS=$(curl -s -o .aspire-apim-response.json -w \"%{http_code}\" \"$GATEWAY/api/weatherforecast\" --max-time 30); " +
                "if [ \"$STATUS\" = \"200\" ]; then " +
                "if jq -e 'type == \"array\" and length == 5 and all(.[]; has(\"date\") and has(\"temperatureC\") and has(\"summary\"))' .aspire-apim-response.json; then OK=1; fi; break; fi; " +
                "echo \"Attempt $i returned $STATUS; retrying in 10s\"; sleep 10; " +
                "done; [ \"$OK\" = \"1\" ]; } && " +
                "ROOT_RESPONSE=$(curl -fsS \"$GATEWAY/api\" --max-time 30) && " +
                "[ \"$ROOT_RESPONSE\" = \"API service is running. Navigate to /weatherforecast to see sample data.\" ]");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(12));
 
            await auto.TypeAsync("exit");
            await auto.EnterAsync();
            await pendingRun;
 
            DeploymentReporter.ReportDeploymentSuccess(
                nameof(DeployStarterTemplateWithApiManagement),
                resourceGroupName,
                deploymentUrls,
                DateTime.UtcNow - startTime);
        }
        catch (Exception ex)
        {
            DeploymentReporter.ReportDeploymentFailure(
                nameof(DeployStarterTemplateWithApiManagement),
                resourceGroupName,
                ex.Message,
                ex.StackTrace);
            throw;
        }
        finally
        {
            await TriggerCleanupResourceGroupAsync(resourceGroupName, subscriptionId);
        }
    }
 
    private static async Task TriggerCleanupResourceGroupAsync(string resourceGroupName, string subscriptionId)
    {
        using var process = new System.Diagnostics.Process
        {
            StartInfo = new System.Diagnostics.ProcessStartInfo
            {
                FileName = "az",
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true,
            },
        };
        process.StartInfo.ArgumentList.Add("group");
        process.StartInfo.ArgumentList.Add("delete");
        process.StartInfo.ArgumentList.Add("--name");
        process.StartInfo.ArgumentList.Add(resourceGroupName);
        process.StartInfo.ArgumentList.Add("--subscription");
        process.StartInfo.ArgumentList.Add(subscriptionId);
        process.StartInfo.ArgumentList.Add("--yes");
        process.StartInfo.ArgumentList.Add("--no-wait");
 
        if (!process.Start())
        {
            const string error = "Azure CLI process did not start.";
            DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, error);
            throw new InvalidOperationException(error);
        }
        var standardOutputTask = process.StandardOutput.ReadToEndAsync();
        var standardErrorTask = process.StandardError.ReadToEndAsync();
        await process.WaitForExitAsync();
        var standardOutput = await standardOutputTask;
        var standardError = await standardErrorTask;
 
        if (process.ExitCode != 0)
        {
            var error = string.IsNullOrWhiteSpace(standardError) ? standardOutput : standardError;
            // A failure before provisioning leaves no group to delete. Azure CLI reports:
            // "ERROR: (ResourceGroupNotFound) Resource group '...' could not be found."
            if (error.Contains("ERROR: (ResourceGroupNotFound)", StringComparison.Ordinal))
            {
                DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Resource group was already absent");
                return;
            }
 
            DeploymentReporter.ReportCleanupStatus(
                resourceGroupName,
                success: false,
                $"Azure CLI exited with code {process.ExitCode}: {error}");
            throw new InvalidOperationException(
                $"Failed to request deletion of resource group '{resourceGroupName}': {error}");
        }
 
        DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Deletion request accepted");
    }
}