File: TypeScriptAzureProvisioningDeploymentTests.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;
 
public sealed class TypeScriptAzureProvisioningDeploymentTests(ITestOutputHelper output)
{
    [Fact]
    public Task DeployAppConfigurationWithProvisioningOverrides()
    {
        return DeployAsync(
            nameof(DeployAppConfigurationWithProvisioningOverrides),
            "ts-proxy-appconfig",
            ["Aspire.Hosting.Azure.AppConfiguration", "Aspire.Hosting.Azure.Provisioning.AppConfiguration"],
            """
            import { createBuilder } from './.aspire/modules/aspire.mjs';
 
            const builder = await createBuilder();
            const appConfig = await builder.addAzureAppConfiguration("appconfig");
            await appConfig.configureInfrastructure(async infrastructure => {
                const store = await infrastructure.getAppConfigurationStore();
                await store.softDeleteRetentionInDays.set(1);
                await store.disableLocalAuth.set(true);
                const tags = await store.tags.get();
                await tags.set("provisioning-proxy", "typescript-appconfig");
            });
 
            await builder.build().run();
            """,
            """
            set -euo pipefail
            az appconfig list --subscription "$AZURE__SUBSCRIPTIONID" -g "$AZURE__RESOURCEGROUP" -o json > stores.json
            store_name=$(jq -er 'if length == 1 then .[0].name else error("Expected exactly one App Configuration store") end' stores.json)
            az appconfig show --subscription "$AZURE__SUBSCRIPTIONID" -g "$AZURE__RESOURCEGROUP" -n "$store_name" -o json > store.json
            jq -e '.provisioningState == "Succeeded" and .softDeleteRetentionInDays == 1 and .disableLocalAuth == true and .tags["provisioning-proxy"] == "typescript-appconfig"' store.json
            """);
    }
 
    [Fact]
    public Task DeployNetworkAndPrivateDnsWithProvisioningOverrides()
    {
        return DeployAsync(
            nameof(DeployNetworkAndPrivateDnsWithProvisioningOverrides),
            "ts-proxy-network",
            [
                "Aspire.Hosting.Azure.Network",
                "Aspire.Hosting.Azure.Provisioning.Network",
                "Aspire.Hosting.Azure.Provisioning.PrivateDns"
            ],
            """
            import { createBuilder } from './.aspire/modules/aspire.mjs';
 
            const builder = await createBuilder();
            const vnet = await builder.addAzureVirtualNetwork("vnet", { addressPrefix: "10.42.0.0/16" });
            await vnet.addSubnet("apps", "10.42.1.0/24");
            await vnet.configureInfrastructure(async infrastructure => {
                const network = await infrastructure.getVirtualNetwork();
                await network.flowTimeoutInMinutes.set(10);
                const tags = await network.tags.get();
                await tags.set("provisioning-proxy", "typescript-network");
            });
 
            const nsg = await builder.addNetworkSecurityGroup("nsg");
            await nsg.configureInfrastructure(async infrastructure => {
                const group = await infrastructure.getNetworkSecurityGroup();
                const tags = await group.tags.get();
                await tags.set("provisioning-proxy", "typescript-nsg");
            });
 
            const dns = await builder.addAzureInfrastructure("privateDns", async infrastructure => {
                const zone = await infrastructure.addPrivateDnsZone("privateDns");
                await zone.location.set(infrastructure.bicep().location("global"));
            });
            await dns.configureInfrastructure(async infrastructure => {
                const zone = await infrastructure.getPrivateDnsZone();
                await zone.name.set("provisioning.internal");
                const tags = await zone.tags.get();
                await tags.set("provisioning-proxy", "typescript-dns");
            });
 
            await builder.build().run();
            """,
            """
            set -euo pipefail
            az network vnet list --subscription "$AZURE__SUBSCRIPTIONID" -g "$AZURE__RESOURCEGROUP" -o json > networks.json
            jq -e 'length == 1 and (.[0] | .provisioningState == "Succeeded" and .flowTimeoutInMinutes == 10 and .tags["provisioning-proxy"] == "typescript-network" and .addressSpace.addressPrefixes == ["10.42.0.0/16"] and (.subnets | length == 1 and .[0].addressPrefix == "10.42.1.0/24"))' networks.json
            az network nsg list --subscription "$AZURE__SUBSCRIPTIONID" -g "$AZURE__RESOURCEGROUP" -o json > security-groups.json
            jq -e 'length == 1 and (.[0] | .provisioningState == "Succeeded" and .tags["provisioning-proxy"] == "typescript-nsg")' security-groups.json
            az network private-dns zone show --subscription "$AZURE__SUBSCRIPTIONID" -g "$AZURE__RESOURCEGROUP" -n provisioning.internal -o json > zone.json
            jq -e '.name == "provisioning.internal" and .location == "global" and .tags["provisioning-proxy"] == "typescript-dns"' zone.json
            """);
    }
 
