Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,6 @@ public class DockerComposeEnvironmentResource : Resource, IComputeEnvironmentRes

internal Dictionary<IResource, DockerComposeServiceResource> ResourceMapping { get; } = new(new ResourceNameComparer());

internal EnvFile? SharedEnvFile { get; set; }

internal PortAllocator PortAllocator { get; } = new();

/// <param name="name">The name of the Docker Compose environment.</param>
Expand Down Expand Up @@ -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;
Expand All @@ -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)
Expand Down
6 changes: 2 additions & 4 deletions src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [])
{
Expand All @@ -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(
Expand Down
74 changes: 58 additions & 16 deletions src/Aspire.Hosting.Docker/EnvFile.cs
Original file line number Diff line number Diff line change
@@ -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<string, EnvEntry> _entries = [];
private string? _path;
private readonly ILogger? _logger;

internal SortedDictionary<string, EnvEntry> Entries { get; } = [];
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Entries property is marked as internal but was previously a private field _entries. Exposing the internal dictionary directly allows external code within the assembly to modify it without going through the controlled Add() method, potentially bypassing validation logic. Consider keeping this as a private field or exposing it as IReadOnlyDictionary<string, EnvEntry> to prevent uncontrolled mutations.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is Entries used outside of this class?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, Entries is only used within the EnvFile class itself. The grep results show it's only accessed internally in EnvFile.cs at line 42 where it assigns values during the Load operation.


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;
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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<string>();

foreach (var entry in _entries.Values)
foreach (var entry in Entries.Values)
{
if (!string.IsNullOrWhiteSpace(entry.Comment))
{
Expand All @@ -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<string>();

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);
}
}
63 changes: 63 additions & 0 deletions tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IResourceContainerImageBuilder, MockImageBuilder>();
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<IResourceContainerImageBuilder, MockImageBuilder>();
builder.Services.AddSingleton<Microsoft.Extensions.Hosting.IHostEnvironment>(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; }
Expand Down
12 changes: 6 additions & 6 deletions tests/Aspire.Hosting.Docker.Tests/EnvFileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand All @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Parameter param1
PARAM1=
PARAM1=changed

# Parameter param2
PARAM2=
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Parameter param1
PARAM1=
PARAM1=changed

Loading