diff --git a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs index f3b36999f2f..330dc2fe8cb 100644 --- a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs +++ b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs @@ -111,28 +111,16 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod // Write a .env file with the environment variable names // that are used in the compose file - var envFile = Path.Combine(OutputPath, ".env"); - using var envWriter = new StreamWriter(envFile); + var envFilePath = Path.Combine(OutputPath, ".env"); + var envFile = EnvFile.Load(envFilePath); foreach (var entry in environment.CapturedEnvironmentVariables ?? []) { var (key, (description, defaultValue)) = entry; - - await envWriter.WriteLineAsync($"# {description}").ConfigureAwait(false); - - if (defaultValue is not null) - { - await envWriter.WriteLineAsync($"{key}={defaultValue}").ConfigureAwait(false); - } - else - { - await envWriter.WriteLineAsync($"{key}=").ConfigureAwait(false); - } - - await envWriter.WriteLineAsync().ConfigureAwait(false); + envFile.AddIfMissing(key, defaultValue, description); } - await envWriter.FlushAsync().ConfigureAwait(false); + envFile.Save(envFilePath); } private static void HandleComposeFileVolumes(DockerComposeServiceResource serviceResource, ComposeFile composeFile) diff --git a/src/Aspire.Hosting.Docker/EnvFile.cs b/src/Aspire.Hosting.Docker/EnvFile.cs new file mode 100644 index 00000000000..dcf70fa3df5 --- /dev/null +++ b/src/Aspire.Hosting.Docker/EnvFile.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Docker; + +internal sealed class EnvFile +{ + private readonly List _lines = []; + private readonly HashSet _keys = []; + + public static EnvFile Load(string path) + { + var envFile = new EnvFile(); + if (!File.Exists(path)) + { + return envFile; + } + + foreach (var line in File.ReadAllLines(path)) + { + envFile._lines.Add(line); + var trimmed = line.TrimStart(); + if (!trimmed.StartsWith('#') && trimmed.Contains('=')) + { + var eqIndex = trimmed.IndexOf('='); + if (eqIndex > 0) + { + var key = trimmed[..eqIndex].Trim(); + envFile._keys.Add(key); + } + } + } + return envFile; + } + + public void AddIfMissing(string key, string? value, string? comment) + { + if (_keys.Contains(key)) + { + return; + } + if (!string.IsNullOrWhiteSpace(comment)) + { + _lines.Add($"# {comment}"); + } + _lines.Add(value is not null ? $"{key}={value}" : $"{key}="); + _lines.Add(string.Empty); + _keys.Add(key); + } + + public void Save(string path) + { + File.WriteAllLines(path, _lines); + } +} diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index 23c9bcb3c82..9b567708c98 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -175,6 +175,76 @@ await Verify(File.ReadAllText(composePath), "yaml") .UseHelixAwareDirectory(); } + [Fact] + public async Task DockerComposeDoesNotOverwriteEnvFileOnPublish() + { + using var tempDir = new TempDirectory(); + var envFilePath = Path.Combine(tempDir.Path, ".env"); + + void PublishApp() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", outputPath: tempDir.Path); + builder.AddDockerComposeEnvironment("docker-compose"); + var param = builder.AddParameter("param1"); + builder.AddContainer("app", "busybox").WithEnvironment("param1", param); + var app = builder.Build(); + app.Run(); + } + + PublishApp(); + Assert.True(File.Exists(envFilePath)); + var firstContent = File.ReadAllText(envFilePath).Replace("PARAM1=", "PARAM1=changed"); + File.WriteAllText(envFilePath, firstContent); + + PublishApp(); + Assert.True(File.Exists(envFilePath)); + var secondContent = File.ReadAllText(envFilePath); + + await Verify(firstContent, "env") + .AppendContentAsFile(secondContent, "env") + .UseHelixAwareDirectory(); + } + + [Fact] + public async Task DockerComposeAppendsNewKeysToEnvFileOnPublish() + { + using var tempDir = new TempDirectory(); + var envFilePath = Path.Combine(tempDir.Path, ".env"); + + void PublishApp(params string[] paramNames) + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", outputPath: tempDir.Path); + builder.AddDockerComposeEnvironment("docker-compose"); + + var parmeters = paramNames.Select(name => builder.AddParameter(name).Resource).ToArray(); + + builder.AddContainer("app", "busybox") + .WithEnvironment(context => + { + foreach (var param in parmeters) + { + context.EnvironmentVariables[param.Name] = param; + } + }); + + var app = builder.Build(); + app.Run(); + } + + PublishApp(["param1"]); + Assert.True(File.Exists(envFilePath)); + var firstContent = File.ReadAllText(envFilePath).Replace("PARAM1=", "PARAM1=changed"); + File.WriteAllText(envFilePath, firstContent); + + PublishApp(["param1", "param2"]); + Assert.True(File.Exists(envFilePath)); + var secondContent = File.ReadAllText(envFilePath); + + await Verify(firstContent, "env") + .AppendContentAsFile(secondContent, "env") + .UseHelixAwareDirectory(); + } + private sealed class MockImageBuilder : IResourceContainerImageBuilder { public bool BuildImageCalled { get; private set; } diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppendsNewKeysToEnvFileOnPublish#00.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppendsNewKeysToEnvFileOnPublish#00.verified.env new file mode 100644 index 00000000000..c9a8d7c3a31 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppendsNewKeysToEnvFileOnPublish#00.verified.env @@ -0,0 +1,3 @@ +# Parameter param1 +PARAM1=changed + 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 new file mode 100644 index 00000000000..c2fbff884af --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppendsNewKeysToEnvFileOnPublish#01.verified.env @@ -0,0 +1,6 @@ +# Parameter param1 +PARAM1=changed + +# Parameter param2 +PARAM2= + diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeDoesNotOverwriteEnvFileOnPublish#00.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeDoesNotOverwriteEnvFileOnPublish#00.verified.env new file mode 100644 index 00000000000..c9a8d7c3a31 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeDoesNotOverwriteEnvFileOnPublish#00.verified.env @@ -0,0 +1,3 @@ +# Parameter param1 +PARAM1=changed + 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 new file mode 100644 index 00000000000..c9a8d7c3a31 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeDoesNotOverwriteEnvFileOnPublish#01.verified.env @@ -0,0 +1,3 @@ +# Parameter param1 +PARAM1=changed +