    private async Task DeployAsync(
        string testName,
        string resourceGroupPrefix,
        string[] packages,
        string appHostSource,
        string verificationScript)
    {
        Assert.SkipUnless(OperatingSystem.IsLinux(), "Deployment terminal automation requires Linux.");
        var subscriptionId = DotnetProjectDeploymentHelpers.GetSubscriptionId();
        var strategy = DeploymentE2ETestHelpers.GetCurrentBuildCliInstallStrategy();
        Assert.SkipUnless(
            strategy.Mode is CliInstallMode.Preinstalled or CliInstallMode.LocalHive or CliInstallMode.LocalArchive or CliInstallMode.PullRequest,
            "Provisioning proxies require current-build artifacts. Set ASPIRE_E2E_ARCHIVE for a local run.");
 
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        timeout.CancelAfter(TimeSpan.FromMinutes(35));
        using var workspace = TemporaryWorkspace.Create(output);
        var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName($"{resourceGroupPrefix}-{Guid.NewGuid():N}");
        var startTime = DateTime.UtcNow;
        var deploymentAttempted = false;
 
        output.WriteLine($"Test: {testName}");
        output.WriteLine($"Resource group: {resourceGroupName}");
 
        try
        {
            using var terminalCancellation = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token);
            using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(testName: testName);
            var pendingRun = terminal.RunAsync(terminalCancellation.Token);
            var counter = new SequenceCounter();
            var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
 
            try
            {
                await auto.PrepareEnvironmentAsync(workspace, counter);
                await auto.InstallAspireCliAsync(strategy, counter, output, includeBundlePath: true, artifactName: "bundle");
                await auto.RunCommandAsync("aspire init --language typescript --non-interactive", counter, TimeSpan.FromMinutes(2));
                foreach (var package in packages)
                {
                    await DotnetProjectDeploymentHelpers.AddPackageAsync(auto, counter, package);
                }
 
                File.WriteAllText(Path.Combine(workspace.Path, "apphost.mts"), appHostSource);
                await auto.RunCommandAsync(
                    "unset ASPIRE_PLAYGROUND Azure__Location Azure__ResourceGroup Azure__SubscriptionId && " +
                    $"export AZURE__LOCATION=westus3 AZURE__RESOURCEGROUP={AspireCliShellCommandHelpers.QuoteBashArg(resourceGroupName)} " +
                    $"AZURE__SUBSCRIPTIONID={AspireCliShellCommandHelpers.QuoteBashArg(subscriptionId)}",
                    counter);
                await DotnetProjectDeploymentHelpers.RunScriptAsync(auto, counter,
                    """
                    set -euo pipefail
                    exists=$(az group exists --subscription "$AZURE__SUBSCRIPTIONID" -n "$AZURE__RESOURCEGROUP")
                    test "$exists" = false
                    """, TimeSpan.FromSeconds(30));
 
                deploymentAttempted = true;
                await auto.TypeAsync("aspire deploy --clear-cache");
                await auto.EnterAsync();
                await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(20));
                await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
                await DotnetProjectDeploymentHelpers.RunScriptAsync(auto, counter, verificationScript, TimeSpan.FromMinutes(2));
                await auto.AspireDestroyAsync(counter, TimeSpan.FromMinutes(5));
                await auto.TypeAsync("exit");
                await auto.EnterAsync();
                await pendingRun.WaitAsync(TimeSpan.FromSeconds(30), timeout.Token);
            }
            finally
            {
                // Stop the deployment terminal before cleanup, including when the test times out.
                await terminalCancellation.CancelAsync();
                try
                {
                    await pendingRun.WaitAsync(TimeSpan.FromSeconds(30));
                }
                catch (OperationCanceledException) when (terminalCancellation.IsCancellationRequested)
                {
                    // Cancellation is the expected shutdown path after a failed deployment or assertion.
                }
            }
 
            DeploymentReporter.ReportDeploymentSuccess(testName, resourceGroupName, new Dictionary<string, string>(), DateTime.UtcNow - startTime);
        }
        catch (Exception ex)
        {
            DeploymentReporter.ReportDeploymentFailure(testName, resourceGroupName, ex.Message, ex.StackTrace);
            throw;
        }
        finally
        {
            if (deploymentAttempted)
            {
                await DotnetProjectDeploymentHelpers.CleanupAsync(resourceGroupName, subscriptionId, output);
            }
        }
    }
}