diff --git a/playground/PostgresEndToEnd/PostgresEndToEnd.JavaService/dependency-reduced-pom.xml b/playground/PostgresEndToEnd/PostgresEndToEnd.JavaService/dependency-reduced-pom.xml
index ceb176d9460..75ebb7ec18a 100644
--- a/playground/PostgresEndToEnd/PostgresEndToEnd.JavaService/dependency-reduced-pom.xml
+++ b/playground/PostgresEndToEnd/PostgresEndToEnd.JavaService/dependency-reduced-pom.xml
@@ -43,6 +43,45 @@
+
+
+
+ org.eclipse.jetty
+ jetty-server
+ 9.4.57.v20241219
+
+
+ org.postgresql
+ postgresql
+ 42.7.2
+
+
+ io.netty
+ netty-codec-http2
+ 4.1.124.Final
+
+
+ com.nimbusds
+ nimbus-jose-jwt
+ 9.37.2
+
+
+ io.projectreactor.netty
+ reactor-netty-core
+ 1.0.39
+
+
+ io.projectreactor.netty
+ reactor-netty-http
+ 1.0.39
+
+
+ io.netty
+ netty-handler
+ 4.1.118.Final
+
+
+
17
17
diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs
index bb239d4ee48..3aff0911c2a 100644
--- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs
+++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs
@@ -147,8 +147,12 @@ private async ValueTask HandleListToolsAsync(RequestContext new Tool
@@ -193,8 +197,12 @@ private async ValueTask HandleCallToolAsync(RequestContext
/// The cancellation token.
- /// The refreshed resource tool map.
- Task> RefreshResourceToolMapAsync(CancellationToken cancellationToken);
+ /// A tuple containing the refreshed resource tool map and a flag indicating whether the tool set changed.
+ Task<(IReadOnlyDictionary ToolMap, bool Changed)> RefreshResourceToolMapAsync(CancellationToken cancellationToken);
///
/// Sends a tools list changed notification to connected MCP clients.
diff --git a/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs
index 95ecf656214..e3d3c368333 100644
--- a/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs
+++ b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs
@@ -71,7 +71,7 @@ public async Task SendToolsListChangedNotificationAsync(CancellationToken cancel
}
///
- public async Task> RefreshResourceToolMapAsync(CancellationToken cancellationToken)
+ public async Task<(IReadOnlyDictionary ToolMap, bool Changed)> RefreshResourceToolMapAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Refreshing resource tool map.");
@@ -95,10 +95,15 @@ public async Task> RefreshResourc
{
Debug.Assert(resource.McpServer is not null);
+ // Use DisplayName (the app-model name, e.g. "db1-mcp") rather than Name
+ // (the DCP runtime ID, e.g. "db1-mcp-ypnvhwvw") because the AppHost resolves
+ // resources by their app-model name in CallResourceMcpToolAsync.
+ var routedResourceName = resource.DisplayName ?? resource.Name;
+
foreach (var tool in resource.McpServer.Tools)
{
- var exposedName = $"{resource.Name.Replace("-", "_")}_{tool.Name}";
- refreshedMap[exposedName] = new ResourceToolEntry(resource.Name, tool);
+ var exposedName = $"{routedResourceName.Replace("-", "_")}_{tool.Name}";
+ refreshedMap[exposedName] = new ResourceToolEntry(routedResourceName, tool);
_logger.LogDebug("{Tool}: {Description}", exposedName, tool.Description);
}
@@ -117,10 +122,38 @@ public async Task> RefreshResourc
lock (_lock)
{
+ var changed = _resourceToolMap.Count != refreshedMap.Count;
+ if (!changed)
+ {
+ // Check for deleted tools (in old but not in new).
+ foreach (var key in _resourceToolMap.Keys)
+ {
+ if (!refreshedMap.ContainsKey(key))
+ {
+ changed = true;
+ break;
+ }
+ }
+
+ // Check for new tools (in new but not in old).
+ if (!changed)
+ {
+ foreach (var key in refreshedMap.Keys)
+ {
+ if (!_resourceToolMap.ContainsKey(key))
+ {
+ changed = true;
+ break;
+ }
+ }
+ }
+ }
+
_resourceToolMap = refreshedMap;
_selectedAppHostPath = selectedAppHostPath;
_invalidated = false;
- return _resourceToolMap;
+ return (_resourceToolMap, changed);
}
}
+
}
diff --git a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs
index c8d3d5c4fef..e0a919c7333 100644
--- a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs
@@ -19,7 +19,7 @@ public override JsonElement GetInputSchema()
public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
- var resourceToolMap = await refreshService.RefreshResourceToolMapAsync(cancellationToken).ConfigureAwait(false);
+ var (resourceToolMap, _) = await refreshService.RefreshResourceToolMapAsync(cancellationToken).ConfigureAwait(false);
await refreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false);
var totalToolCount = KnownMcpTools.All.Count + resourceToolMap.Count;
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/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
index a52c3c2d56e..1e960005d69 100644
--- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
@@ -163,10 +163,13 @@ private async Task BuildProjectImage(PipelineStepContext ctx)
// Add COPY --from: statements for each source
stage.AddContainerFiles(this, containerWorkingDir, logger);
- // Get the directory service to create temp Dockerfile
var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!;
+
+ // Create a unique temporary Dockerfile path for this resource using the directory service.
+ // Passing a file name causes CreateTempFile to create the file in a new, empty subdirectory,
+ // which avoids Docker/buildx scanning the entire temporary directory.
var directoryService = ctx.Services.GetRequiredService();
- var tempDockerfilePath = directoryService.TempDirectory.CreateTempFile().Path;
+ var tempDockerfilePath = directoryService.TempDirectory.CreateTempFile("Dockerfile").Path;
var builtSuccessfully = false;
try
diff --git a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs
index 4d44532128b..b1e75a75be2 100644
--- a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs
@@ -718,9 +718,11 @@ public static IResourceBuilder WithDockerfileFactory(this IResourceBuilder
var fullyQualifiedContextPath = Path.GetFullPath(contextPath, builder.ApplicationBuilder.AppHostDirectory)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
- // Create a unique temporary Dockerfile path for this resource using the directory service
+ // Create a unique temporary Dockerfile path for this resource using the directory service.
+ // Passing a file name causes CreateTempFile to create the file in a new, empty subdirectory,
+ // which avoids Docker/buildx scanning the entire temporary directory.
var directoryService = builder.ApplicationBuilder.FileSystemService;
- var tempDockerfilePath = directoryService.TempDirectory.CreateTempFile().Path;
+ var tempDockerfilePath = directoryService.TempDirectory.CreateTempFile("Dockerfile").Path;
var imageName = ImageNameGenerator.GenerateImageName(builder);
var imageTag = ImageNameGenerator.GenerateImageTag(builder);
diff --git a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs
index dd7cb3e3587..a4110121431 100644
--- a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs
+++ b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs
@@ -165,8 +165,8 @@ public async Task McpServer_ListTools_IncludesResourceMcpTools()
[
new ResourceSnapshot
{
- Name = "test-resource",
- DisplayName = "Test Resource",
+ Name = "test-resource-abcd1234",
+ DisplayName = "test-resource",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
@@ -202,8 +202,8 @@ public async Task McpServer_ListTools_IncludesResourceMcpTools()
// Assert - Verify resource tools are included
Assert.NotNull(tools);
- // The resource tools should be exposed with a prefixed name: {resource_name}_{tool_name}
- // Resource name "test-resource" becomes "test_resource" (dashes replaced with underscores)
+ // The resource tools should be exposed with a prefixed name using the DisplayName (app-model name):
+ // DisplayName "test-resource" becomes "test_resource" (dashes replaced with underscores)
var resourceToolOne = tools.FirstOrDefault(t => t.Name == "test_resource_resource_tool_one");
var resourceToolTwo = tools.FirstOrDefault(t => t.Name == "test_resource_resource_tool_two");
@@ -235,8 +235,8 @@ public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult()
[
new ResourceSnapshot
{
- Name = "my-resource",
- DisplayName = "My Resource",
+ Name = "my-resource-abcd1234",
+ DisplayName = "my-resource",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
@@ -291,6 +291,69 @@ public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult()
Assert.Equal("do_something", callToolName);
}
+ [Fact]
+ public async Task McpServer_CallTool_ResourceMcpTool_UsesDisplayNameForRouting()
+ {
+ // Arrange - Simulate resource snapshots that use a unique resource id and a logical display name.
+ var expectedToolResult = "List schemas completed";
+ string? callResourceName = null;
+ string? callToolName = null;
+
+ var mockBackchannel = new TestAppHostAuxiliaryBackchannel
+ {
+ Hash = "test-apphost-hash",
+ IsInScope = true,
+ AppHostInfo = new AppHostInformation
+ {
+ AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
+ ProcessId = 12345
+ },
+ ResourceSnapshots =
+ [
+ new ResourceSnapshot
+ {
+ Name = "db1-mcp-ypnvhwvw",
+ DisplayName = "db1-mcp",
+ ResourceType = "Container",
+ State = "Running",
+ McpServer = new ResourceSnapshotMcpServer
+ {
+ EndpointUrl = "http://localhost:8080/mcp",
+ Tools =
+ [
+ new Tool
+ {
+ Name = "list_schemas",
+ Description = "Lists database schemas"
+ }
+ ]
+ }
+ }
+ ],
+ CallResourceMcpToolHandler = (resourceName, toolName, arguments, ct) =>
+ {
+ callResourceName = resourceName;
+ callToolName = toolName;
+ return Task.FromResult(new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = expectedToolResult }]
+ });
+ }
+ };
+
+ _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel);
+ await _mcpClient.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: _cts.Token).DefaultTimeout();
+
+ // Act
+ var result = await _mcpClient.CallToolAsync("db1_mcp_list_schemas", cancellationToken: _cts.Token).DefaultTimeout();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}");
+ Assert.Equal("db1-mcp", callResourceName);
+ Assert.Equal("list_schemas", callToolName);
+ }
+
[Fact]
public async Task McpServer_CallTool_ListAppHosts_ReturnsResult()
{
@@ -347,6 +410,92 @@ public async Task McpServer_CallTool_RefreshTools_ReturnsResult()
Assert.Equal(NotificationMethods.ToolListChangedNotification, notification.Method);
}
+ [Fact]
+ public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification()
+ {
+ // Arrange - Create a mock backchannel with a resource that has MCP tools
+ // This simulates the db-mcp scenario where resource tools become available
+ var mockBackchannel = new TestAppHostAuxiliaryBackchannel
+ {
+ Hash = "test-apphost-hash",
+ IsInScope = true,
+ AppHostInfo = new AppHostInformation
+ {
+ AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
+ ProcessId = 12345
+ },
+ ResourceSnapshots =
+ [
+ new ResourceSnapshot
+ {
+ Name = "db-mcp-abcd1234",
+ DisplayName = "db-mcp",
+ ResourceType = "Container",
+ State = "Running",
+ McpServer = new ResourceSnapshotMcpServer
+ {
+ EndpointUrl = "http://localhost:8080/mcp",
+ Tools =
+ [
+ new Tool
+ {
+ Name = "query_database",
+ Description = "Query a database"
+ }
+ ]
+ }
+ }
+ ]
+ };
+
+ // Register the mock backchannel so resource tools will be discovered
+ _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel);
+
+ // Set up a channel to detect any tools/list_changed notifications
+ var notificationCount = 0;
+ await using var notificationHandler = _mcpClient.RegisterNotificationHandler(
+ NotificationMethods.ToolListChangedNotification,
+ (notification, cancellationToken) =>
+ {
+ Interlocked.Increment(ref notificationCount);
+ return default;
+ });
+
+ // Act - Call ListTools which should discover the resource tools via refresh
+ // but should NOT send a tools/list_changed notification (that would cause an infinite loop)
+ var tools = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout();
+
+ // Assert - tools should include the resource tool
+ Assert.NotNull(tools);
+ var dbMcpTool = tools.FirstOrDefault(t => t.Name == "db_mcp_query_database");
+ Assert.NotNull(dbMcpTool);
+
+ // Assert - no tools/list_changed notification should have been sent.
+ using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
+ var notificationChannel = Channel.CreateUnbounded();
+ await using var channelHandler = _mcpClient.RegisterNotificationHandler(
+ NotificationMethods.ToolListChangedNotification,
+ (notification, _) =>
+ {
+ notificationChannel.Writer.TryWrite(notification);
+ return default;
+ });
+
+ var received = false;
+ try
+ {
+ await notificationChannel.Reader.ReadAsync(timeoutCts.Token);
+ received = true;
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected — no notification arrived within the timeout
+ }
+
+ Assert.False(received, "tools/list_changed notification should not be sent during tools/list handling");
+ Assert.Equal(0, notificationCount);
+ }
+
[Fact]
public async Task McpServer_CallTool_UnknownTool_ReturnsError()
{
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