File: AzureSandboxesDeploymentTests.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 System.Diagnostics;
using Aspire.Deployment.EndToEnd.Tests.Helpers;
using Hex1b;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Deployment.EndToEnd.Tests;
 
/// <summary>
/// End-to-end tests for deploying Aspire applications to Azure Container Apps sandboxes.
/// </summary>
public sealed class AzureSandboxesDeploymentTests(ITestOutputHelper output)
{
    private const string EnableSandboxesEnvironmentVariable = "ASPIRE_DEPLOYMENT_TEST_ENABLE_SANDBOXES";
    private const string SandboxStateUrlKey = "\"Azure:Sandboxes:site-sandbox-container:Ports:0:Url\"";
    private const string ExpectedResponseText = "Sandbox TS AppHost service is running.";
 
    private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(90);
 
    [Fact]
    public async Task RedeployProjectToAzureSandboxRetainsPreviousPublicUrl()
    {
        using var cts = new CancellationTokenSource(s_testTimeout);
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            cts.Token, TestContext.Current.CancellationToken);
 
        await RedeployProjectToAzureSandboxRetainsPreviousPublicUrlCore(linkedCts.Token);
    }
 
    private async Task RedeployProjectToAzureSandboxRetainsPreviousPublicUrlCore(CancellationToken cancellationToken)
    {
        if (DeploymentE2ETestHelpers.IsRunningInCI &&
            !string.Equals(Environment.GetEnvironmentVariable(EnableSandboxesEnvironmentVariable), "true", StringComparison.OrdinalIgnoreCase))
        {
            Assert.Skip($"Azure sandboxes deployment tests require preview enrollment and are disabled for this deployment environment. Set {EnableSandboxesEnvironmentVariable}=true only after the environment has the required sandbox preview access and role assignments.");
        }
 
        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 startTime = DateTime.UtcNow;
        var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("sandboxes");
        var deploymentUrls = new Dictionary<string, string>();
 
        var firstDeployOutputFile = Path.Combine(workspace.WorkspaceRoot.FullName, "first-deploy-output.txt");
        var secondDeployOutputFile = Path.Combine(workspace.WorkspaceRoot.FullName, "second-deploy-output.txt");
        var firstUrlFile = Path.Combine(workspace.WorkspaceRoot.FullName, "first-url.txt");
        var secondUrlFile = Path.Combine(workspace.WorkspaceRoot.FullName, "second-url.txt");
        var stateMarkerFile = Path.Combine(workspace.WorkspaceRoot.FullName, "state-marker");
 
        output.WriteLine($"Test: {nameof(RedeployProjectToAzureSandboxRetainsPreviousPublicUrl)}");
        output.WriteLine($"Resource Group: {resourceGroupName}");
        output.WriteLine($"Subscription: {subscriptionId[..8]}...");
        output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}");
 
        Hex1bTerminal? terminal = null;
        Task? pendingRun = null;
        Hex1bTerminalAutomator? auto = null;
        SequenceCounter? counter = null;
        var appHostReady = false;
        var destroyCompleted = false;
        var terminalExited = false;
 
        try
        {
            terminal = DeploymentE2ETestHelpers.CreateTestTerminal(width: 320, height: 60);
            pendingRun = terminal.RunAsync(cancellationToken);
 
            counter = new SequenceCounter();
            auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
 
            output.WriteLine("Step 1: Preparing environment...");
            await auto.PrepareEnvironmentAsync(workspace, counter);
 
            await auto.InstallCurrentBuildAspireBundleAsync(counter, output);
 
            output.WriteLine("Step 3: Creating TypeScript AppHost...");
            await auto.RunCommandAsync("aspire init --language typescript --non-interactive", counter, TimeSpan.FromMinutes(2));
 
            output.WriteLine("Step 4: Adding Azure sandboxes hosting package...");
            await AddPackageAsync(auto, counter, "Aspire.Hosting.Azure.Sandboxes");
 
            output.WriteLine("Step 5: Publishing a Dockerfile-backed app as an Azure sandbox from TypeScript...");
            WriteSandboxAppHost(workspace);
 
            await auto.TypeAsync($"touch {BashQuote(stateMarkerFile)}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            await auto.TypeAsync(
                $"unset ASPIRE_PLAYGROUND && " +
                $"export AZURE__LOCATION=westus3 && " +
                $"export Azure__Location=westus3 && " +
                $"export AZURE__RESOURCEGROUP={resourceGroupName} && " +
                $"export Azure__ResourceGroup={resourceGroupName} && " +
                "export COLUMNS=320");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            appHostReady = true;
 
            output.WriteLine("Step 6: Deploying the sandbox app...");
            await auto.TypeAsync($"aspire deploy 2>&1 | tee {BashQuote(firstDeployOutputFile)}");
            await auto.EnterAsync();
            await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(30));
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            await auto.TypeAsync(CaptureSandboxUrlFromStateCommand(stateMarkerFile, firstUrlFile));
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            output.WriteLine("Step 7: Verifying the first sandbox URL...");
            await auto.TypeAsync(VerifySandboxUrlCommand(firstUrlFile));
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(4));
 
            output.WriteLine("Step 8: Redeploying the sandbox app...");
            await auto.TypeAsync($"aspire deploy 2>&1 | tee {BashQuote(secondDeployOutputFile)}");
            await auto.EnterAsync();
            await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(30));
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            await auto.TypeAsync(CaptureSandboxUrlFromStateCommand(stateMarkerFile, secondUrlFile));
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            output.WriteLine("Step 9: Verifying the redeploy summary and both sandbox URLs...");
            await auto.TypeAsync(VerifyRetainedUrlSummaryCommand(firstUrlFile, secondUrlFile, secondDeployOutputFile));
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            await auto.TypeAsync(VerifySandboxUrlCommand(firstUrlFile));
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(4));
 
            await auto.TypeAsync(VerifySandboxUrlCommand(secondUrlFile));
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(4));
 
            deploymentUrls["first-retained"] = File.ReadAllText(firstUrlFile).Trim();
            deploymentUrls["second-current"] = File.ReadAllText(secondUrlFile).Trim();
 
            output.WriteLine("Step 10: Destroying the Azure sandbox deployment...");
            await auto.AspireDestroyAsync(counter, TimeSpan.FromMinutes(10));
            destroyCompleted = true;
 
            await ExitTerminalAsync(auto, pendingRun);
            terminalExited = true;
 
            var duration = DateTime.UtcNow - startTime;
            output.WriteLine($"Deployment completed in {duration}");
 
            DeploymentReporter.ReportDeploymentSuccess(
                nameof(RedeployProjectToAzureSandboxRetainsPreviousPublicUrl),
                resourceGroupName,
                deploymentUrls,
                duration);
        }
        catch (Exception ex)
        {
            var duration = DateTime.UtcNow - startTime;
            output.WriteLine($"Test failed after {duration}: {ex.Message}");
 
            DeploymentReporter.ReportDeploymentFailure(
                nameof(RedeployProjectToAzureSandboxRetainsPreviousPublicUrl),
                resourceGroupName,
                ex.Message,
                ex.StackTrace);
 
            throw;
        }
        finally
        {
            if (!destroyCompleted && appHostReady && auto is not null && counter is not null && !terminalExited)
            {
                try
                {
                    output.WriteLine("Attempting best-effort aspire destroy after failure...");
                    await auto.AspireDestroyAsync(counter, TimeSpan.FromMinutes(10));
                }
                catch (Exception ex)
                {
                    output.WriteLine($"Best-effort aspire destroy failed: {ex.Message}");
                }
            }
 
            if (!terminalExited && auto is not null && pendingRun is not null)
            {
                try
                {
                    await ExitTerminalAsync(auto, pendingRun);
                }
                catch (Exception ex)
                {
                    output.WriteLine($"Failed to exit terminal cleanly: {ex.Message}");
                }
            }
 
            terminal?.Dispose();
 
            output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}");
            var (cleanupSucceeded, cleanupMessage) = await CleanupResourceGroupAsync(resourceGroupName, subscriptionId);
            DeploymentReporter.ReportCleanupStatus(resourceGroupName, cleanupSucceeded, cleanupMessage);
        }
    }
 
    private static async Task AddPackageAsync(Hex1bTerminalAutomator auto, SequenceCounter counter, string packageName)
    {
        await auto.TypeAsync($"aspire add {packageName}");
        await auto.EnterAsync();
        await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromMinutes(3));
    }
 
    private static void WriteSandboxAppHost(TemporaryWorkspace workspace)
    {
        var siteDir = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "site"));
        File.WriteAllText(Path.Combine(siteDir.FullName, "Dockerfile"), """
            FROM nginx:1.31-alpine
            COPY index.html /usr/share/nginx/html/index.html
            EXPOSE 80
            """);
        File.WriteAllText(Path.Combine(siteDir.FullName, "index.html"), $$"""
            <!DOCTYPE html>
            <html lang="en">
            <head><meta charset="UTF-8"><title>Sandbox TS AppHost</title></head>
            <body>{{ExpectedResponseText}}</body>
            </html>
            """);
 
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts"), """
            import { AzureSandboxTier, createBuilder } from './.aspire/modules/aspire.mjs';
 
            const builder = await createBuilder();
 
            const sandboxGroup = await builder.addAzureSandboxGroup('sandboxes');
 
            await builder.addDockerfile('site', './site')
                .withHttpEndpoint({ name: 'http', targetPort: 80 })
                .withExternalHttpEndpoints()
                .publishAsAzureSandbox(sandboxGroup, {
                    tier: AzureSandboxTier.Medium,
                    endpoints: [
                        {
                            name: 'http',
                            anonymous: true
                        }
                    ]
                });
 
            await builder.build().run();
            """);
    }
 
    private static string CaptureSandboxUrlFromStateCommand(string stateMarkerFile, string outputFile)
    {
        return
            $"STATE_FILE=$(find \"$HOME/.aspire/deployments\" -name '*.json' -newer {BashQuote(stateMarkerFile)} -exec grep -l '{SandboxStateUrlKey}' {{}} + | head -n 1) && " +
            "if [ -z \"$STATE_FILE\" ]; then echo \"Sandbox deployment state file not found\"; find \"$HOME/.aspire/deployments\" -name '*.json' -newer " + BashQuote(stateMarkerFile) + " -print; exit 1; fi && " +
            "URL=$(grep -Eo '\"Azure:Sandboxes:site-sandbox-container:Ports:0:Url\"[[:space:]]*:[[:space:]]*\"[^\"]+\"' \"$STATE_FILE\" | head -n 1 | sed -E 's/^.*\"([^\"]+)\".*$/\\1/') && " +
            "if [ -z \"$URL\" ]; then echo \"Sandbox URL not found in $STATE_FILE\"; cat \"$STATE_FILE\"; exit 1; fi && " +
            $"printf '%s\\n' \"$URL\" > {BashQuote(outputFile)} && " +
            "echo \"Sandbox URL from state: $URL\"";
    }
 
    private static string VerifySandboxUrlCommand(string urlFile)
    {
        return
            $"URL=$(cat {BashQuote(urlFile)}) && " +
            "echo \"Checking $URL\" && " +
            "success=0 && " +
            "for i in $(seq 1 18); do " +
            $"BODY=$(curl -fsS \"$URL\" --max-time 10 2>/tmp/aspire-sandbox-curl.err) && echo \"$BODY\" | grep -Fq {BashQuote(ExpectedResponseText)} && {{ echo \"  OK (attempt $i)\"; success=1; break; }}; " +
            "echo \"  Attempt $i failed; retrying in 10s...\"; " +
            "sleep 10; " +
            "done; " +
            "if [ \"$success\" -ne 1 ]; then echo \"Sandbox URL check failed for $URL\"; cat /tmp/aspire-sandbox-curl.err 2>/dev/null || true; exit 1; fi";
    }
 
    private static string VerifyRetainedUrlSummaryCommand(string firstUrlFile, string secondUrlFile, string secondDeployOutputFile)
    {
        return
            $"FIRST_URL=$(cat {BashQuote(firstUrlFile)}) && " +
            $"SECOND_URL=$(cat {BashQuote(secondUrlFile)}) && " +
            "if [ \"$FIRST_URL\" = \"$SECOND_URL\" ]; then echo \"Expected redeploy to produce a new sandbox URL, but both were $FIRST_URL\"; exit 1; fi && " +
            $"NORMALIZED=$(tr -d '\\r' < {BashQuote(secondDeployOutputFile)} | tr '\\n' ' ' | sed -E 's/[[:space:]]+/ /g') && " +
            "case \"$NORMALIZED\" in *\"retained for references configured before sandbox deployment\"*) ;; *) echo \"Retained URL summary text not found\"; exit 1;; esac && " +
            "case \"$NORMALIZED\" in *\"$FIRST_URL\"*) ;; *) echo \"First URL was not reported as retained\"; exit 1;; esac && " +
            "echo \"Redeploy changed URL from $FIRST_URL to $SECOND_URL and reported the retained URL\"";
    }
 
    private static async Task ExitTerminalAsync(Hex1bTerminalAutomator auto, Task pendingRun)
    {
        await auto.TypeAsync("exit");
        await auto.EnterAsync();
        await pendingRun;
    }
 
    private async Task<(bool Success, string Message)> CleanupResourceGroupAsync(
        string resourceGroupName,
        string subscriptionId)
    {
        try
        {
            var startInfo = new ProcessStartInfo
            {
                FileName = "az",
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false
            };
 
            startInfo.ArgumentList.Add("group");
            startInfo.ArgumentList.Add("delete");
            startInfo.ArgumentList.Add("--name");
            startInfo.ArgumentList.Add(resourceGroupName);
            startInfo.ArgumentList.Add("--subscription");
            startInfo.ArgumentList.Add(subscriptionId);
            startInfo.ArgumentList.Add("--yes");
            startInfo.ArgumentList.Add("--no-wait");
 
            using var process = Process.Start(startInfo);
            if (process is null)
            {
                var message = $"Failed to start cleanup for resource group: {resourceGroupName}";
                output.WriteLine(message);
                return (false, message);
            }
 
            // Read both streams concurrently so Azure CLI cannot block while waiting for a full pipe buffer.
            var stdoutTask = process.StandardOutput.ReadToEndAsync();
            var stderrTask = process.StandardError.ReadToEndAsync();
            await process.WaitForExitAsync();
            _ = await stdoutTask;
            var stderr = await stderrTask;
            if (process.ExitCode == 0)
            {
                var message = $"Resource group deletion initiated: {resourceGroupName}";
                output.WriteLine(message);
                return (true, "Deletion initiated");
            }
 
            var failureMessage = string.IsNullOrWhiteSpace(stderr)
                ? $"Exit code {process.ExitCode}"
                : $"Exit code {process.ExitCode}: {stderr.Trim()}";
            output.WriteLine($"Resource group deletion may have failed ({failureMessage})");
            return (false, failureMessage);
        }
        catch (Exception ex)
        {
            output.WriteLine($"Failed to cleanup resource group: {ex.Message}");
            return (false, ex.Message);
        }
    }
 
    private static string BashQuote(string value)
    {
        return "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'";
    }
}