File: AppServiceReactDeploymentTests.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 deploying Aspire applications to Azure App Service.
/// </summary>
public sealed class AppServiceReactDeploymentTests(ITestOutputHelper output)
{
    // This test deploys both the initial slot-enabled site and its VNet-integration upgrade.
    // Two 30-minute provisioning operations plus bounded verification waits leave time for
    // template creation, package installation, and the commands between deployments.
    private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(85);
 
    [Fact]
    public async Task DeployReactTemplateToAzureAppServiceWithDelegatedSubnet()
    {
        using var cts = new CancellationTokenSource(s_testTimeout);
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            cts.Token, TestContext.Current.CancellationToken);
        var cancellationToken = linkedCts.Token;
 
        await DeployReactTemplateToAzureAppServiceWithDelegatedSubnetCore(cancellationToken);
    }
 
    private async Task DeployReactTemplateToAzureAppServiceWithDelegatedSubnetCore(CancellationToken cancellationToken)
    {
        // Validate prerequisites
        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.");
            }
            else
            {
                Assert.Skip("Azure authentication not available. Run 'az login' to authenticate.");
            }
        }
 
        using var workspace = TemporaryWorkspace.Create(output);
        var startTime = DateTime.UtcNow;
        var deploymentUrls = new Dictionary<string, string>();
        // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt]
        var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("appservice-vnet");
        // Project name can be simpler since resource group is explicitly set
        var projectName = "ReactAppSvc";
 
        output.WriteLine($"Test: {nameof(DeployReactTemplateToAzureAppServiceWithDelegatedSubnet)}");
        output.WriteLine($"Project Name: {projectName}");
        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));
 
            // Step 1: Prepare environment
            output.WriteLine("Step 1: Preparing environment...");
            await auto.PrepareEnvironmentAsync(workspace, counter);
 
            // Step 2: Set up CLI environment
            // The workflow builds and installs the CLI to ~/.aspire/bin before running tests
            // We just need to source it in the bash session
            await auto.InstallCurrentBuildAspireCliAsync(counter, output);
 
            // Step 3: Create React + ASP.NET Core project using aspire new with interactive prompts
            output.WriteLine("Step 3: Creating React + ASP.NET Core project...");
            await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.JsReact, useRedisCache: false);
 
            // Step 4: Navigate to project directory
            output.WriteLine("Step 4: Navigating to project directory...");
            await auto.TypeAsync($"cd {projectName}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // Step 5: Add Aspire.Hosting.Azure.AppService package (instead of AppContainers)
            output.WriteLine("Step 5: Adding Azure App Service hosting package...");
            await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppService");
            await auto.EnterAsync();
 
            // aspire add may show a version selection prompt
            await auto.WaitForAspireAddCompletionAsync(counter);
 
            // Step 6: Add Azure Virtual Network hosting package
            output.WriteLine("Step 6: Adding Azure Virtual Network hosting package...");
            await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Network");
            await auto.EnterAsync();
            await auto.WaitForAspireAddCompletionAsync(counter);
 
            // Step 7: Configure the first deployment with a staging slot but without VNet integration.
            var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
            var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
            var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
 
            output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}");
 
            const string buildRunPattern = "builder.Build().Run();";
            const string initialEnvironmentConfiguration = """
builder.AddAzureAppServiceEnvironment("infra")
    .WithDeploymentSlot("stage");
""";
            const string upgradedEnvironmentConfiguration = """
#pragma warning disable ASPIREAZURE003 // Azure Virtual Network APIs are experimental.
var vnet = builder.AddAzureVirtualNetwork("vnet");
var subnet = vnet.AddSubnet("app-service-subnet", "10.0.0.0/24");
builder.AddAzureAppServiceEnvironment("infra")
    .WithDelegatedSubnet(subnet)
    .WithDeploymentSlot("stage");
#pragma warning restore ASPIREAZURE003
""";
 
            var content = File.ReadAllText(appHostFilePath);
            var initialAppHostConfiguration = $"""
// Add Azure App Service Environment with a staging slot for deployment.
{initialEnvironmentConfiguration}
 
{buildRunPattern}
""";
            if (!content.Contains(buildRunPattern, StringComparison.Ordinal))
            {
                throw new InvalidOperationException($"Could not find '{buildRunPattern}' in the generated AppHost.");
            }
 
            content = content.Replace(buildRunPattern, initialAppHostConfiguration, StringComparison.Ordinal);
            File.WriteAllText(appHostFilePath, content);
 
            output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}");
 
            // Step 8: Navigate to AppHost project directory
            output.WriteLine("Step 8: Navigating to AppHost directory...");
            await auto.TypeAsync($"cd {projectName}.AppHost");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // Step 9: Set environment variables for deployment
            // - Unset ASPIRE_PLAYGROUND to avoid conflicts
            // - Set Azure location
            // - Set AZURE__RESOURCEGROUP to use our unique resource group name
            await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // Step 10: Deploy the site and slot without VNet integration.
            output.WriteLine("Step 10: Starting the initial Azure App Service deployment...");
            await auto.TypeAsync("aspire deploy --clear-cache");
            await auto.EnterAsync();
            await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(30));
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            // The protected production-site path is only exercised when VNet integration is added
            // after the slot-enabled site already exists.
            output.WriteLine("Step 11: Verifying the initial deployment has no VNet integration...");
            await VerifyNoVnetIntegrationAsync(auto, counter, resourceGroupName);
 
            // Step 12: Add the delegated subnet before redeploying to the same resource group.
            content = File.ReadAllText(appHostFilePath);
            var upgradedContent = content.Replace(
                initialEnvironmentConfiguration,
                upgradedEnvironmentConfiguration,
                StringComparison.Ordinal);
            if (upgradedContent == content)
            {
                throw new InvalidOperationException("Could not add regional VNet integration to the generated AppHost.");
            }
 
            File.WriteAllText(appHostFilePath, upgradedContent);
 
            // Clear the terminal before redeploying. WaitForPipelineSuccessAsync matches the
            // "pipeline succeeded" banner anywhere on the visible screen, and the first deploy's
            // banner is still shown. Without clearing, the second deploy's wait matches that stale
            // banner immediately (instead of waiting for the redeploy to finish), which forces the
            // subsequent success-prompt wait to absorb the entire second deployment and time out.
            await auto.TypeAsync("clear");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // Step 13: Upgrade the existing production site, staging slot, and dashboard.
            output.WriteLine("Step 13: Upgrading the deployment with regional VNet integration...");
            await auto.TypeAsync("aspire deploy --clear-cache");
            await auto.EnterAsync();
            await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(30));
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            // Step 14: Extract deployment URLs and verify endpoints with retry.
            // The workload is deployed to its staging slot, so the production site's empty
            // hostname is not a liveness target. Sites without slots, such as the dashboard,
            // are reached through their production hostname.
            // Verify both endpoints concurrently for up to six minutes. Each attempt has a
            // 10-second curl cap and waits 10 seconds before the next attempt.
            output.WriteLine("Step 14: Verifying deployed endpoints...");
            await auto.TypeAsync(BuildEndpointVerificationCommand(resourceGroupName));
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(7));
 
            // Step 15: Verify that the production site, staging slot, and dashboard use the delegated subnet.
            output.WriteLine("Step 15: Verifying regional VNet integration...");
            await VerifyVnetIntegrationAsync(auto, counter, resourceGroupName);
 
            // Step 16: Exit terminal
            await auto.TypeAsync("exit");
            await auto.EnterAsync();
 
            await pendingRun;
 
            var duration = DateTime.UtcNow - startTime;
            output.WriteLine($"Deployment completed in {duration}");
 
            // Report success
            DeploymentReporter.ReportDeploymentSuccess(
                nameof(DeployReactTemplateToAzureAppServiceWithDelegatedSubnet),
                resourceGroupName,
                deploymentUrls,
                duration);
 
            output.WriteLine("✅ Test passed!");
        }
        catch (Exception ex)
        {
            var duration = DateTime.UtcNow - startTime;
            output.WriteLine($"❌ Test failed after {duration}: {ex.Message}");
 
            DeploymentReporter.ReportDeploymentFailure(
                nameof(DeployReactTemplateToAzureAppServiceWithDelegatedSubnet),
                resourceGroupName,
                ex.Message,
                ex.StackTrace);
 
            throw;
        }
        finally
        {
            output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}");
            await TriggerCleanupResourceGroupAsync(resourceGroupName, output);
        }
    }
 
    private static string BuildEndpointVerificationCommand(string resourceGroupName)
    {
        var script = $$"""
            set -uo pipefail
 
            fail() {
                echo "ERROR: $1"
                exit 1
            }
 
            resource_group={{AspireCliShellCommandHelpers.QuoteBashArg(resourceGroupName)}}
 
            az group show -n "$resource_group" >/dev/null ||
                fail "Resource group was not found"
 
            webapps=$(az webapp list -g "$resource_group" --query "[].name" -o tsv) ||
                fail "Failed to query App Service sites"
            [ -n "$webapps" ] || fail "No App Service sites found"
 
            urls=""
            staging_endpoint_count=0
            dashboard_endpoint_count=0
            for webapp in $webapps
            do
                slots=$(az webapp deployment slot list -g "$resource_group" -n "$webapp" --query "[].name" -o tsv) ||
                    fail "Failed to query deployment slots for $webapp"
                if [ -n "$slots" ]
                then
                    for slot in $slots
                    do
                        url=$(az webapp show -g "$resource_group" -n "$webapp" --slot "$slot" --query defaultHostName -o tsv) ||
                            fail "Failed to query hostname for staging slot $webapp/$slot"
                        [ -n "$url" ] || fail "Missing hostname for staging slot $webapp/$slot"
                        urls="$urls $url"
                        staging_endpoint_count=$((staging_endpoint_count + 1))
                    done
                else
                    url=$(az webapp show -g "$resource_group" -n "$webapp" --query defaultHostName -o tsv) ||
                        fail "Failed to query hostname for dashboard $webapp"
                    [ -n "$url" ] || fail "Missing hostname for dashboard $webapp"
                    urls="$urls $url"
                    dashboard_endpoint_count=$((dashboard_endpoint_count + 1))
                fi
            done
 
            [ "$staging_endpoint_count" -eq 1 ] &&
                [ "$dashboard_endpoint_count" -eq 1 ] ||
                fail "Expected one staging slot endpoint and one dashboard endpoint"
 
            verify_endpoint() {
                local url="$1"
                local status
 
                echo "Checking https://$url..."
                for attempt in $(seq 1 18)
                do
                    status=$(curl -s -o /dev/null -w "%{http_code}" "https://$url" --max-time 10 2>/dev/null) ||
                        status=""
                    if [ "$status" = "200" ] || [ "$status" = "302" ]
                    then
                        echo "  $status (attempt $attempt)"
                        return 0
                    fi
 
                    echo "  Attempt $attempt: $status, retrying in 10 seconds..."
                    if [ "$attempt" -lt 18 ]
                    then
                        sleep 10
                    fi
                done
 
                echo "  Failed after 18 attempts"
                return 1
            }
 
            pids=()
            for url in $urls
            do
                verify_endpoint "$url" &
                pids+=("$!")
            done
 
            failed=0
            for pid in "${pids[@]}"
            do
                if ! wait "$pid"
                then
                    failed=1
                fi
            done
 
            [ "$failed" -eq 0 ] || fail "One or more endpoint checks failed"
            """;
 
        return $"bash -c {AspireCliShellCommandHelpers.QuoteBashArg(script)}";
    }
 
    private static async Task VerifyNoVnetIntegrationAsync(
        Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string resourceGroupName)
    {
        await auto.TypeAsync($"RG_NAME={AspireCliShellCommandHelpers.QuoteBashArg(resourceGroupName)} && " +
            "webapps=$(az webapp list -g \"$RG_NAME\" --query \"[].name\" -o tsv 2>/dev/null) && " +
            "if [ -z \"$webapps\" ]; then echo \"ERROR: No App Service sites found\"; exit 1; fi && " +
            "webapp_count=0 && slot_count=0 && " +
            "for webapp in $webapps; do " +
            "webapp_count=$((webapp_count + 1)); " +
            "if ! subnet_id=$(az webapp show -g \"$RG_NAME\" -n \"$webapp\" --query \"virtualNetworkSubnetId\" -o tsv); then echo \"ERROR: Failed to query VNet integration for $webapp\"; exit 1; fi; " +
            "if [ -n \"$subnet_id\" ]; then echo \"ERROR: $webapp unexpectedly has VNet integration\"; exit 1; fi; " +
            "if ! slots=$(az webapp deployment slot list -g \"$RG_NAME\" -n \"$webapp\" --query \"[].name\" -o tsv 2>/dev/null); then echo \"ERROR: Failed to query deployment slots for $webapp\"; exit 1; fi; " +
            "for slot in $slots; do " +
            "slot_count=$((slot_count + 1)); " +
            "if ! subnet_id=$(az webapp show -g \"$RG_NAME\" -n \"$webapp\" --slot \"$slot\" --query \"virtualNetworkSubnetId\" -o tsv); then echo \"ERROR: Failed to query VNet integration for $webapp/$slot\"; exit 1; fi; " +
            "if [ -n \"$subnet_id\" ]; then echo \"ERROR: $webapp/$slot unexpectedly has VNet integration\"; exit 1; fi; " +
            "done; " +
            "done && " +
            "if [ \"$webapp_count\" -ne 2 ] || [ \"$slot_count\" -ne 1 ]; then echo \"ERROR: Expected two sites and one staging slot\"; exit 1; fi && " +
            "echo \"Verified no VNet integration on the initial deployment\"");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
    }
 
    private static async Task VerifyVnetIntegrationAsync(
        Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string resourceGroupName)
    {
        await auto.TypeAsync($"RG_NAME={AspireCliShellCommandHelpers.QuoteBashArg(resourceGroupName)} && " +
            "vnet_name=$(az network vnet list -g \"$RG_NAME\" --query \"[?subnets[?name == 'app-service-subnet']].name | [0]\" -o tsv 2>/dev/null) && " +
            "expected_subnet_id=$(az network vnet subnet show -g \"$RG_NAME\" --vnet-name \"$vnet_name\" --name app-service-subnet --query \"id\" -o tsv 2>/dev/null) && " +
            "if [ -z \"$expected_subnet_id\" ]; then echo \"ERROR: Delegated subnet not found\"; exit 1; fi && " +
            "delegation=$(az network vnet subnet show --ids \"$expected_subnet_id\" --query \"delegations[?serviceName == 'Microsoft.Web/serverFarms'].serviceName | [0]\" -o tsv 2>/dev/null) && " +
            "if [ \"$delegation\" != \"Microsoft.Web/serverFarms\" ]; then echo \"ERROR: Subnet is not delegated to Microsoft.Web/serverFarms\"; exit 1; fi && " +
            "webapps=$(az webapp list -g \"$RG_NAME\" --query \"[].name\" -o tsv 2>/dev/null) && " +
            "if [ -z \"$webapps\" ]; then echo \"ERROR: No App Service sites found\"; exit 1; fi && " +
            // Keep the interactive shell alive after successful verification so Hex1b can observe its prompt.
            "integration_verified=0 && " +
            "for attempt in $(seq 1 12); do " +
            "all_integrated=1; webapp_count=0; slot_count=0; " +
            "for webapp in $webapps; do " +
            "webapp_count=$((webapp_count + 1)); " +
            "subnet_id=$(az webapp show -g \"$RG_NAME\" -n \"$webapp\" --query \"virtualNetworkSubnetId\" -o tsv 2>/dev/null); " +
            "if [ \"$subnet_id\" != \"$expected_subnet_id\" ]; then all_integrated=0; fi; " +
            "if ! slots=$(az webapp deployment slot list -g \"$RG_NAME\" -n \"$webapp\" --query \"[].name\" -o tsv 2>/dev/null); then echo \"ERROR: Failed to query deployment slots for $webapp\"; all_integrated=0; continue; fi; " +
            "for slot in $slots; do " +
            "slot_count=$((slot_count + 1)); " +
            "subnet_id=$(az webapp show -g \"$RG_NAME\" -n \"$webapp\" --slot \"$slot\" --query \"virtualNetworkSubnetId\" -o tsv 2>/dev/null); " +
            "if [ \"$subnet_id\" != \"$expected_subnet_id\" ]; then all_integrated=0; fi; " +
            "done; " +
            "done; " +
            "if [ \"$all_integrated\" -eq 1 ] && [ \"$webapp_count\" -eq 2 ] && [ \"$slot_count\" -eq 1 ]; then echo \"Verified VNet integration on production, staging, and dashboard\"; integration_verified=1; break; fi; " +
            "echo \"Waiting for VNet integration (attempt $attempt)...\"; sleep 10; " +
            "done && " +
            "if [ \"$integration_verified\" -ne 1 ]; then echo \"ERROR: VNet integration was not configured on every site and slot\"; exit 1; fi");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
    }
 
    private static async Task TriggerCleanupResourceGroupAsync(string resourceGroupName, ITestOutputHelper output)
    {
        using var process = new System.Diagnostics.Process
        {
            StartInfo = new System.Diagnostics.ProcessStartInfo
            {
                FileName = "az",
                Arguments = $"group delete --name {resourceGroupName} --yes --no-wait",
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true
            }
        };
 
        if (!process.Start())
        {
            const string message = "Azure CLI did not start the resource group deletion request.";
            output.WriteLine(message);
            DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, message);
            return;
        }
 
        // Read both streams concurrently to avoid deadlock when a pipe buffer fills.
        var standardOutputTask = process.StandardOutput.ReadToEndAsync();
        var standardErrorTask = process.StandardError.ReadToEndAsync();
        await Task.WhenAll(process.WaitForExitAsync(), standardOutputTask, standardErrorTask);
 
        if (process.ExitCode == 0)
        {
            output.WriteLine($"Resource group deletion initiated: {resourceGroupName}");
            DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Deletion initiated");
            return;
        }
 
        var standardError = await standardErrorTask;
        var standardOutput = await standardOutputTask;
        var error = string.IsNullOrWhiteSpace(standardError) ? standardOutput : standardError;
        output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}");
        DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, $"Exit code {process.ExitCode}: {error}");
    }
}