From 9f431c6abe8d8d4775b317422ce5044aa66ffb0b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 17 Aug 2026 14:09:50 +1000 Subject: [PATCH 1/6] Fix Kubernetes embedded environment values Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../KubernetesPublishingContext.cs | 12 +++-- .../KubernetesResource.cs | 36 +++++++------- .../KubernetesDeployTests.cs | 44 +++++++++++++++++ .../KubernetesPublisherTests.cs | 47 +++++++++++++++++++ ...s_EndToEnd_PublishAndResolve.verified.yaml | 8 ++++ ...ExpressionsPopulateValues#00.verified.yaml | 9 ++++ ...ExpressionsPopulateValues#01.verified.yaml | 11 +++++ ...ExpressionsPopulateValues#02.verified.yaml | 12 +++++ ...andlesSpecialResourceName#01.verified.yaml | 2 + 9 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.EmbeddedParametersInEnvironmentExpressions_EndToEnd_PublishAndResolve.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulateValues#00.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulateValues#01.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulateValues#02.verified.yaml diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs index cfdd39ed6f6..bc14d2aada4 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs @@ -187,16 +187,22 @@ private async Task AppendResourceContextToHelmValuesAsync(IResource resource, Ku { await AddValuesToHelmSectionAsync(resource, resourceContext.Parameters, HelmExtensions.ParametersKey).ConfigureAwait(false); - // Merge AdditionalConfigValues (e.g., branch parameters from if/else conditionals) - // into a combined dictionary for the config section of values.yaml. + // Embedded parameters need values.yaml entries for their Helm references, but they must + // not become additional environment variables in the generated ConfigMap or Secret. var configItems = new Dictionary(resourceContext.EnvironmentVariables); foreach (var kvp in resourceContext.AdditionalConfigValues) { configItems.TryAdd(kvp.Key, kvp.Value); } + var secretItems = new Dictionary(resourceContext.Secrets); + foreach (var kvp in resourceContext.AdditionalSecretValues) + { + secretItems.TryAdd(kvp.Key, kvp.Value); + } + await AddValuesToHelmSectionAsync(resource, configItems, HelmExtensions.ConfigKey).ConfigureAwait(false); - await AddValuesToHelmSectionAsync(resource, resourceContext.Secrets, HelmExtensions.SecretsKey).ConfigureAwait(false); + await AddValuesToHelmSectionAsync(resource, secretItems, HelmExtensions.SecretsKey).ConfigureAwait(false); } private async Task AddValuesToHelmSectionAsync( diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index ebeccf84d10..69c0b2938bf 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -50,6 +50,7 @@ internal record EndpointMapping(string Scheme, string Protocol, string Host, Hel internal Dictionary Secrets { get; } = []; internal Dictionary Parameters { get; } = []; internal Dictionary AdditionalConfigValues { get; } = []; + internal Dictionary AdditionalSecretValues { get; } = []; internal Dictionary Labels { get; private set; } = []; internal List Commands { get; } = []; internal List Volumes { get; } = []; @@ -527,7 +528,13 @@ private async Task ProcessValueAsync(KubernetesEnvironmentContext contex if (value is ParameterResource param) { - return AllocateParameter(param, TargetResource); + var helmValue = AllocateParameter(param, TargetResource); + if (embedded) + { + AllocateAdditionalParameter(param, helmValue); + } + + return helmValue; } if (value is ConnectionStringReference cs) @@ -652,8 +659,7 @@ private async Task BuildHelmConditional(KubernetesEnvironmentContext con /// /// Ensures that any instances referenced in a branch's - /// value providers are allocated in the appropriate dictionary (EnvironmentVariables or - /// Secrets) so their values flow to values.yaml via AddValuesToHelmSectionAsync. + /// value providers are allocated so their values flow to values.yaml. /// private void AllocateBranchParameters(ReferenceExpression branch) { @@ -662,23 +668,21 @@ private void AllocateBranchParameters(ReferenceExpression branch) if (vp is ParameterResource branchParam) { var helmValue = AllocateParameter(branchParam, TargetResource); - var key = branchParam.Name.ToHelmValuesSectionName(); - - // Store in AdditionalConfigValues rather than EnvironmentVariables to avoid - // case-insensitive key collisions in ToConfigMap's processedKeys. These values - // flow to the config section of values.yaml but do not appear as env vars. - if (helmValue.ExpressionContainsHelmSecretExpression) - { - Secrets.TryAdd(key, helmValue); - } - else - { - AdditionalConfigValues.TryAdd(key, helmValue); - } + AllocateAdditionalParameter(branchParam, helmValue); } } } + /// + /// Allocates an embedded parameter without adding a synthetic environment variable. + /// + private void AllocateAdditionalParameter(ParameterResource parameter, HelmValue helmValue) + { + var key = parameter.Name.ToHelmValuesSectionName(); + var values = parameter.Secret ? AdditionalSecretValues : AdditionalConfigValues; + values.TryAdd(key, helmValue); + } + private static string GetEndpointValue(EndpointMapping mapping, EndpointProperty property, bool embedded = false) { var (scheme, _, host, targetPort, _, _, exposedPort) = mapping; diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs index 877570b8647..c95db6d0560 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs @@ -1573,6 +1573,50 @@ await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync( Assert.Contains("e2e-test-pw-42", content); } + [Fact] + public async Task EmbeddedParametersInEnvironmentExpressions_EndToEnd_PublishAndResolve() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var builder = TestDistributedApplicationBuilder.Create( + DistributedApplicationOperation.Publish, + workspace.Path, + step: WellKnownPipelineSteps.Publish); + var mockActivityReporter = new TestPipelineActivityReporter(outputHelper); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(mockActivityReporter); + + var envBuilder = builder.AddKubernetesEnvironment("env"); + var host = builder.AddParameter("host", "localhost"); + var token = builder.AddParameter("token", "test-token", secret: true); + + builder.AddContainer("myapp", "nginx") + .WithEnvironment("SOME_URL", $"http://{host}/test") + .WithEnvironment("SECRET_URL", $"http://{host}/test?token={token}"); + + using var app = builder.Build(); + var env = envBuilder.Resource; + await app.RunAsync(); + + Assert.Contains(env.CapturedHelmValues, captured => + captured.Section == "config" && + captured.ResourceKey == "myapp" && + captured.ValueKey == "host" && + captured.Parameter == host.Resource); + Assert.Contains(env.CapturedHelmValues, captured => + captured.Section == "secrets" && + captured.ResourceKey == "myapp" && + captured.ValueKey == "token" && + captured.Parameter == token.Resource); + + await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync( + workspace.Path, env, CancellationToken.None); + + var overridePath = Path.Combine(workspace.Path, HelmDeploymentEngine.GetDeployValuesFileName("env")); + await Verify(await File.ReadAllTextAsync(overridePath), "yaml"); + } + [Fact] public void AddKubernetesEnvironment_CreatesDashboardByDefault() { diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index 70276c6c08e..7b9948db8c6 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -855,6 +855,53 @@ public async Task KubernetesProbeUsesContainerTargetPortNotServicePort() await settingsTask; } + [Fact] + public async Task PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulateValues() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + + builder.AddKubernetesEnvironment("env"); + + // Regression for https://github.com/microsoft/aspire/issues/11140: the base chart keeps + // deployment parameters empty, but every nested Helm reference must still be declared. + var host = builder.AddParameter("host", "localhost"); + var token = builder.AddParameter("token", "test-token", secret: true); + + builder.AddContainer("myapp", "nginx") + .WithEnvironment("SOME_URL", $"http://{host}/test") + .WithEnvironment("SECRET_URL", $"http://{host}/test?token={token}"); + + var app = builder.Build(); + app.Run(); + + var expectedFiles = new[] + { + "values.yaml", + "templates/myapp/config.yaml", + "templates/myapp/secrets.yaml", + }; + + SettingsTask settingsTask = default!; + + foreach (var expectedFile in expectedFiles) + { + var filePath = Path.Combine(workspace.Path, expectedFile); + var fileExtension = Path.GetExtension(filePath)[1..]; + + if (settingsTask is null) + { + settingsTask = Verify(File.ReadAllText(filePath), fileExtension); + } + else + { + settingsTask = settingsTask.AppendContentAsFile(File.ReadAllText(filePath), fileExtension); + } + } + + await settingsTask; + } + [Fact] public async Task PublishAsync_HandlesConditionalReferenceExpression() { diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.EmbeddedParametersInEnvironmentExpressions_EndToEnd_PublishAndResolve.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.EmbeddedParametersInEnvironmentExpressions_EndToEnd_PublishAndResolve.verified.yaml new file mode 100644 index 00000000000..ca6b838750e --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.EmbeddedParametersInEnvironmentExpressions_EndToEnd_PublishAndResolve.verified.yaml @@ -0,0 +1,8 @@ +config: + myapp: + host: localhost + SOME_URL: http://localhost/test +secrets: + myapp: + token: test-token + SECRET_URL: http://localhost/test?token=test-token diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulateValues#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulateValues#00.verified.yaml new file mode 100644 index 00000000000..dee959739d2 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulateValues#00.verified.yaml @@ -0,0 +1,9 @@ +parameters: {} +secrets: + myapp: + SECRET_URL: "" + token: "" +config: + myapp: + SOME_URL: "" + host: "" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulateValues#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulateValues#01.verified.yaml new file mode 100644 index 00000000000..c626e1798c2 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulateValues#01.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "myapp-config" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + SOME_URL: "http://{{ .Values.config.myapp.host }}/test" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulateValues#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulateValues#02.verified.yaml new file mode 100644 index 00000000000..0b4df8b2023 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulateValues#02.verified.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: "v1" +kind: "Secret" +metadata: + name: "myapp-secrets" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" +stringData: + SECRET_URL: "http://{{ .Values.config.myapp.host }}/test?token={{ .Values.secrets.myapp.token }}" +type: "Opaque" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesSpecialResourceName#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesSpecialResourceName#01.verified.yaml index efa2cce635d..275a9d7629f 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesSpecialResourceName#01.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesSpecialResourceName#01.verified.yaml @@ -5,6 +5,7 @@ secrets: SpeciaL_ApP: param3: "" ConnectionStrings__api_cs: "" + param1: "" config: SpeciaL_ApP: OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" @@ -12,3 +13,4 @@ config: OTEL_EXPORTER_OTLP_ENDPOINT: "http://env-dashboard-service:18889" OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" OTEL_SERVICE_NAME: "SpeciaL-ApP" + param0: "" From 53abb9804044c16e64863ada6ffa2becce1e9a6b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 17 Aug 2026 19:03:51 +1000 Subject: [PATCH 2/6] Expand Kubernetes Helm expression coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a27416ca-ff25-4524-b57f-94daeb337f40 --- .../KubernetesResource.cs | 6 + .../KubernetesPublishTests.cs | 70 ++++++++ .../KubernetesDeployTests.cs | 44 +++++ .../KubernetesPublisherTests.cs | 155 ++++++++++++++++-- ...ndToEnd_PublishAndResolve#00.verified.yaml | 5 + ...ndToEnd_PublishAndResolve#01.verified.yaml | 11 ++ ...ndToEnd_PublishAndResolve#02.verified.yaml | 3 + ...nPreservesExpressionShape#00.verified.yaml | 7 + ...nPreservesExpressionShape#01.verified.yaml | 11 ++ ...CaptureEmbeddedParameters#00.verified.yaml | 10 ++ ...CaptureEmbeddedParameters#01.verified.yaml | 12 ++ ...ameterIsScopedPerResource#00.verified.yaml | 9 + ...ameterIsScopedPerResource#01.verified.yaml | 11 ++ ...ameterIsScopedPerResource#02.verified.yaml | 11 ++ .../TestValueProvider.cs | 21 +++ 15 files changed, 373 insertions(+), 13 deletions(-) create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#00.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#01.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#02.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_CompositeExpressionPreservesExpressionShape#00.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_CompositeExpressionPreservesExpressionShape#01.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#00.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#01.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_SharedEmbeddedParameterIsScopedPerResource#00.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_SharedEmbeddedParameterIsScopedPerResource#01.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_SharedEmbeddedParameterIsScopedPerResource#02.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/TestValueProvider.cs diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index 69c0b2938bf..ed903458df1 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -441,6 +441,12 @@ private void ProcessEnvironmentHelmExpression(HelmValue helmExpression, string k { switch (helmExpression) { + case { ValueContainsSecretValuesExpression: true, ValueString: { } secretValue }: + // Parameter-driven conditionals are stored as literal Helm flow-control expressions. + // Route the final environment variable through a Secret when either branch references + // a secret value, even though the HelmValue itself has no Expression metadata. + Secrets[key] = new(key.ToHelmSecretExpression(TargetResource.Name), secretValue); + return; case { ExpressionContainsHelmSecretExpression: true, ValueContainsSecretValuesExpression: false }: Secrets[key] = helmExpression; return; diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs index 146a26ea583..c2181315655 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs @@ -311,4 +311,74 @@ await auto.TypeAsync("helm install aspire-app helm-output " + } } } + + [Fact] + [CaptureWorkspaceOnFailure] + public async Task RenderEmbeddedEnvironmentExpressionsWithHelm() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + using var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + await auto.VerifyPullRequestCliVersionAsync(counter); + await auto.InstallKindAndHelmAsync(counter); + + await auto.AspireNewAsync(ProjectName, counter, useRedisCache: false); + await auto.RunCommandAsync($"cd {ProjectName}", counter); + + await auto.TypeAsync("aspire add Aspire.Hosting.Kubernetes"); + await auto.EnterAsync(); + await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180)); + + var appHostFilePath = Path.Combine( + workspace.WorkspaceRoot.FullName, + ProjectName, + $"{ProjectName}.AppHost", + "AppHost.cs"); + + File.WriteAllText( + appHostFilePath, + """ + #pragma warning disable ASPIRECOMPUTE003, ASPIREPIPELINES001 + using Aspire.Hosting; + using Aspire.Hosting.Kubernetes; + + var builder = DistributedApplication.CreateBuilder(args); + + var host = builder.AddParameter("host", "publish-host"); + var token = builder.AddParameter("token", "publish-token", secret: true); + + builder.AddContainer("myapp", "nginx") + .WithEnvironment("SOME_URL", $"http://{host}/test") + .WithEnvironment("SECRET_URL", $"http://{host}/test?token={token}"); + + builder.AddKubernetesEnvironment("env"); + + builder.Build().Run(); + """); + + await auto.RunCommandAsync("unset ASPIRE_PLAYGROUND", counter); + await auto.RunCommandAsync( + "aspire publish -o helm-output --non-interactive", + counter, + TimeSpan.FromMinutes(5)); + await auto.RunCommandAsync( + "helm template aspire-app helm-output " + + "--set-string config.myapp.host=rendered-host " + + "--set-string secrets.myapp.token=rendered-token > rendered.yaml", + counter); + await auto.RunCommandAsync( + "grep -F 'SOME_URL: \"http://rendered-host/test\"' rendered.yaml", + counter); + await auto.RunCommandAsync( + "grep -F 'SECRET_URL: \"http://rendered-host/test?token=rendered-token\"' rendered.yaml", + counter); + } } diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs index c95db6d0560..ea4b95d44ad 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs @@ -1617,6 +1617,50 @@ await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync( await Verify(await File.ReadAllTextAsync(overridePath), "yaml"); } + [Fact] + public async Task DeferredValueProvider_EndToEnd_PublishAndResolve() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var builder = TestDistributedApplicationBuilder.Create( + DistributedApplicationOperation.Publish, + workspace.Path, + step: WellKnownPipelineSteps.Publish); + var mockActivityReporter = new TestPipelineActivityReporter(outputHelper); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(mockActivityReporter); + + var envBuilder = builder.AddKubernetesEnvironment("env"); + var provider = new TestValueProvider("resolved-value", "{outputs.deferred}"); + + builder.AddContainer("myapp", "nginx") + .WithEnvironment(context => + { + context.EnvironmentVariables["DEFERRED_VALUE"] = provider; + }); + + using var app = builder.Build(); + var env = envBuilder.Resource; + await app.RunAsync(); + + Assert.Contains(env.CapturedHelmValueProviders, captured => + captured.Section == "config" && + captured.ResourceKey == "myapp" && + captured.ValueKey == "DEFERRED_VALUE" && + captured.ValueProvider == provider); + + await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync( + workspace.Path, env, CancellationToken.None); + + var overridePath = Path.Combine(workspace.Path, HelmDeploymentEngine.GetDeployValuesFileName("env")); + await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "values.yaml")), "yaml") + .AppendContentAsFile( + await File.ReadAllTextAsync(Path.Combine(workspace.Path, "templates", "myapp", "config.yaml")), + "yaml") + .AppendContentAsFile(await File.ReadAllTextAsync(overridePath), "yaml"); + } + [Fact] public void AddKubernetesEnvironment_CreatesDashboardByDefault() { diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index 7b9948db8c6..94e01a415cc 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -902,6 +902,95 @@ public async Task PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulat await settingsTask; } + [Fact] + public async Task PublishAsync_CompositeExpressionPreservesExpressionShape() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + + builder.AddKubernetesEnvironment("env"); + + var first = builder.AddParameter("first", "alpha", publishValueAsDefault: true); + var second = builder.AddParameter("second", "beta", publishValueAsDefault: true); + + builder.AddContainer("myapp", "nginx") + .WithEnvironment("COMPOSITE", $"prefix-{{literal}}-{second}-{first}-{second}-suffix"); + + var app = builder.Build(); + app.Run(); + + var expectedFiles = new[] + { + "values.yaml", + "templates/myapp/config.yaml", + }; + + SettingsTask settingsTask = default!; + + foreach (var expectedFile in expectedFiles) + { + var filePath = Path.Combine(workspace.Path, expectedFile); + var fileExtension = Path.GetExtension(filePath)[1..]; + + if (settingsTask is null) + { + settingsTask = Verify(File.ReadAllText(filePath), fileExtension); + } + else + { + settingsTask = settingsTask.AppendContentAsFile(File.ReadAllText(filePath), fileExtension); + } + } + + await settingsTask; + } + + [Fact] + public async Task PublishAsync_SharedEmbeddedParameterIsScopedPerResource() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + + builder.AddKubernetesEnvironment("env"); + + var sharedHost = builder.AddParameter("shared-host", "shared.internal", publishValueAsDefault: true); + + builder.AddContainer("first", "nginx") + .WithEnvironment("URL", $"http://{sharedHost}/first"); + + builder.AddContainer("second", "nginx") + .WithEnvironment("URL", $"http://{sharedHost}/second"); + + var app = builder.Build(); + app.Run(); + + var expectedFiles = new[] + { + "values.yaml", + "templates/first/config.yaml", + "templates/second/config.yaml", + }; + + SettingsTask settingsTask = default!; + + foreach (var expectedFile in expectedFiles) + { + var filePath = Path.Combine(workspace.Path, expectedFile); + var fileExtension = Path.GetExtension(filePath)[1..]; + + if (settingsTask is null) + { + settingsTask = Verify(File.ReadAllText(filePath), fileExtension); + } + else + { + settingsTask = settingsTask.AppendContentAsFile(File.ReadAllText(filePath), fileExtension); + } + } + + await settingsTask; + } + [Fact] public async Task PublishAsync_HandlesConditionalReferenceExpression() { @@ -914,7 +1003,7 @@ public async Task PublishAsync_HandlesConditionalReferenceExpression() .WithEnvironment(context => { var conditional = ReferenceExpression.CreateConditional( - new TestConditionProvider(bool.TrueString), + new TestValueProvider(bool.TrueString), bool.TrueString, ReferenceExpression.Create($",ssl=true"), ReferenceExpression.Empty); @@ -922,7 +1011,7 @@ public async Task PublishAsync_HandlesConditionalReferenceExpression() context.EnvironmentVariables["TLS_SUFFIX"] = conditional; var conditionalFalse = ReferenceExpression.CreateConditional( - new TestConditionProvider(bool.FalseString), + new TestValueProvider(bool.FalseString), bool.TrueString, ReferenceExpression.Create($",ssl=true"), ReferenceExpression.Create($",ssl=false")); @@ -963,6 +1052,57 @@ public async Task PublishAsync_HandlesConditionalReferenceExpression() await settingsTask; } + [Fact] + public async Task PublishAsync_ConditionalBranchesCaptureEmbeddedParameters() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + + builder.AddKubernetesEnvironment("env"); + + var mode = builder.AddParameter("mode", "enabled", publishValueAsDefault: true); + var user = builder.AddParameter("user", "alice", publishValueAsDefault: true); + var password = builder.AddParameter("password", "test-password", secret: true); + + builder.AddContainer("myapp", "nginx") + .WithEnvironment(context => + { + context.EnvironmentVariables["OPTIONS"] = ReferenceExpression.CreateConditional( + mode.Resource, + "enabled", + ReferenceExpression.Create($"user={user};password={password}"), + ReferenceExpression.Create($"user={user};disabled")); + }); + + var app = builder.Build(); + app.Run(); + + var expectedFiles = new[] + { + "values.yaml", + "templates/myapp/secrets.yaml", + }; + + SettingsTask settingsTask = default!; + + foreach (var expectedFile in expectedFiles) + { + var filePath = Path.Combine(workspace.Path, expectedFile); + var fileExtension = Path.GetExtension(filePath)[1..]; + + if (settingsTask is null) + { + settingsTask = Verify(File.ReadAllText(filePath), fileExtension); + } + else + { + settingsTask = settingsTask.AppendContentAsFile(File.ReadAllText(filePath), fileExtension); + } + } + + await settingsTask; + } + [Fact] public async Task PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition() { @@ -1637,17 +1777,6 @@ private static void AssertNoBuggyEmptyMappings(string content) } } - private sealed class TestConditionProvider(string value) : IValueProvider, IManifestExpressionProvider - { - public string ValueExpression => "test-condition"; - - public ValueTask GetValueAsync(CancellationToken cancellationToken = default) - => new(value); - - public ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default) - => new(value); - } - private sealed class TestProject : IProjectMetadata { public string ProjectPath => "another-path"; diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#00.verified.yaml new file mode 100644 index 00000000000..d69828d7f79 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#00.verified.yaml @@ -0,0 +1,5 @@ +parameters: {} +secrets: {} +config: + myapp: + DEFERRED_VALUE: "" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#01.verified.yaml new file mode 100644 index 00000000000..ab251d43eea --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#01.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "myapp-config" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + DEFERRED_VALUE: "{{ .Values.config.myapp.DEFERRED_VALUE }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#02.verified.yaml new file mode 100644 index 00000000000..3817d794388 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#02.verified.yaml @@ -0,0 +1,3 @@ +config: + myapp: + DEFERRED_VALUE: resolved-value diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_CompositeExpressionPreservesExpressionShape#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_CompositeExpressionPreservesExpressionShape#00.verified.yaml new file mode 100644 index 00000000000..f8c96f2721a --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_CompositeExpressionPreservesExpressionShape#00.verified.yaml @@ -0,0 +1,7 @@ +parameters: {} +secrets: {} +config: + myapp: + COMPOSITE: "" + second: "beta" + first: "alpha" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_CompositeExpressionPreservesExpressionShape#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_CompositeExpressionPreservesExpressionShape#01.verified.yaml new file mode 100644 index 00000000000..f0fd3afa0b8 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_CompositeExpressionPreservesExpressionShape#01.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "myapp-config" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + COMPOSITE: "prefix-{literal}-{{ .Values.config.myapp.second }}-{{ .Values.config.myapp.first }}-{{ .Values.config.myapp.second }}-suffix" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#00.verified.yaml new file mode 100644 index 00000000000..121d4ecd96d --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#00.verified.yaml @@ -0,0 +1,10 @@ +parameters: + myapp: + mode: "enabled" +secrets: + myapp: + OPTIONS: "" + password: "" +config: + myapp: + user: "alice" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#01.verified.yaml new file mode 100644 index 00000000000..21e316b822a --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#01.verified.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: "v1" +kind: "Secret" +metadata: + name: "myapp-secrets" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "myapp" + app.kubernetes.io/instance: "{{ .Release.Name }}" +stringData: + OPTIONS: {{ if eq (.Values.parameters.myapp.mode | lower) "enabled" }}user={{ .Values.config.myapp.user }};password={{ .Values.secrets.myapp.password }}{{ else }}user={{ .Values.config.myapp.user }};disabled{{ end }} +type: "Opaque" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_SharedEmbeddedParameterIsScopedPerResource#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_SharedEmbeddedParameterIsScopedPerResource#00.verified.yaml new file mode 100644 index 00000000000..673d2d09e59 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_SharedEmbeddedParameterIsScopedPerResource#00.verified.yaml @@ -0,0 +1,9 @@ +parameters: {} +secrets: {} +config: + first: + URL: "" + shared_host: "shared.internal" + second: + URL: "" + shared_host: "shared.internal" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_SharedEmbeddedParameterIsScopedPerResource#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_SharedEmbeddedParameterIsScopedPerResource#01.verified.yaml new file mode 100644 index 00000000000..e7fe3f5abb7 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_SharedEmbeddedParameterIsScopedPerResource#01.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "first-config" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "first" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + URL: "http://{{ .Values.config.first.shared_host }}/first" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_SharedEmbeddedParameterIsScopedPerResource#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_SharedEmbeddedParameterIsScopedPerResource#02.verified.yaml new file mode 100644 index 00000000000..7e4ca0e16b4 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_SharedEmbeddedParameterIsScopedPerResource#02.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "second-config" + labels: + app.kubernetes.io/name: "{{ .Chart.Name }}" + app.kubernetes.io/component: "second" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + URL: "http://{{ .Values.config.second.shared_host }}/second" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/TestValueProvider.cs b/tests/Aspire.Hosting.Kubernetes.Tests/TestValueProvider.cs new file mode 100644 index 00000000000..1021ea4de2a --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/TestValueProvider.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Kubernetes.Tests; + +internal sealed class TestValueProvider( + string value, + string valueExpression = "{test-value}") : IValueProvider, IManifestExpressionProvider +{ + public string ValueExpression { get; } = valueExpression; + + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) + => new(value); + + public ValueTask GetValueAsync( + ValueProviderContext context, + CancellationToken cancellationToken = default) + => new(value); +} From 6ea8f3bbadbe867bf75a96dc8ea25604b1778482 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 18 Aug 2026 14:18:35 +1000 Subject: [PATCH 3/6] Address Kubernetes Helm review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a27416ca-ff25-4524-b57f-94daeb337f40 --- .../Extensions/HelmExtensions.cs | 25 ++++- .../KubernetesEnvironmentResource.cs | 2 +- .../KubernetesPublishingContext.cs | 94 +++++++++++++++---- .../KubernetesResource.cs | 36 +++++-- .../KubernetesPublishTests.cs | 27 +++++- .../KubernetesDeployTests.cs | 2 +- .../KubernetesPublisherTests.cs | 29 ++++++ ...CaptureEmbeddedParameters#01.verified.yaml | 2 +- ...erBranch_UsesIfElseSyntax#05.verified.yaml | 4 +- ...ionWithParameterCondition#05.verified.yaml | 4 +- 10 files changed, 189 insertions(+), 36 deletions(-) diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs index 60d88f45cc6..782a156e59c 100644 --- a/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using System.Text.RegularExpressions; using YamlDotNet.Core; @@ -85,13 +86,23 @@ public static bool ContainsHelmValuesSecretExpression(this string value) => ExpressionPattern().IsMatch(value) && value.Contains($"{ValuesSegment}.{SecretsKey}.", StringComparison.Ordinal); + public static bool ContainsHelmFlowControlExpression(this string value) + => HelmFlowControlExpressionPattern().IsMatch(value); + + /// + /// Evaluates a string as a Helm template and quotes the complete result as a YAML scalar. + /// + public static string ToQuotedHelmTemplateExpression(this string value) + => $"{StartDelimiter} tpl {JsonSerializer.Serialize(value)} . {PipelineDelimiter} quote {EndDelimiter}"; + public static (bool, ScalarStyle?) ShouldDoubleQuoteString(string value) { - // Flow control expressions (if/else) must be rendered as plain YAML so Helm - // can process them as template expressions without YAML escaping. This check - // runs first because if/else blocks contain multiple {{ }} pairs and won't + // Flow control expressions and generated `tpl ... | quote` wrappers must be rendered + // as plain YAML so Helm can evaluate them before the rendered output is parsed as YAML. + // This check runs first because if/else blocks contain multiple {{ }} pairs and won't // match ScalarExpressionPattern. - if (HelmFlowControlPattern().IsMatch(value)) + if (HelmFlowControlPattern().IsMatch(value) || + QuotedTemplateExpressionPattern().IsMatch(value)) { return (false, ScalarStyle.ForcePlain); } @@ -130,6 +141,12 @@ public static string EnsureStringOutput(this string value) [GeneratedRegex(@"^\{\{\s*if\b")] internal static partial Regex HelmFlowControlPattern(); + [GeneratedRegex(@"\{\{\s*if\b")] + private static partial Regex HelmFlowControlExpressionPattern(); + + [GeneratedRegex(@"^\{\{\s*tpl\b.*\|\s*quote\s*\}\}$")] + private static partial Regex QuotedTemplateExpressionPattern(); + [GeneratedRegex(@"\{\{[^}]*\|\s*(int|int64|float64)\s*\}\}")] internal static partial Regex EndWithNonStringTypePattern(); diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs index 6ccc245d316..177a54c033f 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs @@ -145,7 +145,7 @@ public sealed class KubernetesEnvironmentResource : Resource, IComputeEnvironmen /// /// Captured parameter-to-values.yaml mappings populated during publish, consumed during deploy - /// to resolve secret and unresolved parameter values into the environment values file. + /// to resolve parameter values and composite references into the environment values file. /// internal List CapturedHelmValues { get; } = []; diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs index bc14d2aada4..b45202e3b7c 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs @@ -189,22 +189,71 @@ private async Task AppendResourceContextToHelmValuesAsync(IResource resource, Ku // Embedded parameters need values.yaml entries for their Helm references, but they must // not become additional environment variables in the generated ConfigMap or Secret. - var configItems = new Dictionary(resourceContext.EnvironmentVariables); - foreach (var kvp in resourceContext.AdditionalConfigValues) - { - configItems.TryAdd(kvp.Key, kvp.Value); - } - - var secretItems = new Dictionary(resourceContext.Secrets); - foreach (var kvp in resourceContext.AdditionalSecretValues) - { - secretItems.TryAdd(kvp.Key, kvp.Value); - } + var configItems = MergeHelmValueMappings( + resource, + HelmExtensions.ConfigKey, + resourceContext.EnvironmentVariables, + resourceContext.AdditionalConfigValues); + var secretItems = MergeHelmValueMappings( + resource, + HelmExtensions.SecretsKey, + resourceContext.Secrets, + resourceContext.AdditionalSecretValues); await AddValuesToHelmSectionAsync(resource, configItems, HelmExtensions.ConfigKey).ConfigureAwait(false); await AddValuesToHelmSectionAsync(resource, secretItems, HelmExtensions.SecretsKey).ConfigureAwait(false); } + private static Dictionary MergeHelmValueMappings( + IResource resource, + string helmKey, + IReadOnlyDictionary environmentValues, + IReadOnlyDictionary embeddedParameters) + { + var resourceKey = resource.Name.ToHelmValuesSectionName(); + var result = new Dictionary(StringComparer.Ordinal); + var origins = new Dictionary(StringComparer.Ordinal); + + AddMappings(environmentValues, "environment value"); + AddMappings(embeddedParameters, "embedded parameter"); + + return result; + + void AddMappings( + IReadOnlyDictionary mappings, + string originKind) + { + foreach (var (key, value) in mappings) + { + var valuesKey = value.ValuesKey ?? key.ToHelmValuesSectionName(); + var origin = $"{originKind} '{key}'"; + + if (!result.TryGetValue(valuesKey, out var existing)) + { + result.Add(valuesKey, value); + origins.Add(valuesKey, origin); + continue; + } + + if (value.ParameterSource is not null && + ReferenceEquals(existing.ParameterSource, value.ParameterSource)) + { + if (value.IsEmbeddedParameter && !existing.IsEmbeddedParameter) + { + result[valuesKey] = value; + } + + continue; + } + + throw new InvalidOperationException( + $"Resource '{resource.Name}' maps both {origins[valuesKey]} and {origin} " + + $"to Helm values path '{helmKey}.{resourceKey}.{valuesKey}'. Rename one of them " + + "so each value has a unique Helm path."); + } + } + } + private async Task AddValuesToHelmSectionAsync( IResource resource, Dictionary contextItems, @@ -248,19 +297,30 @@ private async Task AddValuesToHelmSectionAsync( if (parameter.Secret || parameter.Default is null) { // Don't resolve secrets or parameters without defaults during publish. - // Write an empty placeholder and capture the mapping for deploy-time resolution. value = string.Empty; - environment?.CapturedHelmValues.Add( + } + else + { + value = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); + } + + // Embedded parameters must participate in deploy-time lookup even when their + // published default is already present in values.yaml. Parent composite values + // are resolved from this lookup when writing the deploy override file. + if ((parameter.Secret || parameter.Default is null || helmExpressionWithValue.IsEmbeddedParameter) && + environment is not null && + !environment.CapturedHelmValues.Any(captured => + captured.Section == helmKey && + captured.ResourceKey == resource.Name.ToHelmValuesSectionName() && + captured.ValueKey == valuesKey)) + { + environment.CapturedHelmValues.Add( new KubernetesEnvironmentResource.CapturedHelmValue( helmKey, resource.Name.ToHelmValuesSectionName(), valuesKey, parameter)); } - else - { - value = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); - } } else { diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index ed903458df1..63d29321663 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -439,6 +439,12 @@ private async Task ProcessEnvironmentAsync(KubernetesEnvironmentContext environm private void ProcessEnvironmentHelmExpression(HelmValue helmExpression, string key) { + if (helmExpression.ValueString is { } template && + template.ContainsHelmFlowControlExpression()) + { + helmExpression = HelmValue.Literal(template.ToQuotedHelmTemplateExpression()); + } + switch (helmExpression) { case { ValueContainsSecretValuesExpression: true, ValueString: { } secretValue }: @@ -458,6 +464,11 @@ private void ProcessEnvironmentHelmExpression(HelmValue helmExpression, string k private void ProcessEnvironmentStringValue(string stringValue, string key, string resourceName) { + if (stringValue.ContainsHelmFlowControlExpression()) + { + stringValue = stringValue.ToQuotedHelmTemplateExpression(); + } + if (stringValue.ContainsHelmValuesSecretExpression()) { var secretExpression = stringValue.ToHelmSecretExpression(resourceName); @@ -534,7 +545,7 @@ private async Task ProcessValueAsync(KubernetesEnvironmentContext contex if (value is ParameterResource param) { - var helmValue = AllocateParameter(param, TargetResource); + var helmValue = AllocateParameter(param, TargetResource, embedded); if (embedded) { AllocateAdditionalParameter(param, helmValue); @@ -657,9 +668,13 @@ private async Task BuildHelmConditional(KubernetesEnvironmentContext con // Pipe through | lower for case-insensitive comparison, matching .NET's // StringComparison.OrdinalIgnoreCase used in other execution/publish paths. var conditionPath = $"({HelmExtensions.ScalarExpressionPattern().Match(paramExpression).Value.Trim()} | lower)"; - var escapedMatch = (expr.MatchValue ?? string.Empty).ToLowerInvariant().Replace("\\", "\\\\").Replace("\"", "\\\""); + var matchValue = System.Text.Json.JsonSerializer.Serialize((expr.MatchValue ?? string.Empty).ToLowerInvariant()); - var ifElseExpression = $"{{{{ if eq {conditionPath} \"{escapedMatch}\" }}}}{whenTrueStr}{{{{ else }}}}{whenFalseStr}{{{{ end }}}}"; + // Keep the flow control raw while expressions are composed. Once the complete environment + // value is known, ProcessEnvironmentHelmExpression or ProcessEnvironmentStringValue wraps + // the whole template in `tpl ... | quote` so nested conditionals remain composable and the + // final output is emitted as one YAML-safe scalar. + var ifElseExpression = $"{{{{ if eq {conditionPath} {matchValue} }}}}{whenTrueStr}{{{{ else }}}}{whenFalseStr}{{{{ end }}}}"; return HelmValue.Literal(ifElseExpression); } @@ -673,7 +688,7 @@ private void AllocateBranchParameters(ReferenceExpression branch) { if (vp is ParameterResource branchParam) { - var helmValue = AllocateParameter(branchParam, TargetResource); + var helmValue = AllocateParameter(branchParam, TargetResource, isEmbedded: true); AllocateAdditionalParameter(branchParam, helmValue); } } @@ -743,7 +758,7 @@ string GetPortSuffix(HelmValue port) } } - private static HelmValue AllocateParameter(ParameterResource parameter, IResource resource) + private static HelmValue AllocateParameter(ParameterResource parameter, IResource resource, bool isEmbedded) { var formattedName = parameter.Name.ToHelmValuesSectionName(); @@ -754,7 +769,11 @@ private static HelmValue AllocateParameter(ParameterResource parameter, IResourc // Always store the parameter reference for deferred resolution. // Secrets and parameters without defaults are resolved at deploy time (not publish time). // ValuesKey preserves the parameter name so values.yaml key matches the Helm expression path. - return new(expression, parameter) { ValuesKey = formattedName }; + return new(expression, parameter) + { + ValuesKey = formattedName, + IsEmbeddedParameter = isEmbedded + }; } private static HelmValue ResolveUnknownValue(IManifestExpressionProvider parameter, IResource resource) @@ -906,6 +925,11 @@ public HelmValue(string expression, ParameterResource parameterSource) /// public string? ValuesKey { get; init; } + /// + /// Gets a value indicating whether this value supplies a parameter embedded in another Helm value. + /// + public bool IsEmbeddedParameter { get; init; } + /// /// Indicates whether the expression contains a Helm secret expression. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs index c2181315655..b0444d777e9 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs @@ -354,10 +354,24 @@ public async Task RenderEmbeddedEnvironmentExpressionsWithHelm() var host = builder.AddParameter("host", "publish-host"); var token = builder.AddParameter("token", "publish-token", secret: true); + var mode = builder.AddParameter("mode", "enabled", publishValueAsDefault: true); + var user = builder.AddParameter("user", "publish-user", publishValueAsDefault: true); + var password = builder.AddParameter("password", "publish-password", secret: true); builder.AddContainer("myapp", "nginx") .WithEnvironment("SOME_URL", $"http://{host}/test") - .WithEnvironment("SECRET_URL", $"http://{host}/test?token={token}"); + .WithEnvironment("SECRET_URL", $"http://{host}/test?token={token}") + .WithEnvironment(context => + { + var options = ReferenceExpression.CreateConditional( + mode.Resource, + "enabled", + ReferenceExpression.Create($"user={user};password={password}"), + ReferenceExpression.Create($"disabled")); + + context.EnvironmentVariables["OPTIONS"] = options; + context.EnvironmentVariables["EMBEDDED_OPTIONS"] = ReferenceExpression.Create($"prefix-{options}-suffix"); + }); builder.AddKubernetesEnvironment("env"); @@ -372,7 +386,10 @@ await auto.RunCommandAsync( await auto.RunCommandAsync( "helm template aspire-app helm-output " + "--set-string config.myapp.host=rendered-host " + - "--set-string secrets.myapp.token=rendered-token > rendered.yaml", + "--set-string secrets.myapp.token=rendered-token " + + "--set-string parameters.myapp.mode=enabled " + + "--set-string config.myapp.user=rendered-user " + + "--set-string 'secrets.myapp.password=rendered: password' > rendered.yaml", counter); await auto.RunCommandAsync( "grep -F 'SOME_URL: \"http://rendered-host/test\"' rendered.yaml", @@ -380,5 +397,11 @@ await auto.RunCommandAsync( await auto.RunCommandAsync( "grep -F 'SECRET_URL: \"http://rendered-host/test?token=rendered-token\"' rendered.yaml", counter); + await auto.RunCommandAsync( + "grep -F 'OPTIONS: \"user=rendered-user;password=rendered: password\"' rendered.yaml", + counter); + await auto.RunCommandAsync( + "grep -F 'EMBEDDED_OPTIONS: \"prefix-user=rendered-user;password=rendered: password-suffix\"' rendered.yaml", + counter); } } diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs index ea4b95d44ad..9f8921e271f 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs @@ -1588,7 +1588,7 @@ public async Task EmbeddedParametersInEnvironmentExpressions_EndToEnd_PublishAnd builder.Services.AddSingleton(mockActivityReporter); var envBuilder = builder.AddKubernetesEnvironment("env"); - var host = builder.AddParameter("host", "localhost"); + var host = builder.AddParameter("host", "localhost", publishValueAsDefault: true); var token = builder.AddParameter("token", "test-token", secret: true); builder.AddContainer("myapp", "nginx") diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index 94e01a415cc..aa9d8413d0b 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -2,10 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIRECOMPUTE002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIREPIPELINES001 using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Kubernetes.Resources; +using Aspire.Hosting.Pipelines; using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; using YamlDotNet.RepresentationModel; using YamlDotNet.Serialization; @@ -902,6 +905,32 @@ public async Task PublishAsync_EmbeddedParametersInEnvironmentExpressionsPopulat await settingsTask; } + [Fact] + public async Task PublishAsync_ConflictingEmbeddedParameterValuesPathReportsError() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + var reporter = new TestPipelineActivityReporter(outputHelper); + builder.Services.AddSingleton(reporter); + + builder.AddKubernetesEnvironment("env"); + + var host = builder.AddParameter("host", "parameter-host", publishValueAsDefault: true); + + builder.AddContainer("myapp", "nginx") + .WithEnvironment("host", "environment-host") + .WithEnvironment("URL", $"http://{host}/test"); + + using var app = builder.Build(); + await app.RunAsync(); + + Assert.Equal(CompletionState.CompletedWithError, reporter.ResultCompletionState); + Assert.Contains( + "Resource 'myapp' maps both environment value 'host' and embedded parameter 'host' " + + "to Helm values path 'config.myapp.host'. Rename one of them so each value has a unique Helm path.", + Assert.IsType(reporter.CompletionMessage)); + } + [Fact] public async Task PublishAsync_CompositeExpressionPreservesExpressionShape() { diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#01.verified.yaml index 21e316b822a..0e5a8943fd4 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#01.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#01.verified.yaml @@ -8,5 +8,5 @@ metadata: app.kubernetes.io/component: "myapp" app.kubernetes.io/instance: "{{ .Release.Name }}" stringData: - OPTIONS: {{ if eq (.Values.parameters.myapp.mode | lower) "enabled" }}user={{ .Values.config.myapp.user }};password={{ .Values.secrets.myapp.password }}{{ else }}user={{ .Values.config.myapp.user }};disabled{{ end }} + OPTIONS: {{ tpl "{{ if eq (.Values.parameters.myapp.mode | lower) \u0022enabled\u0022 }}user={{ .Values.config.myapp.user }};password={{ .Values.secrets.myapp.password }}{{ else }}user={{ .Values.config.myapp.user }};disabled{{ end }}" . | quote }} type: "Opaque" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#05.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#05.verified.yaml index 76601b6c4d1..54e0f083df9 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#05.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#05.verified.yaml @@ -1,4 +1,4 @@ ---- +--- apiVersion: "v1" kind: "ConfigMap" metadata: @@ -8,4 +8,4 @@ metadata: app.kubernetes.io/component: "myapp" app.kubernetes.io/instance: "{{ .Release.Name }}" data: - TLS_SUFFIX: {{ if eq (.Values.parameters.myapp.enable_tls | lower) "true" }}{{ .Values.config.myapp.tls_suffix }}{{ else }},ssl=false{{ end }} + TLS_SUFFIX: {{ tpl "{{ if eq (.Values.parameters.myapp.enable_tls | lower) \u0022true\u0022 }}{{ .Values.config.myapp.tls_suffix }}{{ else }},ssl=false{{ end }}" . | quote }} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#05.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#05.verified.yaml index 8e285faa648..b8709080baa 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#05.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#05.verified.yaml @@ -1,4 +1,4 @@ ---- +--- apiVersion: "v1" kind: "ConfigMap" metadata: @@ -8,4 +8,4 @@ metadata: app.kubernetes.io/component: "myapp" app.kubernetes.io/instance: "{{ .Release.Name }}" data: - TLS_SUFFIX: {{ if eq (.Values.parameters.myapp.enable_tls | lower) "true" }},ssl=true{{ else }},ssl=false{{ end }} + TLS_SUFFIX: {{ tpl "{{ if eq (.Values.parameters.myapp.enable_tls | lower) \u0022true\u0022 }},ssl=true{{ else }},ssl=false{{ end }}" . | quote }} From ced850a9442b4431e5b9071b23655e1669d58a2d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 18 Aug 2026 14:48:53 +1000 Subject: [PATCH 4/6] Address Helm parameter review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a27416ca-ff25-4524-b57f-94daeb337f40 --- .../KubernetesPublishingContext.cs | 30 ++++++------- .../KubernetesResource.cs | 17 ++++--- .../KubernetesDeployTests.cs | 45 +++++++++++++++++++ .../KubernetesPublisherTests.cs | 34 +++++++++++++- ...t_EndToEnd_PublishAndResolve.verified.yaml | 6 +++ 5 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs index b45202e3b7c..43f99c3e617 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs @@ -185,21 +185,25 @@ private void EnsureCapturedHelmValuePlaceholders(KubernetesEnvironmentResource e private async Task AppendResourceContextToHelmValuesAsync(IResource resource, KubernetesResource resourceContext) { - await AddValuesToHelmSectionAsync(resource, resourceContext.Parameters, HelmExtensions.ParametersKey).ConfigureAwait(false); + var parameterItems = MergeHelmValueMappings( + resource, + HelmExtensions.ParametersKey, + (resourceContext.Parameters, "condition parameter")); // Embedded parameters need values.yaml entries for their Helm references, but they must // not become additional environment variables in the generated ConfigMap or Secret. var configItems = MergeHelmValueMappings( resource, HelmExtensions.ConfigKey, - resourceContext.EnvironmentVariables, - resourceContext.AdditionalConfigValues); + (resourceContext.EnvironmentVariables, "environment value"), + (resourceContext.AdditionalConfigValues, "embedded parameter")); var secretItems = MergeHelmValueMappings( resource, HelmExtensions.SecretsKey, - resourceContext.Secrets, - resourceContext.AdditionalSecretValues); + (resourceContext.Secrets, "environment value"), + (resourceContext.AdditionalSecretValues, "embedded parameter")); + await AddValuesToHelmSectionAsync(resource, parameterItems, HelmExtensions.ParametersKey).ConfigureAwait(false); await AddValuesToHelmSectionAsync(resource, configItems, HelmExtensions.ConfigKey).ConfigureAwait(false); await AddValuesToHelmSectionAsync(resource, secretItems, HelmExtensions.SecretsKey).ConfigureAwait(false); } @@ -207,23 +211,15 @@ private async Task AppendResourceContextToHelmValuesAsync(IResource resource, Ku private static Dictionary MergeHelmValueMappings( IResource resource, string helmKey, - IReadOnlyDictionary environmentValues, - IReadOnlyDictionary embeddedParameters) + params (IReadOnlyDictionary Values, string OriginKind)[] mappingGroups) { var resourceKey = resource.Name.ToHelmValuesSectionName(); var result = new Dictionary(StringComparer.Ordinal); var origins = new Dictionary(StringComparer.Ordinal); - AddMappings(environmentValues, "environment value"); - AddMappings(embeddedParameters, "embedded parameter"); - - return result; - - void AddMappings( - IReadOnlyDictionary mappings, - string originKind) + foreach (var (values, originKind) in mappingGroups) { - foreach (var (key, value) in mappings) + foreach (var (key, value) in values) { var valuesKey = value.ValuesKey ?? key.ToHelmValuesSectionName(); var origin = $"{originKind} '{key}'"; @@ -252,6 +248,8 @@ void AddMappings( "so each value has a unique Helm path."); } } + + return result; } private async Task AddValuesToHelmSectionAsync( diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index 63d29321663..48ead32c996 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -653,12 +653,13 @@ private async Task BuildHelmConditional(KubernetesEnvironmentContext con var formattedName = conditionParam.Name.ToHelmValuesSectionName(); var paramExpression = formattedName.ToHelmParameterExpression(TargetResource.Name); - if (!Parameters.ContainsKey(formattedName)) + // Keep the original parameter name as the dictionary key so names that normalize to the + // same Helm key remain distinct until publishing can report the collision. + Parameters.TryAdd(conditionParam.Name, new HelmValue(paramExpression, conditionParam) { - Parameters[formattedName] = conditionParam.Default is null || conditionParam.Secret - ? new HelmValue(paramExpression, (string?)null) - : new HelmValue(paramExpression, conditionParam); - } + ValuesKey = formattedName, + IsEmbeddedParameter = true + }); // Ensure parameter values referenced in branches are populated in values.yaml. AllocateBranchParameters(expr.WhenTrue!); @@ -699,9 +700,11 @@ private void AllocateBranchParameters(ReferenceExpression branch) /// private void AllocateAdditionalParameter(ParameterResource parameter, HelmValue helmValue) { - var key = parameter.Name.ToHelmValuesSectionName(); var values = parameter.Secret ? AdditionalSecretValues : AdditionalConfigValues; - values.TryAdd(key, helmValue); + + // Keep the original parameter name as the dictionary key so names that normalize to the + // same Helm key remain distinct until publishing can report the collision. + values.TryAdd(parameter.Name, helmValue); } private static string GetEndpointValue(EndpointMapping mapping, EndpointProperty property, bool embedded = false) diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs index 9f8921e271f..b7a38dfb7fa 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs @@ -1617,6 +1617,51 @@ await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync( await Verify(await File.ReadAllTextAsync(overridePath), "yaml"); } + [Fact] + public async Task ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var builder = TestDistributedApplicationBuilder.Create( + DistributedApplicationOperation.Publish, + workspace.Path, + step: WellKnownPipelineSteps.Publish); + var mockActivityReporter = new TestPipelineActivityReporter(outputHelper); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(mockActivityReporter); + builder.Configuration["Parameters:enable-tls"] = bool.TrueString; + + var envBuilder = builder.AddKubernetesEnvironment("env"); + var enableTls = builder.AddParameter("enable-tls"); + + builder.AddContainer("myapp", "nginx") + .WithEnvironment(context => + { + context.EnvironmentVariables["TLS_SUFFIX"] = ReferenceExpression.CreateConditional( + enableTls.Resource, + bool.TrueString, + ReferenceExpression.Create($",ssl=true"), + ReferenceExpression.Create($",ssl=false")); + }); + + using var app = builder.Build(); + var env = envBuilder.Resource; + await app.RunAsync(); + + Assert.Contains(env.CapturedHelmValues, captured => + captured.Section == "parameters" && + captured.ResourceKey == "myapp" && + captured.ValueKey == "enable_tls" && + captured.Parameter == enableTls.Resource); + + await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync( + workspace.Path, env, CancellationToken.None); + + var overridePath = Path.Combine(workspace.Path, HelmDeploymentEngine.GetDeployValuesFileName("env")); + await Verify(await File.ReadAllTextAsync(overridePath), "yaml"); + } + [Fact] public async Task DeferredValueProvider_EndToEnd_PublishAndResolve() { diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index aa9d8413d0b..206f98c422c 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -931,6 +931,32 @@ public async Task PublishAsync_ConflictingEmbeddedParameterValuesPathReportsErro Assert.IsType(reporter.CompletionMessage)); } + [Fact] + public async Task PublishAsync_EmbeddedParametersWithSameNormalizedValuesPathReportError() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + var reporter = new TestPipelineActivityReporter(outputHelper); + builder.Services.AddSingleton(reporter); + + builder.AddKubernetesEnvironment("env"); + + var dashedHost = builder.AddParameter("api-host", "dashed-host", publishValueAsDefault: true); + var underscoredHost = new ParameterResource("api_host", _ => "underscored-host"); + + builder.AddContainer("myapp", "nginx") + .WithEnvironment("URL", $"http://{dashedHost}/{underscoredHost}"); + + using var app = builder.Build(); + await app.RunAsync(); + + Assert.Equal(CompletionState.CompletedWithError, reporter.ResultCompletionState); + Assert.Contains( + "Resource 'myapp' maps both embedded parameter 'api-host' and embedded parameter 'api_host' " + + "to Helm values path 'config.myapp.api_host'. Rename one of them so each value has a unique Helm path.", + Assert.IsType(reporter.CompletionMessage)); + } + [Fact] public async Task PublishAsync_CompositeExpressionPreservesExpressionShape() { @@ -1138,7 +1164,7 @@ public async Task PublishAsync_HandlesConditionalReferenceExpressionWithParamete using var workspace = TemporaryWorkspace.Create(outputHelper); var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); - builder.AddKubernetesEnvironment("env"); + var environment = builder.AddKubernetesEnvironment("env"); // Use a real ParameterResource as the condition with a known default value. var enableTls = builder.AddParameter("enable-tls", "True", publishValueAsDefault: true); @@ -1158,6 +1184,12 @@ public async Task PublishAsync_HandlesConditionalReferenceExpressionWithParamete var app = builder.Build(); app.Run(); + Assert.Contains(environment.Resource.CapturedHelmValues, captured => + captured.Section == "parameters" && + captured.ResourceKey == "myapp" && + captured.ValueKey == "enable_tls" && + captured.Parameter == enableTls.Resource); + var expectedFiles = new[] { "Chart.yaml", diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml new file mode 100644 index 00000000000..f31bdad795e --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml @@ -0,0 +1,6 @@ +parameters: + myapp: + enable_tls: True +config: + myapp: + TLS_SUFFIX: '{{ tpl "{{ if eq (.Values.parameters.myapp.enable_tls | lower) \u0022true\u0022 }},ssl=true{{ else }},ssl=false{{ end }}" . | quote }}' From 072616b81978abde87d3305a90b4ef66bf2f49a6 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 18 Aug 2026 15:22:36 +1000 Subject: [PATCH 5/6] Handle Helm conditional parameter edge cases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a27416ca-ff25-4524-b57f-94daeb337f40 --- .../KubernetesResource.cs | 51 +++++++++++++--- .../KubernetesPublishTests.cs | 13 ++++ .../KubernetesPublisherTests.cs | 60 +++++++++++++++++++ ...t_EndToEnd_PublishAndResolve.verified.yaml | 2 +- ...CaptureEmbeddedParameters#01.verified.yaml | 2 +- ...erBranch_UsesIfElseSyntax#05.verified.yaml | 2 +- ...ionWithParameterCondition#05.verified.yaml | 2 +- 7 files changed, 121 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index 48ead32c996..f0b5a062f12 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -655,20 +655,26 @@ private async Task BuildHelmConditional(KubernetesEnvironmentContext con // Keep the original parameter name as the dictionary key so names that normalize to the // same Helm key remain distinct until publishing can report the collision. - Parameters.TryAdd(conditionParam.Name, new HelmValue(paramExpression, conditionParam) + var conditionValue = new HelmValue(paramExpression, conditionParam) { ValuesKey = formattedName, IsEmbeddedParameter = true - }); + }; + AddParameterMapping( + Parameters, + conditionParam, + conditionValue, + HelmExtensions.ParametersKey, + "condition parameter"); // Ensure parameter values referenced in branches are populated in values.yaml. AllocateBranchParameters(expr.WhenTrue!); AllocateBranchParameters(expr.WhenFalse!); - // Extract the values path (e.g., .Values.parameters.myapp.enable_tls) from {{ expression }}. - // Pipe through | lower for case-insensitive comparison, matching .NET's - // StringComparison.OrdinalIgnoreCase used in other execution/publish paths. - var conditionPath = $"({HelmExtensions.ScalarExpressionPattern().Match(paramExpression).Value.Trim()} | lower)"; + // Deploy override YAML can parse values such as "True" as booleans. Convert the value back + // to a string before the case-insensitive comparison so both string and boolean values work. + // See https://helm.sh/docs/chart_template_guide/function_list/#type-conversion-functions. + var conditionPath = $"({HelmExtensions.ScalarExpressionPattern().Match(paramExpression).Value.Trim()} | toString | lower)"; var matchValue = System.Text.Json.JsonSerializer.Serialize((expr.MatchValue ?? string.Empty).ToLowerInvariant()); // Keep the flow control raw while expressions are composed. Once the complete environment @@ -704,7 +710,38 @@ private void AllocateAdditionalParameter(ParameterResource parameter, HelmValue // Keep the original parameter name as the dictionary key so names that normalize to the // same Helm key remain distinct until publishing can report the collision. - values.TryAdd(parameter.Name, helmValue); + AddParameterMapping( + values, + parameter, + helmValue, + parameter.Secret ? HelmExtensions.SecretsKey : HelmExtensions.ConfigKey, + "embedded parameter"); + } + + private void AddParameterMapping( + Dictionary mappings, + ParameterResource parameter, + HelmValue helmValue, + string helmKey, + string sourceKind) + { + if (!mappings.TryGetValue(parameter.Name, out var existing)) + { + mappings.Add(parameter.Name, helmValue); + return; + } + + if (ReferenceEquals(existing.ParameterSource, parameter)) + { + return; + } + + var resourceKey = TargetResource.Name.ToHelmValuesSectionName(); + var valuesKey = helmValue.ValuesKey ?? parameter.Name.ToHelmValuesSectionName(); + throw new InvalidOperationException( + $"Resource '{TargetResource.Name}' maps multiple distinct {sourceKind} sources named '{parameter.Name}' " + + $"to Helm values path '{helmKey}.{resourceKey}.{valuesKey}'. Reuse the same ParameterResource instance " + + "or give each source a unique name."); } private static string GetEndpointValue(EndpointMapping mapping, EndpointProperty property, bool embedded = false) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs index b0444d777e9..bb611bbfef8 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs @@ -355,6 +355,7 @@ public async Task RenderEmbeddedEnvironmentExpressionsWithHelm() var host = builder.AddParameter("host", "publish-host"); var token = builder.AddParameter("token", "publish-token", secret: true); var mode = builder.AddParameter("mode", "enabled", publishValueAsDefault: true); + var enableTls = builder.AddParameter("enable-tls", "False", publishValueAsDefault: true); var user = builder.AddParameter("user", "publish-user", publishValueAsDefault: true); var password = builder.AddParameter("password", "publish-password", secret: true); @@ -371,6 +372,11 @@ public async Task RenderEmbeddedEnvironmentExpressionsWithHelm() context.EnvironmentVariables["OPTIONS"] = options; context.EnvironmentVariables["EMBEDDED_OPTIONS"] = ReferenceExpression.Create($"prefix-{options}-suffix"); + context.EnvironmentVariables["TLS_SUFFIX"] = ReferenceExpression.CreateConditional( + enableTls.Resource, + bool.TrueString, + ReferenceExpression.Create($",ssl=true"), + ReferenceExpression.Create($",ssl=false")); }); builder.AddKubernetesEnvironment("env"); @@ -383,8 +389,12 @@ await auto.RunCommandAsync( "aspire publish -o helm-output --non-interactive", counter, TimeSpan.FromMinutes(5)); + await auto.RunCommandAsync( + "printf 'parameters:\\n myapp:\\n enable_tls: True\\n' > deploy-values.yaml", + counter); await auto.RunCommandAsync( "helm template aspire-app helm-output " + + "--values deploy-values.yaml " + "--set-string config.myapp.host=rendered-host " + "--set-string secrets.myapp.token=rendered-token " + "--set-string parameters.myapp.mode=enabled " + @@ -394,6 +404,9 @@ await auto.RunCommandAsync( await auto.RunCommandAsync( "grep -F 'SOME_URL: \"http://rendered-host/test\"' rendered.yaml", counter); + await auto.RunCommandAsync( + "grep -F 'TLS_SUFFIX: \",ssl=true\"' rendered.yaml", + counter); await auto.RunCommandAsync( "grep -F 'SECRET_URL: \"http://rendered-host/test?token=rendered-token\"' rendered.yaml", counter); diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index 206f98c422c..866b303ce35 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -957,6 +957,66 @@ public async Task PublishAsync_EmbeddedParametersWithSameNormalizedValuesPathRep Assert.IsType(reporter.CompletionMessage)); } + [Fact] + public async Task PublishAsync_DistinctEmbeddedParameterSourcesWithSameNameReportError() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + + builder.AddKubernetesEnvironment("env"); + + var firstHost = new ParameterResource("host", _ => "first-host"); + var secondHost = new ParameterResource("host", _ => "second-host"); + + builder.AddContainer("myapp", "nginx") + .WithEnvironment("URL", $"http://{firstHost}/{secondHost}"); + + using var app = builder.Build(); + var exception = await Assert.ThrowsAsync(() => app.RunAsync()); + + Assert.Contains( + "Resource 'myapp' maps multiple distinct embedded parameter sources named 'host' " + + "to Helm values path 'config.myapp.host'. Reuse the same ParameterResource instance " + + "or give each source a unique name.", + exception.ToString()); + } + + [Fact] + public async Task PublishAsync_DistinctConditionalParameterSourcesWithSameNameReportError() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + + builder.AddKubernetesEnvironment("env"); + + var firstCondition = new ParameterResource("enable-tls", _ => bool.TrueString); + var secondCondition = new ParameterResource("enable-tls", _ => bool.FalseString); + + builder.AddContainer("myapp", "nginx") + .WithEnvironment(context => + { + context.EnvironmentVariables["FIRST"] = ReferenceExpression.CreateConditional( + firstCondition, + bool.TrueString, + ReferenceExpression.Create($"enabled"), + ReferenceExpression.Create($"disabled")); + context.EnvironmentVariables["SECOND"] = ReferenceExpression.CreateConditional( + secondCondition, + bool.TrueString, + ReferenceExpression.Create($"enabled"), + ReferenceExpression.Create($"disabled")); + }); + + using var app = builder.Build(); + var exception = await Assert.ThrowsAsync(() => app.RunAsync()); + + Assert.Contains( + "Resource 'myapp' maps multiple distinct condition parameter sources named 'enable-tls' " + + "to Helm values path 'parameters.myapp.enable_tls'. Reuse the same ParameterResource instance " + + "or give each source a unique name.", + exception.ToString()); + } + [Fact] public async Task PublishAsync_CompositeExpressionPreservesExpressionShape() { diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml index f31bdad795e..8f2463d964e 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml @@ -3,4 +3,4 @@ enable_tls: True config: myapp: - TLS_SUFFIX: '{{ tpl "{{ if eq (.Values.parameters.myapp.enable_tls | lower) \u0022true\u0022 }},ssl=true{{ else }},ssl=false{{ end }}" . | quote }}' + TLS_SUFFIX: '{{ tpl "{{ if eq (.Values.parameters.myapp.enable_tls | toString | lower) \u0022true\u0022 }},ssl=true{{ else }},ssl=false{{ end }}" . | quote }}' diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#01.verified.yaml index 0e5a8943fd4..d46b2fdf36a 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#01.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalBranchesCaptureEmbeddedParameters#01.verified.yaml @@ -8,5 +8,5 @@ metadata: app.kubernetes.io/component: "myapp" app.kubernetes.io/instance: "{{ .Release.Name }}" stringData: - OPTIONS: {{ tpl "{{ if eq (.Values.parameters.myapp.mode | lower) \u0022enabled\u0022 }}user={{ .Values.config.myapp.user }};password={{ .Values.secrets.myapp.password }}{{ else }}user={{ .Values.config.myapp.user }};disabled{{ end }}" . | quote }} + OPTIONS: {{ tpl "{{ if eq (.Values.parameters.myapp.mode | toString | lower) \u0022enabled\u0022 }}user={{ .Values.config.myapp.user }};password={{ .Values.secrets.myapp.password }}{{ else }}user={{ .Values.config.myapp.user }};disabled{{ end }}" . | quote }} type: "Opaque" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#05.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#05.verified.yaml index 54e0f083df9..f7e9b65df87 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#05.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_ConditionalWithParameterBranch_UsesIfElseSyntax#05.verified.yaml @@ -8,4 +8,4 @@ metadata: app.kubernetes.io/component: "myapp" app.kubernetes.io/instance: "{{ .Release.Name }}" data: - TLS_SUFFIX: {{ tpl "{{ if eq (.Values.parameters.myapp.enable_tls | lower) \u0022true\u0022 }}{{ .Values.config.myapp.tls_suffix }}{{ else }},ssl=false{{ end }}" . | quote }} + TLS_SUFFIX: {{ tpl "{{ if eq (.Values.parameters.myapp.enable_tls | toString | lower) \u0022true\u0022 }}{{ .Values.config.myapp.tls_suffix }}{{ else }},ssl=false{{ end }}" . | quote }} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#05.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#05.verified.yaml index b8709080baa..035d8dfec8f 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#05.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#05.verified.yaml @@ -8,4 +8,4 @@ metadata: app.kubernetes.io/component: "myapp" app.kubernetes.io/instance: "{{ .Release.Name }}" data: - TLS_SUFFIX: {{ tpl "{{ if eq (.Values.parameters.myapp.enable_tls | lower) \u0022true\u0022 }},ssl=true{{ else }},ssl=false{{ end }}" . | quote }} + TLS_SUFFIX: {{ tpl "{{ if eq (.Values.parameters.myapp.enable_tls | toString | lower) \u0022true\u0022 }},ssl=true{{ else }},ssl=false{{ end }}" . | quote }} From 2d435ddc68f32ec06000490bfc50045de215f3eb Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 19 Aug 2026 13:43:50 +1000 Subject: [PATCH 6/6] Preserve string values in Helm overrides Quote deploy-time YAML strings so Helm cannot reinterpret numeric- or boolean-looking parameter values. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a27416ca-ff25-4524-b57f-94daeb337f40 --- .../Deployment/HelmDeploymentEngine.cs | 4 ++++ .../KubernetesDeployTests.cs | 14 ++++++++++---- ...efault_EndToEnd_PublishAndResolve.verified.yaml | 4 ++-- ...der_EndToEnd_PublishAndResolve#02.verified.yaml | 2 +- ...ssions_EndToEnd_PublishAndResolve.verified.yaml | 8 ++++---- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index c0e602dcf90..644dfc68d96 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -13,6 +13,7 @@ using Aspire.Hosting.Kubernetes.Extensions; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Utils; +using Aspire.Hosting.Yaml; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -352,6 +353,9 @@ internal static async Task ResolveAndWriteDeployValuesAsync( if (overrideValues.Count > 0) { var serializer = new YamlDotNet.Serialization.SerializerBuilder() + // Parameter values are strings. Quote them so Helm does not reinterpret values such + // as "01", "1.0", or "True" as numeric or boolean YAML scalars. + .WithEventEmitter(nextEmitter => new ForceQuotedStringsEventEmitter(nextEmitter)) .WithNewLine("\n") .Build(); var overrideContent = serializer.Serialize(overrideValues); diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs index b7a38dfb7fa..0901b6aa13c 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs @@ -1507,6 +1507,10 @@ await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync( outputHelper.WriteLine("=== Override file ==="); outputHelper.WriteLine(content); + // Snapshot tests cover YAML scalar style. These assertions only verify that resolution + // populated every path, so ignore the quotes required to preserve string values for Helm. + content = content.Replace("\"", string.Empty, StringComparison.Ordinal); + // Phase 1: Both cache and server should have the resolved password Assert.Contains("cache_password: test-password-123", content); @@ -1588,8 +1592,10 @@ public async Task EmbeddedParametersInEnvironmentExpressions_EndToEnd_PublishAnd builder.Services.AddSingleton(mockActivityReporter); var envBuilder = builder.AddKubernetesEnvironment("env"); - var host = builder.AddParameter("host", "localhost", publishValueAsDefault: true); - var token = builder.AddParameter("token", "test-token", secret: true); + // Numeric-looking strings must retain their lexical form when the deploy values file is + // parsed by Helm instead of being normalized as numeric YAML scalars. + var host = builder.AddParameter("host", "01", publishValueAsDefault: true); + var token = builder.AddParameter("token", "1.0", secret: true); builder.AddContainer("myapp", "nginx") .WithEnvironment("SOME_URL", $"http://{host}/test") @@ -1630,7 +1636,7 @@ public async Task ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve( builder.Services.AddSingleton(); builder.Services.AddSingleton(mockActivityReporter); - builder.Configuration["Parameters:enable-tls"] = bool.TrueString; + builder.Configuration["Parameters:enable-tls"] = "1.0"; var envBuilder = builder.AddKubernetesEnvironment("env"); var enableTls = builder.AddParameter("enable-tls"); @@ -1640,7 +1646,7 @@ public async Task ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve( { context.EnvironmentVariables["TLS_SUFFIX"] = ReferenceExpression.CreateConditional( enableTls.Resource, - bool.TrueString, + "1.0", ReferenceExpression.Create($",ssl=true"), ReferenceExpression.Create($",ssl=false")); }); diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml index 8f2463d964e..ea89791d03d 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml @@ -1,6 +1,6 @@ parameters: myapp: - enable_tls: True + enable_tls: "1.0" config: myapp: - TLS_SUFFIX: '{{ tpl "{{ if eq (.Values.parameters.myapp.enable_tls | toString | lower) \u0022true\u0022 }},ssl=true{{ else }},ssl=false{{ end }}" . | quote }}' + TLS_SUFFIX: "{{ tpl \"{{ if eq (.Values.parameters.myapp.enable_tls | toString | lower) \\u00221.0\\u0022 }},ssl=true{{ else }},ssl=false{{ end }}\" . | quote }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#02.verified.yaml index 3817d794388..8f7d054ca59 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#02.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.DeferredValueProvider_EndToEnd_PublishAndResolve#02.verified.yaml @@ -1,3 +1,3 @@ config: myapp: - DEFERRED_VALUE: resolved-value + DEFERRED_VALUE: "resolved-value" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.EmbeddedParametersInEnvironmentExpressions_EndToEnd_PublishAndResolve.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.EmbeddedParametersInEnvironmentExpressions_EndToEnd_PublishAndResolve.verified.yaml index ca6b838750e..56214983306 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.EmbeddedParametersInEnvironmentExpressions_EndToEnd_PublishAndResolve.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.EmbeddedParametersInEnvironmentExpressions_EndToEnd_PublishAndResolve.verified.yaml @@ -1,8 +1,8 @@ config: myapp: - host: localhost - SOME_URL: http://localhost/test + host: "01" + SOME_URL: "http://01/test" secrets: myapp: - token: test-token - SECRET_URL: http://localhost/test?token=test-token + token: "1.0" + SECRET_URL: "http://01/test?token=1.0"