From b3ee350acec86d8c019176cd650edc986811a68d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:08:48 +0000 Subject: [PATCH 1/3] Initial plan From 3801b06db80adf8e4bab948c7e4333b3171e7984 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:20:49 +0000 Subject: [PATCH 2/3] Fix AzureBicepResource template file handling and add test - Convert relative template file paths to absolute in constructor - Copy template files to output directory when directory parameter is provided - Add test to verify bicep resources with template files can be published Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../AzureBicepResource.cs | 20 ++++++- .../Aspire.Hosting.Azure.Tests.csproj | 6 ++ .../AzureBicepResourceTests.cs | 60 +++++++++++++++++++ .../TestBicepTemplates/test-template.bicep | 16 +++++ 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/Aspire.Hosting.Azure.Tests/TestBicepTemplates/test-template.bicep diff --git a/src/Aspire.Hosting.Azure/AzureBicepResource.cs b/src/Aspire.Hosting.Azure/AzureBicepResource.cs index 079c9224576..effd57c16c8 100644 --- a/src/Aspire.Hosting.Azure/AzureBicepResource.cs +++ b/src/Aspire.Hosting.Azure/AzureBicepResource.cs @@ -34,6 +34,13 @@ public class AzureBicepResource : Resource, IAzureResource, IResourceWithParamet /// The name of an embedded resource that represents the bicep file. public AzureBicepResource(string name, string? templateFile = null, string? templateString = null, string? templateResourceName = null) : base(name) { + // Convert relative template file paths to absolute paths to ensure they remain valid + // regardless of changes to the current working directory + if (templateFile is not null && !Path.IsPathRooted(templateFile)) + { + templateFile = Path.GetFullPath(templateFile); + } + TemplateFile = templateFile; TemplateString = templateString; TemplateResourceName = templateResourceName; @@ -182,9 +189,18 @@ public virtual BicepTemplateFile GetBicepTemplateFile(string? directory = null, resourceStream.CopyTo(fs); } } + else if (directory is not null) + { + // When a template file is provided and a directory is specified, + // copy the file to the directory instead of just combining paths + var fileName = Path.GetFileName(path); + var targetPath = Path.Combine(directory, fileName); + File.Copy(path, targetPath, overwrite: true); + return new(targetPath, isTempFile && deleteTemporaryFileOnDispose); + } - var targetPath = directory is not null ? Path.Combine(directory, path) : path; - return new(targetPath, isTempFile && deleteTemporaryFileOnDispose); + var finalPath = directory is not null ? Path.Combine(directory, path) : path; + return new(finalPath, isTempFile && deleteTemporaryFileOnDispose); } /// diff --git a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj index a8a46d0e2b5..d70550575f0 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj +++ b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj @@ -59,4 +59,10 @@ + + + PreserveNewest + + + diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs index d746f8799f4..914fbdb2fb8 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs @@ -246,4 +246,64 @@ public async Task BicepResourceHasPipelineStepAnnotationWithCorrectConfiguration // Assert - Step depends on CreateProvisioningContext Assert.Contains(AzureEnvironmentResource.CreateProvisioningContextStepName, step.DependsOnSteps); } + + [Fact] + public async Task AzureBicepResourceWithTemplateFile_CanBePublished() + { + // Arrange + using var tempDir = new TestTempDirectory(); + + // Create a bicep file (simulating a file in the AppHost project) + var bicepFileName = "test-template.bicep"; + var bicepFilePath = Path.Combine(tempDir.Path, bicepFileName); + + var bicepContent = """ +param location string = resourceGroup().location +param testParameter string + +resource testResource 'Microsoft.Storage/storageAccounts@2021-09-01' = { + name: 'teststorage${uniqueString(resourceGroup().id)}' + location: location + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + tags: { + testParam: testParameter + } +} + +output storageEndpoint string = testResource.properties.primaryEndpoints.blob +"""; + await File.WriteAllTextAsync(bicepFilePath, bicepContent); + + var outputDir = Path.Combine(tempDir.Path, "output"); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir); + + // Add Azure Container App Environment (required for publishing) + builder.AddAzureContainerAppEnvironment("acaEnv"); + + // Add a bicep resource with a template file (using absolute path for test, but in real scenario this would be relative) + var bicepResource = builder.AddBicepTemplate("myresource", bicepFilePath) + .WithParameter("testParameter", "test-value"); + + // Act + using var app = builder.Build(); + app.Run(); + + // Assert - Verify the bicep files were created + var mainBicepPath = Path.Combine(outputDir, "main.bicep"); + Assert.True(File.Exists(mainBicepPath), "main.bicep should be generated"); + + var resourceBicepPath = Path.Combine(outputDir, "myresource", "myresource.bicep"); + Assert.True(File.Exists(resourceBicepPath), "myresource/myresource.bicep should be generated"); + + // Verify the content of the copied file matches the original + var copiedContent = await File.ReadAllTextAsync(resourceBicepPath); + Assert.Equal(bicepContent, copiedContent); + + // Verify the main.bicep references the resource + var mainBicepContent = await File.ReadAllTextAsync(mainBicepPath); + Assert.Contains("module myresource 'myresource/myresource.bicep'", mainBicepContent); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/TestBicepTemplates/test-template.bicep b/tests/Aspire.Hosting.Azure.Tests/TestBicepTemplates/test-template.bicep new file mode 100644 index 00000000000..ddc7666ab13 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/TestBicepTemplates/test-template.bicep @@ -0,0 +1,16 @@ +param location string = resourceGroup().location +param testParameter string + +resource testResource 'Microsoft.Storage/storageAccounts@2021-09-01' = { + name: 'teststorage${uniqueString(resourceGroup().id)}' + location: location + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + tags: { + testParam: testParameter + } +} + +output storageEndpoint string = testResource.properties.primaryEndpoints.blob From eaa877cad7bbe19ff36a0cdc62db10a73ca6a390 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 16 Jan 2026 18:08:26 -0600 Subject: [PATCH 3/3] Fix AzureBicepResource template file handling - Don't combine paths if TemplateFile is specified - Add test to verify bicep resources with template files can be published Fix #13967 --- .../AzureBicepResource.cs | 33 ++---- .../Aspire.Hosting.Azure.Tests.csproj | 6 -- .../AzureBicepResourceTests.cs | 100 ++++++++++-------- .../AzureEnvironmentResourceTests.cs | 70 ++++++++++++ .../TestBicepTemplates/test-template.bicep | 16 --- 5 files changed, 135 insertions(+), 90 deletions(-) delete mode 100644 tests/Aspire.Hosting.Azure.Tests/TestBicepTemplates/test-template.bicep diff --git a/src/Aspire.Hosting.Azure/AzureBicepResource.cs b/src/Aspire.Hosting.Azure/AzureBicepResource.cs index effd57c16c8..29a1404f75f 100644 --- a/src/Aspire.Hosting.Azure/AzureBicepResource.cs +++ b/src/Aspire.Hosting.Azure/AzureBicepResource.cs @@ -34,13 +34,6 @@ public class AzureBicepResource : Resource, IAzureResource, IResourceWithParamet /// The name of an embedded resource that represents the bicep file. public AzureBicepResource(string name, string? templateFile = null, string? templateString = null, string? templateResourceName = null) : base(name) { - // Convert relative template file paths to absolute paths to ensure they remain valid - // regardless of changes to the current working directory - if (templateFile is not null && !Path.IsPathRooted(templateFile)) - { - templateFile = Path.GetFullPath(templateFile); - } - TemplateFile = templateFile; TemplateString = templateString; TemplateResourceName = templateResourceName; @@ -159,14 +152,11 @@ public virtual BicepTemplateFile GetBicepTemplateFile(string? directory = null, throw new InvalidOperationException("Multiple template sources are specified."); } - var path = TemplateFile; - var isTempFile = false; - - if (path is null) + if (TemplateFile is null) { - isTempFile = directory is null; + var isTempFile = directory is null; - path = TempDirectory is null + var path = TempDirectory is null ? Path.Combine(directory ?? Directory.CreateTempSubdirectory("aspire").FullName, $"{Name.ToLowerInvariant()}.module.bicep") : Path.Combine(TempDirectory, $"{Name.ToLowerInvariant()}.module.bicep"); @@ -188,19 +178,14 @@ public virtual BicepTemplateFile GetBicepTemplateFile(string? directory = null, using var fs = File.OpenWrite(path); resourceStream.CopyTo(fs); } - } - else if (directory is not null) - { - // When a template file is provided and a directory is specified, - // copy the file to the directory instead of just combining paths - var fileName = Path.GetFileName(path); - var targetPath = Path.Combine(directory, fileName); - File.Copy(path, targetPath, overwrite: true); - return new(targetPath, isTempFile && deleteTemporaryFileOnDispose); + + return new(path, isTempFile && deleteTemporaryFileOnDispose); } - var finalPath = directory is not null ? Path.Combine(directory, path) : path; - return new(finalPath, isTempFile && deleteTemporaryFileOnDispose); + // When TemplateFile is specified, return the original path directly. + // The directory parameter is only for writing temporary files when the template + // is from a string or embedded resource, not for combining with an existing file path. + return new(TemplateFile, deleteFileOnDispose: false); } /// diff --git a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj index d70550575f0..a8a46d0e2b5 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj +++ b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj @@ -59,10 +59,4 @@ - - - PreserveNewest - - - diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs index 914fbdb2fb8..940fcb84e18 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs @@ -248,62 +248,74 @@ public async Task BicepResourceHasPipelineStepAnnotationWithCorrectConfiguration } [Fact] - public async Task AzureBicepResourceWithTemplateFile_CanBePublished() + public void GetBicepTemplateFile_WithTemplateFile_ReturnsOriginalPathWhenDirectoryProvided() { - // Arrange + // This test verifies the fix for https://github.com/dotnet/aspire/issues/13967 + // When a templateFile is specified, GetBicepTemplateFile should return the original path + // and not combine it with the directory parameter. + using var tempDir = new TestTempDirectory(); - - // Create a bicep file (simulating a file in the AppHost project) + + // Create a test bicep file var bicepFileName = "test-template.bicep"; var bicepFilePath = Path.Combine(tempDir.Path, bicepFileName); - - var bicepContent = """ -param location string = resourceGroup().location -param testParameter string - -resource testResource 'Microsoft.Storage/storageAccounts@2021-09-01' = { - name: 'teststorage${uniqueString(resourceGroup().id)}' - location: location - sku: { - name: 'Standard_LRS' - } - kind: 'StorageV2' - tags: { - testParam: testParameter - } -} + File.WriteAllText(bicepFilePath, "param location string = resourceGroup().location"); -output storageEndpoint string = testResource.properties.primaryEndpoints.blob -"""; - await File.WriteAllTextAsync(bicepFilePath, bicepContent); - + // Create the AzureBicepResource with the templateFile + var resource = new AzureBicepResource("test-resource", templateFile: bicepFilePath); + + // Create a different directory to pass to GetBicepTemplateFile var outputDir = Path.Combine(tempDir.Path, "output"); - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir); + Directory.CreateDirectory(outputDir); - // Add Azure Container App Environment (required for publishing) - builder.AddAzureContainerAppEnvironment("acaEnv"); + // Get the bicep template file with a directory parameter + using var templateFile = resource.GetBicepTemplateFile(outputDir); - // Add a bicep resource with a template file (using absolute path for test, but in real scenario this would be relative) - var bicepResource = builder.AddBicepTemplate("myresource", bicepFilePath) - .WithParameter("testParameter", "test-value"); + // The path should be the original template file path, not combined with outputDir + Assert.Equal(bicepFilePath, templateFile.Path); + Assert.True(File.Exists(templateFile.Path), $"The template file should exist at {templateFile.Path}"); + } - // Act - using var app = builder.Build(); - app.Run(); + [Fact] + public void GetBicepTemplateFile_WithTemplateFile_ReturnsOriginalPathWithoutDirectory() + { + using var tempDir = new TestTempDirectory(); - // Assert - Verify the bicep files were created - var mainBicepPath = Path.Combine(outputDir, "main.bicep"); - Assert.True(File.Exists(mainBicepPath), "main.bicep should be generated"); + // Create a test bicep file + var bicepFileName = "test-template.bicep"; + var bicepFilePath = Path.Combine(tempDir.Path, bicepFileName); + File.WriteAllText(bicepFilePath, "param location string = resourceGroup().location"); + + // Create the AzureBicepResource with the templateFile + var resource = new AzureBicepResource("test-resource", templateFile: bicepFilePath); + + // Get the bicep template file without a directory parameter + using var templateFile = resource.GetBicepTemplateFile(); + + // The path should be the original template file path + Assert.Equal(bicepFilePath, templateFile.Path); + } + + [Fact] + public void GetBicepTemplateFile_WithTemplateString_WritesToDirectory() + { + using var tempDir = new TestTempDirectory(); - var resourceBicepPath = Path.Combine(outputDir, "myresource", "myresource.bicep"); - Assert.True(File.Exists(resourceBicepPath), "myresource/myresource.bicep should be generated"); + var bicepContent = "param location string = resourceGroup().location"; + + // Create the AzureBicepResource with a template string (not a file) + var resource = new AzureBicepResource("test-resource", templateString: bicepContent); + + // Create a directory to pass to GetBicepTemplateFile + var outputDir = Path.Combine(tempDir.Path, "output"); + Directory.CreateDirectory(outputDir); - // Verify the content of the copied file matches the original - var copiedContent = await File.ReadAllTextAsync(resourceBicepPath); - Assert.Equal(bicepContent, copiedContent); + // Get the bicep template file with a directory parameter + using var templateFile = resource.GetBicepTemplateFile(outputDir); - // Verify the main.bicep references the resource - var mainBicepContent = await File.ReadAllTextAsync(mainBicepPath); - Assert.Contains("module myresource 'myresource/myresource.bicep'", mainBicepContent); + // The path should be in the output directory + Assert.StartsWith(outputDir, templateFile.Path); + Assert.True(File.Exists(templateFile.Path), $"The template file should exist at {templateFile.Path}"); + Assert.Equal(bicepContent, File.ReadAllText(templateFile.Path)); } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEnvironmentResourceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEnvironmentResourceTests.cs index 92ac2016391..4f764ed1cf2 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEnvironmentResourceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEnvironmentResourceTests.cs @@ -7,6 +7,7 @@ using Aspire.Hosting.Utils; using Azure.Provisioning; using Azure.Provisioning.Storage; +using Microsoft.DotNet.RemoteExecutor; namespace Aspire.Hosting.Azure.Tests; @@ -238,6 +239,75 @@ public async Task PublishAsync_WithDockerfileFactory_WritesDockerfileToOutputFol await Verify(actualContent); } + [Fact] + public void AzurePublishingContext_WithBicepTemplateFile_WorksWithRelativePath() + { + using var testTempDir = new TestTempDirectory(); + + var remoteInvokeOptions = new RemoteInvokeOptions(); + remoteInvokeOptions.StartInfo.WorkingDirectory = testTempDir.Path; + RemoteExecutor.Invoke(RunTest, testTempDir.Path, remoteInvokeOptions).Dispose(); + + static async Task RunTest(string tempDir) + { + // This test verifies the fix for https://github.com/dotnet/aspire/issues/13967 + // When using AzureBicepResource with a relative templateFile and AzurePublishingContext, + // the bicep file should be correctly copied to the output directory. + + // Create a source bicep file (simulating a user's custom bicep template) + var bicepFileName = "custom-resource.bicep"; + var bicepFilePath = Path.Combine(tempDir, bicepFileName); + var bicepContent = """ + param location string = resourceGroup().location + param customName string + + resource storageAccount 'Microsoft.Storage/storageAccounts@2021-02-01' = { + name: customName + location: location + kind: 'StorageV2' + sku: { + name: 'Standard_LRS' + } + } + + output endpoint string = storageAccount.properties.primaryEndpoints.blob + """; + await File.WriteAllTextAsync(bicepFilePath, bicepContent); + + // Create output directory for publishing + var outputDir = Path.Combine(tempDir, "output"); + Directory.CreateDirectory(outputDir); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: outputDir); + + // Add a container app environment (required for publishing) + builder.AddAzureContainerAppEnvironment("env"); + + // Add the custom AzureBicepResource with a relative template file path + var customResource = new AzureBicepResource("custom-resource", bicepFileName); + builder.AddResource(customResource) + .WithParameter("customName", "mystorageaccount"); + + var app = builder.Build(); + app.Run(); + + // Verify the bicep file was copied to the output directory + var mainBicepPath = Path.Combine(outputDir, "main.bicep"); + Assert.True(File.Exists(mainBicepPath), "main.bicep should be generated"); + + var resourceBicepPath = Path.Combine(outputDir, "custom-resource", "custom-resource.bicep"); + Assert.True(File.Exists(resourceBicepPath), "custom-resource/custom-resource.bicep should be generated"); + + // Verify the content of the copied file matches the original + var copiedContent = await File.ReadAllTextAsync(resourceBicepPath); + Assert.Equal(bicepContent, copiedContent); + + // Verify the main.bicep references the resource + var mainBicepContent = await File.ReadAllTextAsync(mainBicepPath); + Assert.Contains("module custom_resource 'custom-resource/custom-resource.bicep'", mainBicepContent); + } + } + private sealed class ExternalResourceWithParameters(string name) : Resource(name), IResourceWithParameters { public IDictionary Parameters { get; } = new Dictionary(); diff --git a/tests/Aspire.Hosting.Azure.Tests/TestBicepTemplates/test-template.bicep b/tests/Aspire.Hosting.Azure.Tests/TestBicepTemplates/test-template.bicep deleted file mode 100644 index ddc7666ab13..00000000000 --- a/tests/Aspire.Hosting.Azure.Tests/TestBicepTemplates/test-template.bicep +++ /dev/null @@ -1,16 +0,0 @@ -param location string = resourceGroup().location -param testParameter string - -resource testResource 'Microsoft.Storage/storageAccounts@2021-09-01' = { - name: 'teststorage${uniqueString(resourceGroup().id)}' - location: location - sku: { - name: 'Standard_LRS' - } - kind: 'StorageV2' - tags: { - testParam: testParameter - } -} - -output storageEndpoint string = testResource.properties.primaryEndpoints.blob