From cd3e65eb1cdcabba428ec2d8fe90db2371daef95 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 25 Mar 2026 13:02:26 +1100 Subject: [PATCH] Export WithAdminDeploymentScriptSubnet for polyglot app hosts Now that Aspire.Hosting.Azure.Network is shipped, AzureSubnetResource is available to polyglot app hosts. Replace [AspireExportIgnore] with [AspireExport] on WithAdminDeploymentScriptSubnet so TypeScript (and other polyglot) app hosts can configure explicit subnets for SQL Server admin deployment scripts. - Add [AspireExport] attribute with camelCase name and description - Update TypeScript Azure SQL validation app to exercise VNet + subnet + withAdminDeploymentScriptSubnet (validated by polyglot CI workflow) - Add TypeScript VNet SQL Server deployment E2E test Fixes #15373 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ValidationAppHost/apphost.ts | 6 + .../aspire.config.json | 3 +- .../AzureSqlExtensions.cs | 2 +- ...ScriptVnetSqlServerInfraDeploymentTests.cs | 270 ++++++++++++++++++ 4 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptVnetSqlServerInfraDeploymentTests.cs diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Sql/ValidationAppHost/apphost.ts b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Sql/ValidationAppHost/apphost.ts index 9add6023451..56ae2b514e1 100644 --- a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Sql/ValidationAppHost/apphost.ts +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Sql/ValidationAppHost/apphost.ts @@ -2,12 +2,18 @@ import { createBuilder } from './.modules/aspire.js'; const builder = await createBuilder(); const storage = await builder.addAzureStorage("storage"); + +// VNet with subnet for deployment script (validates #15373 fix) +const vnet = await builder.addAzureVirtualNetwork("vnet"); +const aciSubnet = await vnet.addSubnet("aci-subnet", "10.0.2.0/29"); + const sqlServer = await builder.addAzureSqlServer("sql"); const db = await sqlServer.addDatabase("mydb"); const db2 = await sqlServer.addDatabase("inventory", { databaseName: "inventorydb" }); await db2.withDefaultAzureSku(); await sqlServer.runAsContainer({ configureContainer: async _ => {} }); await sqlServer.withAdminDeploymentScriptStorage(storage); +await sqlServer.withAdminDeploymentScriptSubnet(aciSubnet); const _db3 = await sqlServer.addDatabase("analytics").withDefaultAzureSku(); const _hostName = await sqlServer.hostName.get(); diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Sql/aspire.config.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Sql/aspire.config.json index c9060bb88e2..26c20e499c9 100644 --- a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Sql/aspire.config.json +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Sql/aspire.config.json @@ -5,6 +5,7 @@ }, "packages": { "Aspire.Hosting.Azure.Sql": "", - "Aspire.Hosting.Azure.Storage": "" + "Aspire.Hosting.Azure.Storage": "", + "Aspire.Hosting.Azure.Network": "" } } diff --git a/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs b/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs index 17c4c05dc43..28b3ed29161 100644 --- a/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs +++ b/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs @@ -389,7 +389,7 @@ private static SqlServer CreateSqlServerResourceOnly(AzureResourceInfrastructure /// peSubnet.AddPrivateEndpoint(sql); /// /// - [AspireExportIgnore(Reason = "Azure subnet resources are not currently available to polyglot app hosts.")] + [AspireExport("withAdminDeploymentScriptSubnet", Description = "Configures the Azure SQL server to use a specific subnet for deployment scripts")] [Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] public static IResourceBuilder WithAdminDeploymentScriptSubnet( this IResourceBuilder builder, diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptVnetSqlServerInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptVnetSqlServerInfraDeploymentTests.cs new file mode 100644 index 00000000000..bea38786351 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptVnetSqlServerInfraDeploymentTests.cs @@ -0,0 +1,270 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Tests.Utils; +using Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// L1 infrastructure verification test for deploying a TypeScript AppHost with Azure SQL Server, +/// VNet, and Private Endpoint to Azure. Validates that withAdminDeploymentScriptSubnet works +/// correctly in polyglot (TypeScript) app hosts. +/// +public sealed class TypeScriptVnetSqlServerInfraDeploymentTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); + + [Fact] + public async Task DeployTypeScriptVnetSqlServerInfrastructure() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployTypeScriptVnetSqlServerInfrastructureCore(cancellationToken); + } + + private async Task DeployTypeScriptVnetSqlServerInfrastructureCore(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."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("ts-vnet-sql-l1"); + + output.WriteLine($"Test: {nameof(DeployTypeScriptVnetSqlServerInfrastructure)}"); + 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 (in CI) + // TypeScript apphosts need the full bundle because + // the prebuilt AppHost server is required for aspire add to regenerate SDK code. + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + var prNumber = DeploymentE2ETestHelpers.GetPrNumber(); + if (prNumber > 0) + { + output.WriteLine($"Step 2: Installing Aspire bundle from PR #{prNumber}..."); + await auto.InstallAspireBundleFromPullRequestAsync(prNumber, counter); + } + await auto.SourceAspireBundleEnvironmentAsync(counter); + } + + // Step 3: Create TypeScript AppHost using aspire init + output.WriteLine("Step 3: Creating TypeScript AppHost with aspire init..."); + + var waitingForNuGetConfigPrompt = new CellPatternSearcher() + .Find("NuGet.config"); + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + await auto.TypeAsync("aspire init --language typescript"); + await auto.EnterAsync(); + + // NuGet.config prompt may or may not appear depending on environment. + await auto.WaitUntilAsync( + s => waitingForNuGetConfigPrompt.Search(s).Count > 0 + || waitingForInitComplete.Search(s).Count > 0, + timeout: TimeSpan.FromMinutes(2), + description: "NuGet.config prompt or init completion"); + await auto.EnterAsync(); // Dismiss NuGet.config prompt if present + + await auto.WaitUntilAsync( + s => waitingForInitComplete.Search(s).Count > 0, + timeout: TimeSpan.FromMinutes(2), + description: "aspire initialization complete"); + + await auto.DeclineAgentInitPromptAsync(counter); + + // Step 4a: Add Aspire.Hosting.Azure.AppContainers + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + await auto.TypeAsync("aspire add Aspire.Hosting.Azure.AppContainers"); + await auto.EnterAsync(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60)); + await auto.EnterAsync(); + } + + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.Network + output.WriteLine("Step 4b: Adding Azure Network hosting package..."); + await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Network"); + await auto.EnterAsync(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60)); + await auto.EnterAsync(); + } + + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + + // Step 4c: Add Aspire.Hosting.Azure.Sql + output.WriteLine("Step 4c: Adding Azure SQL hosting package..."); + await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Sql"); + await auto.EnterAsync(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60)); + await auto.EnterAsync(); + } + + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.ts to add VNet + PE + SQL infrastructure + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"); + output.WriteLine($"Looking for apphost.ts at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + content = content.Replace( + "await builder.build().run();", + """ +// Add Azure Container App Environment for deployment +const env = await builder.addAzureContainerAppEnvironment("env"); + +// VNet with delegated subnet for ACA and PE subnet +const vnet = await builder.addAzureVirtualNetwork("vnet"); +const acaSubnet = await vnet.addSubnet("aca-subnet", "10.0.0.0/23"); +const peSubnet = await vnet.addSubnet("pe-subnet", "10.0.2.0/24"); +const aciSubnet = await vnet.addSubnet("aci-subnet", "10.0.3.0/29"); + +await env.withDelegatedSubnet(acaSubnet); + +// SQL Server with Private Endpoint and explicit deployment script subnet +const sql = await builder.addAzureSqlServer("sql"); +const db = await sql.addDatabase("db"); +await peSubnet.addPrivateEndpoint(sql); +await sql.withAdminDeploymentScriptSubnet(aciSubnet); + +await builder.build().run(); +"""); + + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.ts with VNet + SQL Server PE + deployment script subnet infrastructure"); + output.WriteLine($"New content:\n{content}"); + } + + // Step 6: Set environment variables for deployment + await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 7: Deploy to Azure + output.WriteLine("Step 7: Starting Azure deployment..."); + await auto.TypeAsync("aspire deploy --clear-cache"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("PIPELINE SUCCEEDED", timeout: TimeSpan.FromMinutes(25)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify VNet infrastructure + output.WriteLine("Step 8: Verifying VNet infrastructure..."); + await auto.TypeAsync($"az network vnet list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv | head -5 && " + + $"echo \"---PE---\" && az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " + + $"echo \"---DNS---\" && az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60)); + + // Step 9: Exit terminal + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployTypeScriptVnetSqlServerInfrastructure), + resourceGroupName, + new Dictionary(), + duration); + + output.WriteLine("✅ Test passed!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployTypeScriptVnetSqlServerInfrastructure), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + } + + private void TriggerCleanupResourceGroup(string resourceGroupName) + { + 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 + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +}