From efd24cb8a5098d58ec097a0e36ad42d43ed517c0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 11 Feb 2026 17:24:54 +1100 Subject: [PATCH 01/10] Add WithCompactResourceNaming() to fix storage name collisions Fixes #14427. When Azure Container App environment names are long, the uniqueString suffix gets truncated in storage account names, causing naming collisions across deployments. WithCompactResourceNaming() is an opt-in method that shortens storage account and managed storage names to preserve the full 13-char uniqueString while keeping names within Azure's length limits. - Storage accounts: take('{prefix}sv{resourceToken}', 24) - Managed storage: take('{name}-{volume}-{resourceToken}', 32) - File shares: take('{name}-{volume}', 60) Includes unit tests with snapshot verification and E2E deployment tests covering both the fix and upgrade safety scenarios. --- .../AzureContainerAppEnvironmentResource.cs | 2 + .../AzureContainerAppExtensions.cs | 77 ++++- .../AcaCompactNamingDeploymentTests.cs | 239 ++++++++++++++ .../AcaCompactNamingUpgradeDeploymentTests.cs | 294 ++++++++++++++++++ .../Helpers/DeploymentE2ETestHelpers.cs | 15 + .../AzureContainerAppsTests.cs | 59 ++++ ...NamingPreservesUniqueString.verified.bicep | 129 ++++++++ ...tNamingPreservesUniqueString.verified.json | 8 + ...tipleVolumesHaveUniqueNames.verified.bicep | 177 +++++++++++ ...ltipleVolumesHaveUniqueNames.verified.json | 8 + 10 files changed, 1007 insertions(+), 1 deletion(-) create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.json create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.json diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs index 99f2124b0ca..7809daaa638 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs @@ -141,6 +141,8 @@ await context.ReportingStep.CompleteAsync( } internal bool UseAzdNamingConvention { get; set; } + internal bool UseCompactResourceNaming { get; set; } + /// /// Gets or sets a value indicating whether the Aspire dashboard should be included in the container app environment. /// Default is true. diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs index b86febc57ec..009f0617d8d 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs @@ -75,7 +75,7 @@ public static IResourceBuilder AddAzureCon infra.Add(tags); ProvisioningVariable? resourceToken = null; - if (appEnvResource.UseAzdNamingConvention) + if (appEnvResource.UseAzdNamingConvention || appEnvResource.UseCompactResourceNaming) { resourceToken = new ProvisioningVariable("resourceToken", typeof(string)) { @@ -256,6 +256,30 @@ public static IResourceBuilder AddAzureCon $"{BicepFunction.ToLower(output.resource.Name)}-{BicepFunction.ToLower(volumeName)}"), 32); } + else if (appEnvResource.UseCompactResourceNaming) + { + Debug.Assert(resourceToken is not null); + + var volumeName = output.volume.Type switch + { + ContainerMountType.BindMount => $"bm{output.index}", + ContainerMountType.Volume => output.volume.Source ?? $"v{output.index}", + _ => throw new NotSupportedException() + }; + + // Remove '.' and '-' characters from volumeName + volumeName = volumeName.Replace(".", "").Replace("-", ""); + + share.Name = BicepFunction.Take( + BicepFunction.Interpolate( + $"{BicepFunction.ToLower(output.resource.Name)}-{BicepFunction.ToLower(volumeName)}"), + 60); + + containerAppStorage.Name = BicepFunction.Take( + BicepFunction.Interpolate( + $"{BicepFunction.ToLower(output.resource.Name)}-{BicepFunction.ToLower(volumeName)}-{resourceToken}"), + 32); + } } } @@ -292,6 +316,28 @@ public static IResourceBuilder AddAzureCon storageVolume.Name = BicepFunction.Interpolate($"vol{resourceToken}"); } } + else if (appEnvResource.UseCompactResourceNaming) + { + Debug.Assert(resourceToken is not null); + +#pragma warning disable IDE0031 // Use null propagation (IDE0031) + if (storageVolume is not null) +#pragma warning restore IDE0031 + { + // Sanitize env name for storage accounts: lowercase alphanumeric only. + // Reserve 2 chars for "sv" prefix + 13 for uniqueString = 15, leaving 9 for the env name. + var sanitizedPrefix = new string(appEnvResource.Name.ToLowerInvariant() + .Where(c => char.IsLetterOrDigit(c)).ToArray()); + if (sanitizedPrefix.Length > 9) + { + sanitizedPrefix = sanitizedPrefix[..9]; + } + + storageVolume.Name = BicepFunction.Take( + BicepFunction.Interpolate($"{sanitizedPrefix}sv{resourceToken}"), + 24); + } + } // Exposed so that callers reference the LA workspace in other bicep modules infra.Add(new ProvisioningOutput("AZURE_LOG_ANALYTICS_WORKSPACE_NAME", typeof(string)) @@ -370,6 +416,35 @@ public static IResourceBuilder WithAzdReso return builder; } + /// + /// Configures the container app environment to use compact resource naming that maximally preserves + /// the uniqueString suffix for length-constrained Azure resources such as storage accounts. + /// + /// The to configure. + /// A reference to the for chaining. + /// + /// + /// By default, the generated Azure resource names use long static suffixes (e.g. storageVolume, + /// managedStorage) that can consume most of the 24-character storage account name limit, truncating + /// the uniqueString(resourceGroup().id) portion that provides cross-deployment uniqueness. + /// + /// + /// When enabled, this method shortens the static portions of generated names so the full 13-character + /// uniqueString is preserved. This prevents naming collisions when deploying multiple environments + /// to different resource groups. + /// + /// + /// This option only affects volume-related storage resources. It does not change the naming of the + /// container app environment, container registry, log analytics workspace, or managed identity. + /// Use to change those names as well. + /// + /// + public static IResourceBuilder WithCompactResourceNaming(this IResourceBuilder builder) + { + builder.Resource.UseCompactResourceNaming = true; + return builder; + } + /// /// Configures whether the Aspire dashboard should be included in the container app environment. /// diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs new file mode 100644 index 00000000000..b8945597d64 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs @@ -0,0 +1,239 @@ +// 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; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for compact resource naming with Azure Container App Environments. +/// Validates that WithCompactResourceNaming() fixes storage account naming collisions +/// caused by long environment names, and that the default naming is unchanged on upgrade. +/// +public sealed class AcaCompactNamingDeploymentTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); + + /// + /// Verifies that deploying with a long ACA environment name and a volume + /// succeeds when WithCompactResourceNaming() is used. + /// The storage account name would otherwise exceed 24 chars and truncate the uniqueString. + /// + [Fact] + public async Task DeployWithCompactNamingFixesStorageCollision() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + + await DeployWithCompactNamingFixesStorageCollisionCore(linkedCts.Token); + } + + private async Task DeployWithCompactNamingFixesStorageCollisionCore(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 recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployWithCompactNamingFixesStorageCollision)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("compact"); + + output.WriteLine($"Test: {nameof(DeployWithCompactNamingFixesStorageCollision)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost + output.WriteLine("Step 3: Creating single-file AppHost..."); + sequenceBuilder.Type("aspire init") + .Enter() + .Wait(TimeSpan.FromSeconds(5)) + .Enter() + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4: Add required packages + output.WriteLine("Step 4: Adding Azure Container Apps package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs with a long environment name and a container with volume. + // Use WithCompactResourceNaming() so the storage account name preserves the uniqueString. + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Long env name (16 chars) would truncate uniqueString without compact naming +builder.AddAzureContainerAppEnvironment("my-long-env-name") + .WithCompactResourceNaming(); + +// Container with a volume triggers storage account creation +builder.AddContainer("worker", "mcr.microsoft.com/dotnet/samples", "aspnetapp") + .WithVolume("data", "/app/data"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs with long env name + compact naming + volume"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy + output.WriteLine("Step 7: Deploying with compact naming..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify storage account was created and name contains uniqueString + output.WriteLine("Step 8: Verifying storage account naming..."); + sequenceBuilder + .Type($"STORAGE_NAMES=$(az storage account list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv) && " + + "echo \"Storage accounts: $STORAGE_NAMES\" && " + + "STORAGE_COUNT=$(echo \"$STORAGE_NAMES\" | wc -l) && " + + "echo \"Count: $STORAGE_COUNT\" && " + + // Verify each storage name contains 'sv' (compact naming marker) + "for name in $STORAGE_NAMES; do " + + "if echo \"$name\" | grep -q 'sv'; then echo \"✅ $name uses compact naming\"; " + + "else echo \"⚠️ $name does not use compact naming (may be ACR storage)\"; fi; " + + "done") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit + sequenceBuilder.Type("exit").Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"✅ Test completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployWithCompactNamingFixesStorageCollision), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"❌ Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployWithCompactNamingFixesStorageCollision), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + 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 + } + }; + + process.Start(); + await process.WaitForExitAsync(); + output.WriteLine(process.ExitCode == 0 + ? $"Resource group deletion initiated: {resourceGroupName}" + : $"Resource group deletion may have failed (exit code {process.ExitCode})"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs new file mode 100644 index 00000000000..0a8023acd16 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -0,0 +1,294 @@ +// 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; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// Upgrade safety test: deploys with the GA Aspire CLI, then upgrades to the dev (PR) CLI +/// and redeploys WITHOUT enabling compact naming. Verifies that the default naming behavior +/// is unchanged — no duplicate storage accounts are created on upgrade. +/// +public sealed class AcaCompactNamingUpgradeDeploymentTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(60); + + /// + /// Deploys with GA CLI → upgrades to dev CLI → redeploys same apphost → verifies + /// no duplicate storage accounts were created (default naming unchanged). + /// + [Fact] + public async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccounts() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + + await UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(linkedCts.Token); + } + + private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(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 recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(UpgradeFromGaToDevDoesNotDuplicateStorageAccounts)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("upgrade"); + + output.WriteLine($"Test: {nameof(UpgradeFromGaToDevDoesNotDuplicateStorageAccounts)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // ============================================================ + // Phase 1: Install GA CLI and deploy + // ============================================================ + + // Step 2: Install the GA (release) Aspire CLI + output.WriteLine("Step 2: Installing GA Aspire CLI..."); + sequenceBuilder.InstallAspireCliRelease(counter); + + // Step 3: Source CLI environment + output.WriteLine("Step 3: Configuring CLI environment..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + + // Step 4: Log the GA CLI version + output.WriteLine("Step 4: Logging GA CLI version..."); + sequenceBuilder.Type("aspire --version") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Create single-file AppHost with GA CLI + output.WriteLine("Step 5: Creating single-file AppHost with GA CLI..."); + sequenceBuilder.Type("aspire init") + .Enter() + .Wait(TimeSpan.FromSeconds(5)) + .Enter() + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 6: Add ACA package using GA CLI (uses GA NuGet packages) + output.WriteLine("Step 6: Adding Azure Container Apps package (GA)..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 7: Modify apphost.cs with a short env name (fits within 24 chars with default naming) + // and a container with volume to trigger storage account creation + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + // Use short name "env" (3 chars) so default naming works: "envstoragevolume" (16) + uniqueString fits in 24 + var replacement = """ +builder.AddAzureContainerAppEnvironment("env"); + +builder.AddContainer("worker", "mcr.microsoft.com/dotnet/samples", "aspnetapp") + .WithVolume("data", "/app/data"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine("Modified apphost.cs with short env name + volume (GA-compatible)"); + }); + + // Step 8: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 9: Deploy with GA CLI + output.WriteLine("Step 9: First deployment with GA CLI..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Record the storage account count after first deploy + output.WriteLine("Step 10: Recording storage account count after GA deploy..."); + sequenceBuilder + .Type($"GA_STORAGE_COUNT=$(az storage account list -g \"{resourceGroupName}\" --query \"length([])\" -o tsv) && " + + "echo \"GA deploy storage count: $GA_STORAGE_COUNT\"") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // ============================================================ + // Phase 2: Upgrade to dev CLI and redeploy + // ============================================================ + + // Step 11: Install the dev (PR) CLI, overwriting the GA installation + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 11: Upgrading to dev CLI (pre-installed in CI)..."); + // In CI, the dev CLI is already available — just re-source with the dev build path + // The CI workflow builds the CLI and places it in ~/.aspire/bin before tests run. + // Since we installed GA over it in step 2, we need to reinstall the dev build. + // Use the workflow's pre-built CLI artifact path. + sequenceBuilder + .Type("cp -f /tmp/aspire-cli-dev/aspire ~/.aspire/bin/aspire 2>/dev/null || echo 'Dev CLI not at /tmp, checking env...'") + .Enter() + .WaitForSuccessPrompt(counter); + + // Re-source environment to pick up the dev CLI + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + else + { + // For local testing, use the PR install script if GITHUB_PR_NUMBER is set + var prNumber = DeploymentE2ETestHelpers.GetPrNumber(); + if (prNumber > 0) + { + output.WriteLine($"Step 11: Upgrading to dev CLI from PR #{prNumber}..."); + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + else + { + output.WriteLine("Step 11: No PR number available, using current CLI as 'dev'..."); + } + } + + // Step 12: Log the dev CLI version + output.WriteLine("Step 12: Logging dev CLI version..."); + sequenceBuilder.Type("aspire --version") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 13: Redeploy with dev CLI — same apphost, NO compact naming + // This proves the default naming didn't change on upgrade + output.WriteLine("Step 13: Redeploying with dev CLI (same apphost, no compact naming)..."); + sequenceBuilder + .Type("aspire deploy") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 14: Verify no duplicate storage accounts + output.WriteLine("Step 14: Verifying no duplicate storage accounts..."); + sequenceBuilder + .Type($"DEV_STORAGE_COUNT=$(az storage account list -g \"{resourceGroupName}\" --query \"length([])\" -o tsv) && " + + "echo \"Dev deploy storage count: $DEV_STORAGE_COUNT\" && " + + "echo \"GA deploy storage count: $GA_STORAGE_COUNT\" && " + + "if [ \"$DEV_STORAGE_COUNT\" = \"$GA_STORAGE_COUNT\" ]; then " + + "echo '✅ No duplicate storage accounts — default naming unchanged on upgrade'; " + + "else " + + "echo \"❌ Storage count changed from $GA_STORAGE_COUNT to $DEV_STORAGE_COUNT — NAMING REGRESSION\"; exit 1; " + + "fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 15: Exit + sequenceBuilder.Type("exit").Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"✅ Upgrade test completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(UpgradeFromGaToDevDoesNotDuplicateStorageAccounts), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"❌ Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(UpgradeFromGaToDevDoesNotDuplicateStorageAccounts), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + 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 + } + }; + + process.Start(); + await process.WaitForExitAsync(); + output.WriteLine(process.ExitCode == 0 + ? $"Resource group deletion initiated: {resourceGroupName}" + : $"Resource group deletion may have failed (exit code {process.ExitCode})"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs index 1b07a17e257..905b001719b 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs @@ -144,6 +144,21 @@ internal static Hex1bTerminalInputSequenceBuilder InstallAspireCliFromPullReques .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(300)); } + /// + /// Installs the latest GA (release quality) Aspire CLI. + /// + internal static Hex1bTerminalInputSequenceBuilder InstallAspireCliRelease( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter) + { + var command = "curl -fsSL https://aka.ms/aspire/get/install.sh | bash -s -- --quality release"; + + return builder + .Type(command) + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(300)); + } + /// /// Configures the PATH and environment variables for the Aspire CLI. /// diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 2ffe5d25d2f..82ff0e029dc 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -1326,6 +1326,65 @@ await Verify(manifest.ToString(), "json") .AppendContentAsFile(bicep, "bicep"); } + [Fact] + public async Task AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + // Use a deliberately long name (15 chars) that would cause collisions without compact naming + var env = builder.AddAzureContainerAppEnvironment("my-long-env-name"); + env.WithCompactResourceNaming(); + + var pg = builder.AddAzurePostgresFlexibleServer("pg") + .WithPasswordAuthentication() + .AddDatabase("db"); + + builder.AddContainer("cache", "redis") + .WithVolume("App.da-ta", "/data") + .WithReference(pg); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var environment = Assert.Single(model.Resources.OfType()); + + var (manifest, bicep) = await GetManifestWithBicep(environment); + + await Verify(manifest.ToString(), "json") + .AppendContentAsFile(bicep, "bicep"); + } + + [Fact] + public async Task CompactNamingMultipleVolumesHaveUniqueNames() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var env = builder.AddAzureContainerAppEnvironment("my-ace"); + env.WithCompactResourceNaming(); + + builder.AddContainer("druid", "apache/druid", "34.0.0") + .WithHttpEndpoint(targetPort: 8081) + .WithVolume("druid_shared", "/opt/shared") + .WithVolume("coordinator_var", "/opt/druid/var") + .WithBindMount("./config", "/opt/druid/conf"); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var environment = Assert.Single(model.Resources.OfType()); + + var (manifest, bicep) = await GetManifestWithBicep(environment); + + await Verify(manifest.ToString(), "json") + .AppendContentAsFile(bicep, "bicep"); + } + // see https://github.com/dotnet/aspire/issues/8381 for more information on this scenario // Azure SqlServer needs an admin when it is first provisioned. To supply this, we use the // principalId from the Azure Container App Environment. diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.bicep new file mode 100644 index 00000000000..fe2f52e7b54 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.bicep @@ -0,0 +1,129 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param my_long_env_name_acr_outputs_name string + +var resourceToken = uniqueString(resourceGroup().id) + +resource my_long_env_name_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('my_long_env_name_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource my_long_env_name_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: my_long_env_name_acr_outputs_name +} + +resource my_long_env_name_acr_my_long_env_name_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(my_long_env_name_acr.id, my_long_env_name_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: my_long_env_name_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: my_long_env_name_acr +} + +resource my_long_env_name_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('mylongenvnamelaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource my_long_env_name 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('mylongenvname${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: my_long_env_name_law.properties.customerId + sharedKey: my_long_env_name_law.listKeys().primarySharedKey + } + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: my_long_env_name +} + +resource my_long_env_name_storageVolume 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('mylongenvsv${resourceToken}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_LRS' + } + properties: { + largeFileSharesState: 'Enabled' + minimumTlsVersion: 'TLS1_2' + } + tags: tags +} + +resource storageVolumeFileService 'Microsoft.Storage/storageAccounts/fileServices@2024-01-01' = { + name: 'default' + parent: my_long_env_name_storageVolume +} + +resource shares_volumes_cache_0 'Microsoft.Storage/storageAccounts/fileServices/shares@2024-01-01' = { + name: take('${toLower('cache')}-${toLower('Appdata')}', 60) + properties: { + enabledProtocols: 'SMB' + shareQuota: 1024 + } + parent: storageVolumeFileService +} + +resource managedStorage_volumes_cache_0 'Microsoft.App/managedEnvironments/storages@2025-01-01' = { + name: take('${toLower('cache')}-${toLower('Appdata')}-${resourceToken}', 32) + properties: { + azureFile: { + accountName: my_long_env_name_storageVolume.name + accountKey: my_long_env_name_storageVolume.listKeys().keys[0].value + accessMode: 'ReadWrite' + shareName: shares_volumes_cache_0.name + } + } + parent: my_long_env_name +} + +output volumes_cache_0 string = managedStorage_volumes_cache_0.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = my_long_env_name_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = my_long_env_name_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = my_long_env_name_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = my_long_env_name_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = my_long_env_name_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = my_long_env_name.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = my_long_env_name.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = my_long_env_name.properties.defaultDomain \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.json new file mode 100644 index 00000000000..93e67adc240 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.json @@ -0,0 +1,8 @@ +{ + "type": "azure.bicep.v0", + "path": "my-long-env-name.module.bicep", + "params": { + "my_long_env_name_acr_outputs_name": "{my-long-env-name-acr.outputs.name}", + "userPrincipalId": "" + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.bicep new file mode 100644 index 00000000000..3fe9e593aa4 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.bicep @@ -0,0 +1,177 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param my_ace_acr_outputs_name string + +var resourceToken = uniqueString(resourceGroup().id) + +resource my_ace_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('my_ace_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource my_ace_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: my_ace_acr_outputs_name +} + +resource my_ace_acr_my_ace_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(my_ace_acr.id, my_ace_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: my_ace_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: my_ace_acr +} + +resource my_ace_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('myacelaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource my_ace 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('myace${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: my_ace_law.properties.customerId + sharedKey: my_ace_law.listKeys().primarySharedKey + } + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: my_ace +} + +resource my_ace_storageVolume 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('myacesv${resourceToken}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_LRS' + } + properties: { + largeFileSharesState: 'Enabled' + minimumTlsVersion: 'TLS1_2' + } + tags: tags +} + +resource storageVolumeFileService 'Microsoft.Storage/storageAccounts/fileServices@2024-01-01' = { + name: 'default' + parent: my_ace_storageVolume +} + +resource shares_volumes_druid_0 'Microsoft.Storage/storageAccounts/fileServices/shares@2024-01-01' = { + name: take('${toLower('druid')}-${toLower('druid_shared')}', 60) + properties: { + enabledProtocols: 'SMB' + shareQuota: 1024 + } + parent: storageVolumeFileService +} + +resource managedStorage_volumes_druid_0 'Microsoft.App/managedEnvironments/storages@2025-01-01' = { + name: take('${toLower('druid')}-${toLower('druid_shared')}-${resourceToken}', 32) + properties: { + azureFile: { + accountName: my_ace_storageVolume.name + accountKey: my_ace_storageVolume.listKeys().keys[0].value + accessMode: 'ReadWrite' + shareName: shares_volumes_druid_0.name + } + } + parent: my_ace +} + +resource shares_volumes_druid_1 'Microsoft.Storage/storageAccounts/fileServices/shares@2024-01-01' = { + name: take('${toLower('druid')}-${toLower('coordinator_var')}', 60) + properties: { + enabledProtocols: 'SMB' + shareQuota: 1024 + } + parent: storageVolumeFileService +} + +resource managedStorage_volumes_druid_1 'Microsoft.App/managedEnvironments/storages@2025-01-01' = { + name: take('${toLower('druid')}-${toLower('coordinator_var')}-${resourceToken}', 32) + properties: { + azureFile: { + accountName: my_ace_storageVolume.name + accountKey: my_ace_storageVolume.listKeys().keys[0].value + accessMode: 'ReadWrite' + shareName: shares_volumes_druid_1.name + } + } + parent: my_ace +} + +resource shares_bindmounts_druid_0 'Microsoft.Storage/storageAccounts/fileServices/shares@2024-01-01' = { + name: take('${toLower('druid')}-${toLower('bm0')}', 60) + properties: { + enabledProtocols: 'SMB' + shareQuota: 1024 + } + parent: storageVolumeFileService +} + +resource managedStorage_bindmounts_druid_0 'Microsoft.App/managedEnvironments/storages@2025-01-01' = { + name: take('${toLower('druid')}-${toLower('bm0')}-${resourceToken}', 32) + properties: { + azureFile: { + accountName: my_ace_storageVolume.name + accountKey: my_ace_storageVolume.listKeys().keys[0].value + accessMode: 'ReadWrite' + shareName: shares_bindmounts_druid_0.name + } + } + parent: my_ace +} + +output volumes_druid_0 string = managedStorage_volumes_druid_0.name + +output volumes_druid_1 string = managedStorage_volumes_druid_1.name + +output bindmounts_druid_0 string = managedStorage_bindmounts_druid_0.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = my_ace_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = my_ace_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = my_ace_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = my_ace_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = my_ace_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = my_ace.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = my_ace.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = my_ace.properties.defaultDomain \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.json new file mode 100644 index 00000000000..5f4e5709703 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.json @@ -0,0 +1,8 @@ +{ + "type": "azure.bicep.v0", + "path": "my-ace.module.bicep", + "params": { + "my_ace_acr_outputs_name": "{my-ace-acr.outputs.name}", + "userPrincipalId": "" + } +} \ No newline at end of file From 02c3c6818bb4275126f19b5fec983913c28821f4 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 11 Feb 2026 17:52:36 +1100 Subject: [PATCH 02/10] Fix upgrade test: handle version prompt and backup/restore dev CLI - Add version selection prompt handling for 'aspire add' (same as passing test) - Back up dev CLI before GA install, restore after GA phase - Update package to dev version after CLI restoration - Set channel back to local after restore --- .../AcaCompactNamingUpgradeDeploymentTests.cs | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs index 0a8023acd16..5b8a2855853 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -76,6 +76,9 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell var waitingForInitComplete = new CellPatternSearcher() .Find("Aspire initialization complete"); + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + var waitingForPipelineSucceeded = new CellPatternSearcher() .Find("PIPELINE SUCCEEDED"); @@ -90,8 +93,15 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell // Phase 1: Install GA CLI and deploy // ============================================================ - // Step 2: Install the GA (release) Aspire CLI - output.WriteLine("Step 2: Installing GA Aspire CLI..."); + // Step 2: Back up the dev CLI (pre-installed by CI), then install the GA CLI + output.WriteLine("Step 2: Backing up dev CLI and installing GA Aspire CLI..."); + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .Type("cp ~/.aspire/bin/aspire /tmp/aspire-dev-backup && cp -r ~/.aspire/hives /tmp/aspire-hives-backup 2>/dev/null; echo 'dev CLI backed up'") + .Enter() + .WaitForSuccessPrompt(counter); + } sequenceBuilder.InstallAspireCliRelease(counter); // Step 3: Source CLI environment @@ -116,6 +126,8 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell // Step 6: Add ACA package using GA CLI (uses GA NuGet packages) output.WriteLine("Step 6: Adding Azure Container Apps package (GA)..."); sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter() + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); @@ -171,18 +183,29 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell // Step 11: Install the dev (PR) CLI, overwriting the GA installation if (DeploymentE2ETestHelpers.IsRunningInCI) { - output.WriteLine("Step 11: Upgrading to dev CLI (pre-installed in CI)..."); - // In CI, the dev CLI is already available — just re-source with the dev build path - // The CI workflow builds the CLI and places it in ~/.aspire/bin before tests run. - // Since we installed GA over it in step 2, we need to reinstall the dev build. - // Use the workflow's pre-built CLI artifact path. + output.WriteLine("Step 11: Restoring dev CLI from backup..."); + // Restore the dev CLI and hive that we backed up before GA install sequenceBuilder - .Type("cp -f /tmp/aspire-cli-dev/aspire ~/.aspire/bin/aspire 2>/dev/null || echo 'Dev CLI not at /tmp, checking env...'") + .Type("cp -f /tmp/aspire-dev-backup ~/.aspire/bin/aspire && cp -rf /tmp/aspire-hives-backup/* ~/.aspire/hives/ 2>/dev/null; echo 'dev CLI restored'") + .Enter() + .WaitForSuccessPrompt(counter); + + // Ensure the dev CLI uses the local channel (GA install may have changed it) + sequenceBuilder + .Type("aspire config set channel local --global 2>/dev/null; echo 'channel set'") .Enter() .WaitForSuccessPrompt(counter); // Re-source environment to pick up the dev CLI sequenceBuilder.SourceAspireCliEnvironment(counter); + + // Update the ACA package to the dev version so the dev naming code is used + output.WriteLine("Step 11b: Updating package to dev version..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter() + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); } else { From 70d2d79f56b7eebf487eb1052b9956468d29aa42 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 11 Feb 2026 11:27:02 -0600 Subject: [PATCH 03/10] Remove the manifest from verify tests. It is not necessary. --- .../AzureContainerAppsTests.cs | 10 ++++------ ...ithCompactNamingPreservesUniqueString.verified.json | 8 -------- ...tNamingMultipleVolumesHaveUniqueNames.verified.json | 8 -------- 3 files changed, 4 insertions(+), 22 deletions(-) delete mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.json delete mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.json diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 82ff0e029dc..e9a0bb6c563 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -1351,10 +1351,9 @@ public async Task AddContainerAppEnvironmentWithCompactNamingPreservesUniqueStri var environment = Assert.Single(model.Resources.OfType()); - var (manifest, bicep) = await GetManifestWithBicep(environment); + var manifest = await GetManifestWithBicep(environment); - await Verify(manifest.ToString(), "json") - .AppendContentAsFile(bicep, "bicep"); + await Verify(manifest.BicepText, "bicep"); } [Fact] @@ -1379,10 +1378,9 @@ public async Task CompactNamingMultipleVolumesHaveUniqueNames() var environment = Assert.Single(model.Resources.OfType()); - var (manifest, bicep) = await GetManifestWithBicep(environment); + var manifest = await GetManifestWithBicep(environment); - await Verify(manifest.ToString(), "json") - .AppendContentAsFile(bicep, "bicep"); + await Verify(manifest.BicepText, "bicep"); } // see https://github.com/dotnet/aspire/issues/8381 for more information on this scenario diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.json deleted file mode 100644 index 93e67adc240..00000000000 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "type": "azure.bicep.v0", - "path": "my-long-env-name.module.bicep", - "params": { - "my_long_env_name_acr_outputs_name": "{my-long-env-name-acr.outputs.name}", - "userPrincipalId": "" - } -} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.json deleted file mode 100644 index 5f4e5709703..00000000000 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "type": "azure.bicep.v0", - "path": "my-ace.module.bicep", - "params": { - "my_ace_acr_outputs_name": "{my-ace-acr.outputs.name}", - "userPrincipalId": "" - } -} \ No newline at end of file From c1721f88bc0c2580b19410405f1bf343e80ff3c0 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 11 Feb 2026 11:46:47 -0600 Subject: [PATCH 04/10] Remove unnecessary suppression --- .../AzureContainerAppExtensions.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs index 009f0617d8d..92710731082 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs @@ -320,9 +320,7 @@ public static IResourceBuilder AddAzureCon { Debug.Assert(resourceToken is not null); -#pragma warning disable IDE0031 // Use null propagation (IDE0031) if (storageVolume is not null) -#pragma warning restore IDE0031 { // Sanitize env name for storage accounts: lowercase alphanumeric only. // Reserve 2 chars for "sv" prefix + 13 for uniqueString = 15, leaving 9 for the env name. From c8535492577989a62813ce55ed21504d89263fee Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 12 Feb 2026 09:22:46 +1100 Subject: [PATCH 05/10] Fix upgrade test: use 'aspire update' to actually upgrade project packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upgrade test was only swapping the CLI binary but the apphost.cs still had #:package directives pointing to GA 13.1.0 packages. The deployment logic comes from the NuGet packages, not the CLI, so the test was actually redeploying with the old GA naming code both times. Now uses 'aspire update --channel local' to update the #:package directives in apphost.cs from GA → dev version, ensuring the dev naming code is exercised during the second deployment. --- .../AcaCompactNamingUpgradeDeploymentTests.cs | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs index 5b8a2855853..265a59cee93 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -79,6 +79,9 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell var waitingForVersionSelectionPrompt = new CellPatternSearcher() .Find("(based on NuGet.config)"); + var waitingForUpdateSuccessful = new CellPatternSearcher() + .Find("Update successful"); + var waitingForPipelineSucceeded = new CellPatternSearcher() .Find("PIPELINE SUCCEEDED"); @@ -199,13 +202,14 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell // Re-source environment to pick up the dev CLI sequenceBuilder.SourceAspireCliEnvironment(counter); - // Update the ACA package to the dev version so the dev naming code is used - output.WriteLine("Step 11b: Updating package to dev version..."); - sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") - .Enter() - .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + // Run aspire update to upgrade the #:package directives in apphost.cs + // from the GA version to the dev build version. This ensures the actual + // deployment logic (naming, bicep generation) comes from the dev packages. + output.WriteLine("Step 11b: Updating project packages to dev version..."); + sequenceBuilder.Type("aspire update --channel local") .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); } else { @@ -216,24 +220,42 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell output.WriteLine($"Step 11: Upgrading to dev CLI from PR #{prNumber}..."); sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); sequenceBuilder.SourceAspireCliEnvironment(counter); + + // Update project packages to the PR version + output.WriteLine("Step 11b: Updating project packages to dev version..."); + sequenceBuilder.Type($"aspire update --channel pr-{prNumber}") + .Enter() + .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); } else { output.WriteLine("Step 11: No PR number available, using current CLI as 'dev'..."); + // Still run aspire update to pick up whatever local packages are available + sequenceBuilder.Type("aspire update") + .Enter() + .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); } } - // Step 12: Log the dev CLI version - output.WriteLine("Step 12: Logging dev CLI version..."); + // Step 12: Log the dev CLI version and verify packages were updated + output.WriteLine("Step 12: Logging dev CLI version and verifying package update..."); sequenceBuilder.Type("aspire --version") .Enter() .WaitForSuccessPrompt(counter); - // Step 13: Redeploy with dev CLI — same apphost, NO compact naming - // This proves the default naming didn't change on upgrade - output.WriteLine("Step 13: Redeploying with dev CLI (same apphost, no compact naming)..."); + // Verify the #:package directives in apphost.cs were updated from GA version + sequenceBuilder.Type("grep '#:package\\|#:sdk' apphost.cs") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 13: Redeploy with dev packages — same apphost, NO compact naming + // The dev packages contain our changes but default naming is unchanged, + // so this should reuse the same resources created by the GA deploy. + output.WriteLine("Step 13: Redeploying with dev packages (no compact naming)..."); sequenceBuilder - .Type("aspire deploy") + .Type("aspire deploy --clear-cache") .Enter() .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); From 21a93de5225e646e49287fdd13679bb97b6d3dc9 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 12 Feb 2026 09:59:24 +1100 Subject: [PATCH 06/10] Fix upgrade test: handle 'Perform updates?' confirmation prompt aspire update shows a y/n confirmation before applying package updates. The test was waiting for 'Update successful' but the command was stuck at the confirmation prompt. --- .../AcaCompactNamingUpgradeDeploymentTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs index 265a59cee93..f7c4b0b133a 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -79,6 +79,9 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell var waitingForVersionSelectionPrompt = new CellPatternSearcher() .Find("(based on NuGet.config)"); + var waitingForUpdateConfirmation = new CellPatternSearcher() + .Find("Perform updates?"); + var waitingForUpdateSuccessful = new CellPatternSearcher() .Find("Update successful"); @@ -207,6 +210,8 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell // deployment logic (naming, bicep generation) comes from the dev packages. output.WriteLine("Step 11b: Updating project packages to dev version..."); sequenceBuilder.Type("aspire update --channel local") + .Enter() + .WaitUntil(s => waitingForUpdateConfirmation.Search(s).Count > 0, TimeSpan.FromMinutes(2)) .Enter() .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); @@ -224,6 +229,8 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell // Update project packages to the PR version output.WriteLine("Step 11b: Updating project packages to dev version..."); sequenceBuilder.Type($"aspire update --channel pr-{prNumber}") + .Enter() + .WaitUntil(s => waitingForUpdateConfirmation.Search(s).Count > 0, TimeSpan.FromMinutes(2)) .Enter() .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); @@ -233,6 +240,8 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell output.WriteLine("Step 11: No PR number available, using current CLI as 'dev'..."); // Still run aspire update to pick up whatever local packages are available sequenceBuilder.Type("aspire update") + .Enter() + .WaitUntil(s => waitingForUpdateConfirmation.Search(s).Count > 0, TimeSpan.FromMinutes(2)) .Enter() .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); From 6e8dec8fdd1b08aea50d9e5676223a2d1031a04c Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 12 Feb 2026 10:30:59 +1100 Subject: [PATCH 07/10] Fix upgrade test: handle NuGet.config directory prompt from aspire update aspire update shows two prompts when switching channels: 1. 'Perform updates? [y/n]' - package confirmation 2. 'Which directory for NuGet.config file?' - NuGet config placement Both need Enter to accept defaults. --- .../AcaCompactNamingUpgradeDeploymentTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs index f7c4b0b133a..44fc5506103 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -82,6 +82,9 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell var waitingForUpdateConfirmation = new CellPatternSearcher() .Find("Perform updates?"); + var waitingForNugetConfigPrompt = new CellPatternSearcher() + .Find("NuGet.config file?"); + var waitingForUpdateSuccessful = new CellPatternSearcher() .Find("Update successful"); @@ -213,6 +216,8 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell .Enter() .WaitUntil(s => waitingForUpdateConfirmation.Search(s).Count > 0, TimeSpan.FromMinutes(2)) .Enter() + .WaitUntil(s => waitingForNugetConfigPrompt.Search(s).Count > 0 || waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); } @@ -232,6 +237,8 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell .Enter() .WaitUntil(s => waitingForUpdateConfirmation.Search(s).Count > 0, TimeSpan.FromMinutes(2)) .Enter() + .WaitUntil(s => waitingForNugetConfigPrompt.Search(s).Count > 0 || waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); } @@ -243,6 +250,8 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell .Enter() .WaitUntil(s => waitingForUpdateConfirmation.Search(s).Count > 0, TimeSpan.FromMinutes(2)) .Enter() + .WaitUntil(s => waitingForNugetConfigPrompt.Search(s).Count > 0 || waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); } From 1451d0dd11e6af1844567924659bb00927fd1dc3 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 12 Feb 2026 11:09:32 +1100 Subject: [PATCH 08/10] Fix upgrade test: use timed Enter presses for aspire update prompts aspire update has multiple sequential prompts (confirm, NuGet.config dir, NuGet.config apply, potential CLI self-update). Use Wait+Enter pattern to accept all defaults without needing to track each prompt individually. --- .../AcaCompactNamingUpgradeDeploymentTests.cs | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs index 44fc5506103..f7404de7510 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -79,12 +79,6 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell var waitingForVersionSelectionPrompt = new CellPatternSearcher() .Find("(based on NuGet.config)"); - var waitingForUpdateConfirmation = new CellPatternSearcher() - .Find("Perform updates?"); - - var waitingForNugetConfigPrompt = new CellPatternSearcher() - .Find("NuGet.config file?"); - var waitingForUpdateSuccessful = new CellPatternSearcher() .Find("Update successful"); @@ -211,13 +205,15 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell // Run aspire update to upgrade the #:package directives in apphost.cs // from the GA version to the dev build version. This ensures the actual // deployment logic (naming, bicep generation) comes from the dev packages. + // aspire update shows multiple prompts (confirm updates, NuGet.config dir, + // apply NuGet.config changes, CLI self-update) — accept all defaults. output.WriteLine("Step 11b: Updating project packages to dev version..."); sequenceBuilder.Type("aspire update --channel local") .Enter() - .WaitUntil(s => waitingForUpdateConfirmation.Search(s).Count > 0, TimeSpan.FromMinutes(2)) - .Enter() - .WaitUntil(s => waitingForNugetConfigPrompt.Search(s).Count > 0 || waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2)) - .Enter() + .Wait(TimeSpan.FromSeconds(5)).Enter() // "Perform updates? [y/n]" + .Wait(TimeSpan.FromSeconds(5)).Enter() // "Which directory for NuGet.config?" + .Wait(TimeSpan.FromSeconds(5)).Enter() // "Apply these changes to NuGet.config?" + .Wait(TimeSpan.FromSeconds(5)).Enter() // CLI self-update prompt (if any) .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); } @@ -235,10 +231,10 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell output.WriteLine("Step 11b: Updating project packages to dev version..."); sequenceBuilder.Type($"aspire update --channel pr-{prNumber}") .Enter() - .WaitUntil(s => waitingForUpdateConfirmation.Search(s).Count > 0, TimeSpan.FromMinutes(2)) - .Enter() - .WaitUntil(s => waitingForNugetConfigPrompt.Search(s).Count > 0 || waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2)) - .Enter() + .Wait(TimeSpan.FromSeconds(5)).Enter() + .Wait(TimeSpan.FromSeconds(5)).Enter() + .Wait(TimeSpan.FromSeconds(5)).Enter() + .Wait(TimeSpan.FromSeconds(5)).Enter() .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); } @@ -248,10 +244,10 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell // Still run aspire update to pick up whatever local packages are available sequenceBuilder.Type("aspire update") .Enter() - .WaitUntil(s => waitingForUpdateConfirmation.Search(s).Count > 0, TimeSpan.FromMinutes(2)) - .Enter() - .WaitUntil(s => waitingForNugetConfigPrompt.Search(s).Count > 0 || waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2)) - .Enter() + .Wait(TimeSpan.FromSeconds(5)).Enter() + .Wait(TimeSpan.FromSeconds(5)).Enter() + .Wait(TimeSpan.FromSeconds(5)).Enter() + .Wait(TimeSpan.FromSeconds(5)).Enter() .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); } From ec43de238770abb26f0561fbf67777d14eb41208 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 12 Feb 2026 11:46:24 +1100 Subject: [PATCH 09/10] Fix upgrade test: use explicit WaitUntil for each aspire update prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timed Enter presses were unreliable — if prompts appeared at different speeds, extra Enters would leak to the shell and corrupt subsequent commands. Now explicitly waits for each of the 3 prompts: 1. 'Perform updates? [y/n]' 2. 'Which directory for NuGet.config file?' 3. 'Apply these changes to NuGet.config? [y/n]' --- .../AcaCompactNamingUpgradeDeploymentTests.cs | 50 +++++++++++-------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs index f7404de7510..0902fd50e94 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -82,6 +82,11 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell var waitingForUpdateSuccessful = new CellPatternSearcher() .Find("Update successful"); + // aspire update prompts (used in Phase 2) + var waitingForPerformUpdates = new CellPatternSearcher().Find("Perform updates?"); + var waitingForNugetConfigDir = new CellPatternSearcher().Find("NuGet.config file?"); + var waitingForApplyNugetConfig = new CellPatternSearcher().Find("Apply these changes"); + var waitingForPipelineSucceeded = new CellPatternSearcher() .Find("PIPELINE SUCCEEDED"); @@ -205,17 +210,18 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell // Run aspire update to upgrade the #:package directives in apphost.cs // from the GA version to the dev build version. This ensures the actual // deployment logic (naming, bicep generation) comes from the dev packages. - // aspire update shows multiple prompts (confirm updates, NuGet.config dir, - // apply NuGet.config changes, CLI self-update) — accept all defaults. + // aspire update shows 3 interactive prompts — handle each explicitly. output.WriteLine("Step 11b: Updating project packages to dev version..."); sequenceBuilder.Type("aspire update --channel local") .Enter() - .Wait(TimeSpan.FromSeconds(5)).Enter() // "Perform updates? [y/n]" - .Wait(TimeSpan.FromSeconds(5)).Enter() // "Which directory for NuGet.config?" - .Wait(TimeSpan.FromSeconds(5)).Enter() // "Apply these changes to NuGet.config?" - .Wait(TimeSpan.FromSeconds(5)).Enter() // CLI self-update prompt (if any) - .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) - .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + .WaitUntil(s => waitingForPerformUpdates.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForNugetConfigDir.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForApplyNugetConfig.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); } else { @@ -231,12 +237,14 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell output.WriteLine("Step 11b: Updating project packages to dev version..."); sequenceBuilder.Type($"aspire update --channel pr-{prNumber}") .Enter() - .Wait(TimeSpan.FromSeconds(5)).Enter() - .Wait(TimeSpan.FromSeconds(5)).Enter() - .Wait(TimeSpan.FromSeconds(5)).Enter() - .Wait(TimeSpan.FromSeconds(5)).Enter() - .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) - .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + .WaitUntil(s => waitingForPerformUpdates.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForNugetConfigDir.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForApplyNugetConfig.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); } else { @@ -244,12 +252,14 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell // Still run aspire update to pick up whatever local packages are available sequenceBuilder.Type("aspire update") .Enter() - .Wait(TimeSpan.FromSeconds(5)).Enter() - .Wait(TimeSpan.FromSeconds(5)).Enter() - .Wait(TimeSpan.FromSeconds(5)).Enter() - .Wait(TimeSpan.FromSeconds(5)).Enter() - .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(3)) - .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + .WaitUntil(s => waitingForPerformUpdates.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForNugetConfigDir.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForApplyNugetConfig.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); } } From 471f184ef30d0a9cc901325811d5f82998709c53 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 12 Feb 2026 17:52:30 +1100 Subject: [PATCH 10/10] Increase deploy WaitForSuccessPrompt timeout from 2 to 5 minutes --- .../AcaCompactNamingUpgradeDeploymentTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs index 0902fd50e94..c0f39d3175c 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -174,7 +174,7 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell .Type("aspire deploy --clear-cache") .Enter() .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) - .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); // Step 10: Record the storage account count after first deploy output.WriteLine("Step 10: Recording storage account count after GA deploy..."); @@ -282,7 +282,7 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell .Type("aspire deploy --clear-cache") .Enter() .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) - .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); // Step 14: Verify no duplicate storage accounts output.WriteLine("Step 14: Verifying no duplicate storage accounts...");