File: ApiManagementInfraDeploymentTests.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>
/// L1 infrastructure verification test for Azure API Management.
/// Deploys a Consumption service and representative child resources, then verifies them through Azure CLI.
/// </summary>
public sealed class ApiManagementInfraDeploymentTests(ITestOutputHelper output)
{
    private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30);
 
    [Fact]
    public async Task DeployApiManagementInfrastructure()
    {
        using var cts = new CancellationTokenSource(s_testTimeout);
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            cts.Token, TestContext.Current.CancellationToken);
 
        await DeployApiManagementInfrastructureCore(linkedCts.Token);
    }
 
    private async Task DeployApiManagementInfrastructureCore(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.");
        }
 
        var workspace = TemporaryWorkspace.Create(output);
        var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("apim-l1");
        var startTime = DateTime.UtcNow;
 
        output.WriteLine($"Test: {nameof(DeployApiManagementInfrastructure)}");
        output.WriteLine($"Resource Group: {resourceGroupName}");
        output.WriteLine($"Subscription: {subscriptionId[..8]}...");
        output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}");
 
        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);
 
            output.WriteLine("Step 3: Creating single-file AppHost...");
            await auto.AspireInitAsync(counter);
 
            output.WriteLine("Step 4: Adding Azure API Management hosting package...");
            await auto.TypeAsync("aspire add Aspire.Hosting.Azure.ApiManagement");
            await auto.EnterAsync();
            await auto.WaitForAspireAddCompletionAsync(counter);
 
            var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
            var content = File.ReadAllText(appHostFilePath);
            Assert.Contains("builder.Build().Run();", content);
            content = content.Replace(
                "builder.Build().Run();",
                """
var apim = builder.AddAzureApiManagement("apim", new()
{
    PublisherEmail = "api-owners@example.com",
    PublisherName = "Aspire infrastructure tests",
    Sku = Aspire.Hosting.Azure.AzureApiManagementSku.Consumption,
});
 
apim.AddNamedValue("environment", "infra-test");
 
// This test verifies provisioning only; it does not send traffic to the backend.
var catalogBackend = apim.AddBackend(
    "catalog-backend",
    ReferenceExpression.Create($"https://example.com"));
var catalogApi = apim.AddApi(
    "catalog-api",
    path: "catalog",
    displayName: "Catalog API")
    .WithBackend(catalogBackend);
catalogApi.AddOperation(
    "get-items",
    method: "GET",
    urlTemplate: "/items",
    displayName: "Get items");
 
apim.AddProduct("catalog-product", "Catalog")
    .WithApi(catalogApi)
    .AddSubscription("catalog-client", "Catalog client");
 
builder.Build().Run();
""");
            File.WriteAllText(appHostFilePath, content);
 
            output.WriteLine($"Modified apphost.cs with API Management infrastructure:\n{content}");
 
            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);
 
            output.WriteLine("Step 6: Deploying API Management infrastructure...");
            // Model validation can fail before the pipeline starts, without printing "Pipeline failed".
            // The command's exit status also catches these failures without waiting for the full timeout.
            await auto.RunCommandAsync("aspire deploy --clear-cache", counter, TimeSpan.FromMinutes(20));
 
            output.WriteLine("Step 7: Verifying API Management infrastructure...");
            await auto.TypeAsync(
                $"SERVICE=$(az apim list -g \"{resourceGroupName}\" --subscription \"{subscriptionId}\" " +
                "--query \"[?sku.name == 'Consumption' && provisioningState == 'Succeeded'].name | [0]\" -o tsv) && " +
                "[ -n \"$SERVICE\" ] && " +
                $"[ \"$(az apim api show -g \"{resourceGroupName}\" --service-name \"$SERVICE\" --subscription \"{subscriptionId}\" " +
                "--api-id catalog-api --query path -o tsv)\" = \"catalog\" ] && " +
                $"[ \"$(az apim api operation show -g \"{resourceGroupName}\" --service-name \"$SERVICE\" --subscription \"{subscriptionId}\" " +
                "--api-id catalog-api --operation-id get-items --query method -o tsv)\" = \"GET\" ] && " +
                $"[ \"$(az apim nv show -g \"{resourceGroupName}\" --service-name \"$SERVICE\" --subscription \"{subscriptionId}\" " +
                "--named-value-id environment --query value -o tsv)\" = \"infra-test\" ] && " +
                $"[ \"$(az apim product show -g \"{resourceGroupName}\" --service-name \"$SERVICE\" --subscription \"{subscriptionId}\" " +
                "--product-id catalog-product --query state -o tsv)\" = \"published\" ] && " +
                $"API_ASSOCIATED=$(az apim product api check -g \"{resourceGroupName}\" --service-name \"$SERVICE\" --subscription \"{subscriptionId}\" " +
                "--product-id catalog-product --api-id catalog-api --output json) && " +
                "[ \"$API_ASSOCIATED\" = \"true\" ] && " +
                $"SERVICE_ID=$(az apim show -g \"{resourceGroupName}\" --name \"$SERVICE\" --subscription \"{subscriptionId}\" --query id -o tsv) && " +
                "[ \"$(az rest --method get " +
                "--url \"https://management.azure.com${SERVICE_ID}/backends/catalog-backend?api-version=2024-05-01\" " +
                "--query properties.url -o tsv)\" = \"https://example.com\" ] && " +
                "SUBSCRIPTION_SCOPE=$(az rest --method get " +
                "--url \"https://management.azure.com${SERVICE_ID}/subscriptions/catalog-client?api-version=2024-05-01\" " +
                "--query properties.scope -o tsv) && " +
                "[[ \"$SUBSCRIPTION_SCOPE\" == */products/catalog-product ]]");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            await auto.TypeAsync("exit");
            await auto.EnterAsync();
            await pendingRun;
 
            DeploymentReporter.ReportDeploymentSuccess(
                nameof(DeployApiManagementInfrastructure),
                resourceGroupName,
                new Dictionary<string, string>(),
                DateTime.UtcNow - startTime);
        }
        catch (Exception ex)
        {
            DeploymentReporter.ReportDeploymentFailure(
                nameof(DeployApiManagementInfrastructure),
                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 pre-provisioning failure leaves no group to delete. Azure CLI reports this as
            // "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");
    }
}