diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 301bbc03abe..436287be3fc 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -50,8 +50,6 @@ public class DockerComposeEnvironmentResource : Resource, IComputeEnvironmentRes internal Dictionary ResourceMapping { get; } = new(new ResourceNameComparer()); - internal EnvFile? SharedEnvFile { get; set; } - internal PortAllocator PortAllocator { get; } = new(); /// The name of the Docker Compose environment. @@ -327,11 +325,14 @@ private async Task PrepareAsync(PipelineStepContext context) { var envFilePath = GetEnvFilePath(context); - if (CapturedEnvironmentVariables.Count == 0 || SharedEnvFile is null) + if (CapturedEnvironmentVariables.Count == 0) { return; } + // Initialize a new EnvFile for this environment + var envFile = EnvFile.Create(envFilePath, context.Logger); + foreach (var entry in CapturedEnvironmentVariables) { var (key, (description, defaultValue, source)) = entry; @@ -346,10 +347,10 @@ private async Task PrepareAsync(PipelineStepContext context) defaultValue = imageName; } - SharedEnvFile.Add(key, defaultValue, description, onlyIfMissing: false); + envFile.Add(key, defaultValue, description, onlyIfMissing: false); } - SharedEnvFile.Save(envFilePath, includeValues: true); + envFile.Save(includeValues: true); } internal string AddEnvironmentVariable(string name, string? description = null, string? defaultValue = null, object? source = null) diff --git a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs index cc672a89f8a..c928cb00f93 100644 --- a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs +++ b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs @@ -153,7 +153,7 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod if (environment.CapturedEnvironmentVariables.Count > 0) { var envFilePath = Path.Combine(OutputPath, ".env"); - var envFile = environment.SharedEnvFile ?? EnvFile.Load(envFilePath); + var envFile = EnvFile.Load(envFilePath, logger); foreach (var entry in environment.CapturedEnvironmentVariables ?? []) { @@ -162,9 +162,7 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod envFile.Add(key, value: null, description, onlyIfMissing: true); } - environment.SharedEnvFile = envFile; - - envFile.Save(envFilePath, includeValues: false); + envFile.Save(includeValues: false); } await writeTask.SucceedAsync( diff --git a/src/Aspire.Hosting.Docker/EnvFile.cs b/src/Aspire.Hosting.Docker/EnvFile.cs index 23dcde95098..d1650ea7eeb 100644 --- a/src/Aspire.Hosting.Docker/EnvFile.cs +++ b/src/Aspire.Hosting.Docker/EnvFile.cs @@ -1,17 +1,32 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.Extensions.Logging; + namespace Aspire.Hosting.Docker; internal sealed record EnvEntry(string Key, string? Value, string? Comment); internal sealed class EnvFile { - private readonly SortedDictionary _entries = []; + private string? _path; + private readonly ILogger? _logger; + + internal SortedDictionary Entries { get; } = []; + + private EnvFile(ILogger? logger = null) + { + _logger = logger; + } + + public static EnvFile Create(string path, ILogger? logger = null) + { + return new EnvFile(logger) { _path = path }; + } - public static EnvFile Load(string path) + public static EnvFile Load(string path, ILogger? logger = null) { - var envFile = new EnvFile(); + var envFile = new EnvFile(logger) { _path = path }; if (!File.Exists(path)) { return envFile; @@ -29,7 +44,7 @@ public static EnvFile Load(string path) } else if (TryParseKeyValue(line, out var key, out var value)) { - envFile._entries[key] = new EnvEntry(key, value, currentComment); + envFile.Entries[key] = new EnvEntry(key, value, currentComment); currentComment = null; // Reset comment after associating it with a key } else @@ -43,12 +58,12 @@ public static EnvFile Load(string path) public void Add(string key, string? value, string? comment, bool onlyIfMissing = true) { - if (_entries.ContainsKey(key) && onlyIfMissing) + if (Entries.ContainsKey(key) && onlyIfMissing) { return; } - _entries[key] = new EnvEntry(key, value, comment); + Entries[key] = new EnvEntry(key, value, comment); } private static bool TryParseKeyValue(string line, out string key, out string? value) @@ -69,11 +84,22 @@ private static bool TryParseKeyValue(string line, out string key, out string? va return false; } - public void Save(string path) + public void Save() { + if (_path is null) + { + throw new InvalidOperationException("Cannot save EnvFile without a path. Use Load() to create an EnvFile with a path."); + } + + // Log if we're about to overwrite an existing file + if (File.Exists(_path)) + { + _logger?.LogInformation("Environment file '{EnvFilePath}' already exists and will be overwritten", _path); + } + var lines = new List(); - foreach (var entry in _entries.Values) + foreach (var entry in Entries.Values) { if (!string.IsNullOrWhiteSpace(entry.Comment)) { @@ -83,35 +109,51 @@ public void Save(string path) lines.Add(string.Empty); } - File.WriteAllLines(path, lines); + File.WriteAllLines(_path, lines); } - public void Save(string path, bool includeValues) + public void Save(bool includeValues) { if (includeValues) { - Save(path); + Save(); } else { - SaveKeysOnly(path); + SaveKeysOnly(); } } - private void SaveKeysOnly(string path) + private void SaveKeysOnly() { + if (_path is null) + { + throw new InvalidOperationException("Cannot save EnvFile without a path. Use Load() to create an EnvFile with a path."); + } + var lines = new List(); - foreach (var entry in _entries.Values) + foreach (var entry in Entries.Values) { if (!string.IsNullOrWhiteSpace(entry.Comment)) { lines.Add($"# {entry.Comment}"); } - lines.Add($"{entry.Key}="); + + // If the entry already has a non-empty value (loaded from disk), preserve it + // This ensures user-modified values are not overwritten when we save keys only + if (!string.IsNullOrEmpty(entry.Value)) + { + lines.Add($"{entry.Key}={entry.Value}"); + } + else + { + lines.Add($"{entry.Key}="); + } + lines.Add(string.Empty); } - File.WriteAllLines(path, lines); + File.WriteAllLines(_path, lines); } } diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index a834f8425c6..a07aa9709ba 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -588,6 +588,69 @@ await Verify(envFileContent, "env") .UseParameters("various-parameters"); } + [Fact] + public void PrepareStep_OverwritesExistingEnvFileAndLogsWarning() + { + using var tempDir = new TempDirectory(); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "prepare-docker-compose"); + builder.Services.AddSingleton(); + builder.WithTestAndResourceLogging(outputHelper); + + var environment = builder.AddDockerComposeEnvironment("docker-compose"); + + var param1 = builder.AddParameter("param1", "defaultValue1"); + + builder.AddContainer("testapp", "testimage") + .WithEnvironment("PARAM1", param1); + + // Pre-create the env file to simulate it already existing + var envFilePath = Path.Combine(tempDir.Path, ".env.Production"); + File.WriteAllText(envFilePath, "# Old content\nOLD_KEY=old_value\n"); + + var app = builder.Build(); + app.Run(); + + // Verify the file was overwritten with new content + var envFileContent = File.ReadAllText(envFilePath); + Assert.Contains("PARAM1", envFileContent); + Assert.DoesNotContain("OLD_KEY", envFileContent); + + // The log message should be captured by the test output helper + // We can verify it was called by checking the test output + // The xunit logger will output to outputHelper + } + + [Fact] + public void PrepareStep_OverwritesExistingEnvFileWithCustomEnvironmentName() + { + using var tempDir = new TempDirectory(); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "prepare-docker-compose"); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(new TestHostEnvironment("Staging")); + builder.WithTestAndResourceLogging(outputHelper); + + var environment = builder.AddDockerComposeEnvironment("docker-compose"); + + var param1 = builder.AddParameter("param1", "stagingValue"); + + builder.AddContainer("testapp", "testimage") + .WithEnvironment("PARAM1", param1); + + // Pre-create the env file with custom environment name + var envFilePath = Path.Combine(tempDir.Path, ".env.Staging"); + File.WriteAllText(envFilePath, "# Old staging content\nOLD_STAGING_KEY=old_staging_value\n"); + + var app = builder.Build(); + app.Run(); + + // Verify the file was overwritten with new content + var envFileContent = File.ReadAllText(envFilePath); + Assert.Contains("PARAM1", envFileContent); + Assert.DoesNotContain("OLD_STAGING_KEY", envFileContent); + } + private sealed class MockImageBuilder : IResourceContainerImageBuilder { public bool BuildImageCalled { get; private set; } diff --git a/tests/Aspire.Hosting.Docker.Tests/EnvFileTests.cs b/tests/Aspire.Hosting.Docker.Tests/EnvFileTests.cs index 58d23741b61..6a99431b463 100644 --- a/tests/Aspire.Hosting.Docker.Tests/EnvFileTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/EnvFileTests.cs @@ -21,7 +21,7 @@ public void Add_WithOnlyIfMissingTrue_DoesNotAddDuplicate() // Load and try to add the same key with onlyIfMissing=true var envFile = EnvFile.Load(envFilePath); envFile.Add("KEY1", "value2", "New comment", onlyIfMissing: true); - envFile.Save(envFilePath); + envFile.Save(); var lines = File.ReadAllLines(envFilePath); var keyLines = lines.Where(l => l.StartsWith("KEY1=")).ToArray(); @@ -47,7 +47,7 @@ public void Add_WithOnlyIfMissingFalse_UpdatesExistingKey() // Load and try to add the same key with onlyIfMissing=false var envFile = EnvFile.Load(envFilePath); envFile.Add("KEY1", "value2", "New comment", onlyIfMissing: false); - envFile.Save(envFilePath); + envFile.Save(); var lines = File.ReadAllLines(envFilePath); var keyLines = lines.Where(l => l.StartsWith("KEY1=")).ToArray(); @@ -82,7 +82,7 @@ public void Add_WithOnlyIfMissingFalse_UpdatesImageNameWithoutDuplication() // Add IMAGE with onlyIfMissing=false (should update the existing value) envFile.Add("PROJECT1_IMAGE", "project1:1.0.0", "Container image name for project1", onlyIfMissing: false); - envFile.Save(envFilePath); + envFile.Save(); var lines = File.ReadAllLines(envFilePath); var imageLines = lines.Where(l => l.StartsWith("PROJECT1_IMAGE=")).ToArray(); @@ -113,7 +113,7 @@ public void Add_NewKey_AddsToFile() // Load and add a new key var envFile = EnvFile.Load(envFilePath); envFile.Add("KEY2", "value2", "Comment for KEY2", onlyIfMissing: true); - envFile.Save(envFilePath); + envFile.Save(); var lines = File.ReadAllLines(envFilePath); @@ -133,7 +133,7 @@ public void Load_EmptyFile_ReturnsEmptyEnvFile() var envFile = EnvFile.Load(envFilePath); envFile.Add("KEY1", "value1", "Comment"); - envFile.Save(envFilePath); + envFile.Save(); var lines = File.ReadAllLines(envFilePath); Assert.Contains("KEY1=value1", lines); @@ -148,7 +148,7 @@ public void Load_NonExistentFile_ReturnsEmptyEnvFile() // Don't create the file var envFile = EnvFile.Load(envFilePath); envFile.Add("KEY1", "value1", "Comment"); - envFile.Save(envFilePath); + envFile.Save(); Assert.True(File.Exists(envFilePath)); var lines = File.ReadAllLines(envFilePath); diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppendsNewKeysToEnvFileOnPublish#01.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppendsNewKeysToEnvFileOnPublish#01.verified.env index 39ea2dfd67f..c2fbff884af 100644 --- a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppendsNewKeysToEnvFileOnPublish#01.verified.env +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppendsNewKeysToEnvFileOnPublish#01.verified.env @@ -1,5 +1,5 @@ # Parameter param1 -PARAM1= +PARAM1=changed # Parameter param2 PARAM2= diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeDoesNotOverwriteEnvFileOnPublish#01.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeDoesNotOverwriteEnvFileOnPublish#01.verified.env index 17417ffb17d..c9a8d7c3a31 100644 --- a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeDoesNotOverwriteEnvFileOnPublish#01.verified.env +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeDoesNotOverwriteEnvFileOnPublish#01.verified.env @@ -1,3 +1,3 @@ # Parameter param1 -PARAM1= +PARAM1=changed