diff --git a/src/Aspire.Hosting.Azure/AzureBicepResource.cs b/src/Aspire.Hosting.Azure/AzureBicepResource.cs index 079c9224576..29a1404f75f 100644 --- a/src/Aspire.Hosting.Azure/AzureBicepResource.cs +++ b/src/Aspire.Hosting.Azure/AzureBicepResource.cs @@ -152,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"); @@ -181,10 +178,14 @@ public virtual BicepTemplateFile GetBicepTemplateFile(string? directory = null, using var fs = File.OpenWrite(path); resourceStream.CopyTo(fs); } + + return new(path, isTempFile && deleteTemporaryFileOnDispose); } - var targetPath = directory is not null ? Path.Combine(directory, path) : path; - return new(targetPath, 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/AzureBicepResourceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs index d746f8799f4..940fcb84e18 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs @@ -246,4 +246,76 @@ public async Task BicepResourceHasPipelineStepAnnotationWithCorrectConfiguration // Assert - Step depends on CreateProvisioningContext Assert.Contains(AzureEnvironmentResource.CreateProvisioningContextStepName, step.DependsOnSteps); } + + [Fact] + public void GetBicepTemplateFile_WithTemplateFile_ReturnsOriginalPathWhenDirectoryProvided() + { + // 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 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); + + // Create a different directory to pass to GetBicepTemplateFile + var outputDir = Path.Combine(tempDir.Path, "output"); + Directory.CreateDirectory(outputDir); + + // Get the bicep template file with a directory parameter + using var templateFile = resource.GetBicepTemplateFile(outputDir); + + // 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}"); + } + + [Fact] + public void GetBicepTemplateFile_WithTemplateFile_ReturnsOriginalPathWithoutDirectory() + { + using var tempDir = new TestTempDirectory(); + + // 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 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); + + // Get the bicep template file with a directory parameter + using var templateFile = resource.GetBicepTemplateFile(outputDir); + + // 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();