From d7b2b3ab0114389c34ca06c37c180ad756e8944c Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Mon, 25 May 2026 14:42:31 +0200 Subject: [PATCH 1/4] Support IValueProvider sources in Docker Compose .env file generation Co-Authored-By: Claude Sonnet 4.6 --- .../DockerComposeEnvironmentResource.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index d0054a28dc9..50ee9e29fc8 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -515,14 +515,16 @@ private async Task PrepareAsync(PipelineStepContext context) var envVar = entry.Value; var defaultValue = envVar.DefaultValue; - if (defaultValue is null && envVar.Source is ParameterResource parameter) + // Only resolve from the parameter if no static default is already set; + // a caller that provides an explicit default intends to skip parameter resolution. + if (envVar.Source is ParameterResource parameter) { - defaultValue = await parameter.GetValueAsync(context.CancellationToken).ConfigureAwait(false); + defaultValue ??= await parameter.GetValueAsync(context.CancellationToken).ConfigureAwait(false); } - - if (envVar.Source is ContainerImageReference cir) + else if (envVar.Source is IValueProvider vp) { - defaultValue = await ((IValueProvider)cir).GetValueAsync(context.CancellationToken).ConfigureAwait(false); + // IValueProvider sources are always resolved dynamically — a static default is never used. + defaultValue = await vp.GetValueAsync(context.CancellationToken).ConfigureAwait(false); } envFile.Add(entry.Key, defaultValue, envVar.Description, onlyIfMissing: false); From 44cfcc84be5ff3fc94104c30d901c3269f6742b4 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Mon, 25 May 2026 15:39:14 +0200 Subject: [PATCH 2/4] Add test for IValueProvider source resolution in .env file generation Co-Authored-By: Claude Sonnet 4.6 --- .../DockerComposePublisherTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index 2e31a98fae3..977bbf128a2 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -746,6 +746,34 @@ public void PrepareStep_OverwritesExistingEnvFileWithCustomEnvironmentName() Assert.DoesNotContain("OLD_STAGING_KEY", envFileContent); } + [Fact] + public async Task PrepareStep_ResolvesArbitraryIValueProviderSource() + { + using var tempDir = new TestTempDirectory(); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "prepare-docker-compose"); + builder.Services.AddSingleton(); + + builder.AddDockerComposeEnvironment("docker-compose"); + + builder.AddContainer("testapp", "testimage") + .WithEnvironment(context => + { + context.EnvironmentVariables["MY_VAR"] = new TestConditionProvider("resolved-value"); + }); + + var app = builder.Build(); + app.Run(); + + // The compose file uses the user-specified container env var name; the .env file uses the + // name derived from the provider's ValueExpression. Docker Compose interpolates between them. + var composeContent = await File.ReadAllTextAsync(Path.Combine(tempDir.Path, "docker-compose.yaml")); + Assert.Contains("MY_VAR: \"${TEST_CONDITION}\"", composeContent); + + var envFileContent = await File.ReadAllTextAsync(Path.Combine(tempDir.Path, ".env.Production")); + Assert.Contains("TEST_CONDITION=resolved-value", envFileContent); + } + [Fact] public async Task PublishAsync_BindMounts_ReplacedWithEnvironmentPlaceholders() { From 7a544693f870004136d0feb52babe942b807ceeb Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Mon, 25 May 2026 16:00:29 +0200 Subject: [PATCH 3/4] Add regression tests for env file value resolution edge cases Co-Authored-By: Claude Sonnet 4.6 --- .../DockerComposePublisherTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index 977bbf128a2..f9fd28618f6 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -774,6 +774,63 @@ public async Task PrepareStep_ResolvesArbitraryIValueProviderSource() Assert.Contains("TEST_CONDITION=resolved-value", envFileContent); } + [Fact] + public async Task PrepareStep_SkipsParameterResolutionWhenStaticDefaultIsSet() + { + using var tempDir = new TestTempDirectory(); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "prepare-docker-compose"); + builder.Services.AddSingleton(); + + var environment = builder.AddDockerComposeEnvironment("docker-compose"); + + var param = builder.AddParameter("myparam", "dynamic-value"); + + builder.AddContainer("testapp", "testimage") + .WithEnvironment("MY_PARAM", param); + + // Set a static default before PrepareAsync runs; parameter resolution should be skipped. + environment.ConfigureEnvFile(vars => + { + if (vars.TryGetValue("MYPARAM", out var envVar)) + { + envVar.DefaultValue = "static-override"; + } + }); + + var app = builder.Build(); + app.Run(); + + var envFileContent = await File.ReadAllTextAsync(Path.Combine(tempDir.Path, ".env.Production")); + Assert.Contains("MYPARAM=static-override", envFileContent); + Assert.DoesNotContain("MYPARAM=dynamic-value", envFileContent); + } + + [Fact] + public async Task PrepareStep_ResolvesContainerImageReferenceViaIValueProvider() + { + using var tempDir = new TestTempDirectory(); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "prepare-docker-compose"); + builder.Services.AddSingleton(); + + builder.AddDockerComposeEnvironment("docker-compose"); + + // ProjectResource triggers AsContainerImagePlaceholder, which creates a CapturedEnvironmentVariable + // with Source=ContainerImageReference and DefaultValue="project1:latest". + // PrepareAsync should call GetValueAsync() via the IValueProvider branch; with no registry + // configured in the test, GetValueAsync() returns null and the entry is written empty — + // not "project1:latest". If the IValueProvider branch were skipped, the static default would appear. + builder.AddProject("project1"); + + var app = builder.Build(); + app.Run(); + + var envFileContent = await File.ReadAllTextAsync(Path.Combine(tempDir.Path, ".env.Production")); + Assert.Contains("PROJECT1_IMAGE=", envFileContent); + Assert.DoesNotContain("PROJECT1_IMAGE=project1:latest", envFileContent); + } + [Fact] public async Task PublishAsync_BindMounts_ReplacedWithEnvironmentPlaceholders() { From a1bea22dc551f67a5b05107faf2f8a541f481e71 Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Mon, 25 May 2026 20:42:49 +0200 Subject: [PATCH 4/4] Use Verify snapshots --- AGENTS.md | 1 + .../DockerComposePublisherTests.cs | 18 ++++++++--------- ...ArbitraryIValueProviderSource.verified.env | 2 ++ ...rbitraryIValueProviderSource.verified.yaml | 20 +++++++++++++++++++ ...ageReferenceViaIValueProvider.verified.env | 6 ++++++ ...olutionWhenStaticDefaultIsSet.verified.env | 3 +++ 6 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_ResolvesArbitraryIValueProviderSource.verified.env create mode 100644 tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_ResolvesArbitraryIValueProviderSource.verified.yaml create mode 100644 tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_ResolvesContainerImageReferenceViaIValueProvider.verified.env create mode 100644 tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_SkipsParameterResolutionWhenStaticDefaultIsSet.verified.env diff --git a/AGENTS.md b/AGENTS.md index 06778d059ee..5908f5613d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -263,6 +263,7 @@ kill * Do not use Directory.SetCurrentDirectory in tests as it can cause side effects when tests execute concurrently. * Prefer using shared test service implementations (e.g., project-level `TestServices/` or `Helpers/` directories, or the cross-project `tests/Shared/` folder) rather than creating private implementation classes within individual test files. Reusing existing test fakes and helpers keeps tests consistent, reduces duplication, and makes maintenance easier. Do not create private test classes when a shared one already exists or can be extended. * MTP diagnostic args (hang dump, crash dump, exit code handling) are defined in `eng/Testing.props` via `MtpBaseArgs`. Do not hardcode these args in workflow YAML. See [docs/ci/mtp-args-pipeline.md](docs/ci/mtp-args-pipeline.md) for details. +* Use `Verify` (snapshot testing) for generated artifacts (files, serialized output, structured text). Prefer `await Verify(value, "ext")` over `Assert.Contains` / `Assert.DoesNotContain` / `Assert.Equal` on the same value. Run the test once to generate the `.received.` file, review it, then rename it to `.verified.` to accept it. ## Running tests diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index f9fd28618f6..7ac8fdec09f 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREPIPELINES003 +using System.Text.RegularExpressions; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Docker.Resources.ComposeNodes; using Aspire.Hosting.Publishing; @@ -768,10 +769,9 @@ public async Task PrepareStep_ResolvesArbitraryIValueProviderSource() // The compose file uses the user-specified container env var name; the .env file uses the // name derived from the provider's ValueExpression. Docker Compose interpolates between them. var composeContent = await File.ReadAllTextAsync(Path.Combine(tempDir.Path, "docker-compose.yaml")); - Assert.Contains("MY_VAR: \"${TEST_CONDITION}\"", composeContent); - var envFileContent = await File.ReadAllTextAsync(Path.Combine(tempDir.Path, ".env.Production")); - Assert.Contains("TEST_CONDITION=resolved-value", envFileContent); + await Verify(composeContent, "yaml") + .AppendContentAsFile(envFileContent, "env"); } [Fact] @@ -802,8 +802,7 @@ public async Task PrepareStep_SkipsParameterResolutionWhenStaticDefaultIsSet() app.Run(); var envFileContent = await File.ReadAllTextAsync(Path.Combine(tempDir.Path, ".env.Production")); - Assert.Contains("MYPARAM=static-override", envFileContent); - Assert.DoesNotContain("MYPARAM=dynamic-value", envFileContent); + await Verify(envFileContent, "env"); } [Fact] @@ -827,8 +826,9 @@ public async Task PrepareStep_ResolvesContainerImageReferenceViaIValueProvider() app.Run(); var envFileContent = await File.ReadAllTextAsync(Path.Combine(tempDir.Path, ".env.Production")); - Assert.Contains("PROJECT1_IMAGE=", envFileContent); - Assert.DoesNotContain("PROJECT1_IMAGE=project1:latest", envFileContent); + await Verify(envFileContent, "env") + // The image tag includes the current time (e.g. "aspire-deploy-20260525182406"); scrub it so the snapshot is stable across runs. + .ScrubLinesWithReplace(line => Regex.Replace(line, @"aspire-deploy-\d{14}", "aspire-deploy-TIMESTAMP")); } [Fact] @@ -972,8 +972,8 @@ public async Task PublishAsync_ConfigureEnvFile_CanRemoveGeneratedPlaceholder() .ConfigureEnvFile(envVars => { // Find and remove the auto-generated bind mount placeholder for yarp - var keysToRemove = envVars.Where(kv => - kv.Value.Resource?.Name == "yarp" && + var keysToRemove = envVars.Where(kv => + kv.Value.Resource?.Name == "yarp" && kv.Value.Source is ContainerMountAnnotation).Select(kv => kv.Key).ToList(); foreach (var key in keysToRemove) diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_ResolvesArbitraryIValueProviderSource.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_ResolvesArbitraryIValueProviderSource.verified.env new file mode 100644 index 00000000000..414c9ca6231 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_ResolvesArbitraryIValueProviderSource.verified.env @@ -0,0 +1,2 @@ +TEST_CONDITION=resolved-value + diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_ResolvesArbitraryIValueProviderSource.verified.yaml b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_ResolvesArbitraryIValueProviderSource.verified.yaml new file mode 100644 index 00000000000..b1bc1986498 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_ResolvesArbitraryIValueProviderSource.verified.yaml @@ -0,0 +1,20 @@ +services: + docker-compose-dashboard: + image: "mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest" + ports: + - "18888" + expose: + - "18889" + - "18890" + networks: + - "aspire" + restart: "always" + testapp: + image: "testimage:latest" + environment: + MY_VAR: "${TEST_CONDITION}" + networks: + - "aspire" +networks: + aspire: + driver: "bridge" diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_ResolvesContainerImageReferenceViaIValueProvider.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_ResolvesContainerImageReferenceViaIValueProvider.verified.env new file mode 100644 index 00000000000..d602207e988 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_ResolvesContainerImageReferenceViaIValueProvider.verified.env @@ -0,0 +1,6 @@ +# Container image name for project1 +PROJECT1_IMAGE=project1:aspire-deploy-TIMESTAMP + +# Default container port for project1 +PROJECT1_PORT=8080 + diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_SkipsParameterResolutionWhenStaticDefaultIsSet.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_SkipsParameterResolutionWhenStaticDefaultIsSet.verified.env new file mode 100644 index 00000000000..3f75b2449e8 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_SkipsParameterResolutionWhenStaticDefaultIsSet.verified.env @@ -0,0 +1,3 @@ +# Parameter myparam +MYPARAM=static-override +