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