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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
25 changes: 21 additions & 4 deletions src/Aspire.Hosting.Kubernetes/Extensions/HelmExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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);

/// <summary>
/// Evaluates a string as a Helm template and quotes the complete result as a YAML scalar.
/// </summary>
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);
}
Expand Down Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ public sealed class KubernetesEnvironmentResource : Resource, IComputeEnvironmen

/// <summary>
/// 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.
/// </summary>
internal List<CapturedHelmValue> CapturedHelmValues { get; } = [];

Expand Down
92 changes: 78 additions & 14 deletions src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, KubernetesResource.HelmValue> MergeHelmValueMappings(
IResource resource,
string helmKey,
params (IReadOnlyDictionary<string, KubernetesResource.HelmValue> Values, string OriginKind)[] mappingGroups)
{
var resourceKey = resource.Name.ToHelmValuesSectionName();
var result = new Dictionary<string, KubernetesResource.HelmValue>(StringComparer.Ordinal);
var origins = new Dictionary<string, string>(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<string, KubernetesResource.HelmValue>(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(
Expand Down Expand Up @@ -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
{
Expand Down
Loading
Loading