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/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 cfdd39ed6f6..43f99c3e617 100644
--- a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs
+++ b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs
@@ -185,18 +185,71 @@ 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, "environment value"),
+ (resourceContext.AdditionalConfigValues, "embedded parameter"));
+ var secretItems = MergeHelmValueMappings(
+ resource,
+ HelmExtensions.SecretsKey,
+ (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);
+ }
+
+ private static Dictionary MergeHelmValueMappings(
+ IResource resource,
+ string helmKey,
+ params (IReadOnlyDictionary Values, string OriginKind)[] mappingGroups)
+ {
+ var resourceKey = resource.Name.ToHelmValuesSectionName();
+ var result = new Dictionary(StringComparer.Ordinal);
+ var origins = new Dictionary(StringComparer.Ordinal);
- // Merge AdditionalConfigValues (e.g., branch parameters from if/else conditionals)
- // into a combined dictionary for the config section of values.yaml.
- var configItems = new Dictionary(resourceContext.EnvironmentVariables);
- foreach (var kvp in resourceContext.AdditionalConfigValues)
+ foreach (var (values, originKind) in mappingGroups)
{
- configItems.TryAdd(kvp.Key, kvp.Value);
+ foreach (var (key, value) in values)
+ {
+ 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.");
+ }
}
- await AddValuesToHelmSectionAsync(resource, configItems, HelmExtensions.ConfigKey).ConfigureAwait(false);
- await AddValuesToHelmSectionAsync(resource, resourceContext.Secrets, HelmExtensions.SecretsKey).ConfigureAwait(false);
+ return result;
}
private async Task AddValuesToHelmSectionAsync(
@@ -242,19 +295,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 ebeccf84d10..f0b5a062f12 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; } = [];
@@ -438,8 +439,20 @@ 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 }:
+ // 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;
@@ -451,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);
@@ -527,7 +545,13 @@ private async Task