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..92710731082 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,26 @@ public static IResourceBuilder AddAzureCon storageVolume.Name = BicepFunction.Interpolate($"vol{resourceToken}"); } } + else if (appEnvResource.UseCompactResourceNaming) + { + Debug.Assert(resourceToken is not null); + + if (storageVolume is not null) + { + // 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 +414,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..c0f39d3175c --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -0,0 +1,363 @@ +// 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 waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + 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"); + + 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: 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 + 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() + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .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(5)); + + // 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: Restoring dev CLI from backup..."); + // Restore the dev CLI and hive that we backed up before GA install + sequenceBuilder + .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); + + // 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 3 interactive prompts — handle each explicitly. + output.WriteLine("Step 11b: Updating project packages to dev version..."); + sequenceBuilder.Type("aspire update --channel local") + .Enter() + .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 + { + // 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); + + // 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 => 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 + { + 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 => 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)); + } + } + + // 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); + + // 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 --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // 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..e9a0bb6c563 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -1326,6 +1326,63 @@ 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 = await GetManifestWithBicep(environment); + + await Verify(manifest.BicepText, "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 = await GetManifestWithBicep(environment); + + await Verify(manifest.BicepText, "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.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