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 ProcessValueAsync(KubernetesEnvironmentContext contex if (value is ParameterResource param) { - return AllocateParameter(param, TargetResource); + var helmValue = AllocateParameter(param, TargetResource, embedded); + if (embedded) + { + AllocateAdditionalParameter(param, helmValue); + } + + return helmValue; } if (value is ConnectionStringReference cs) @@ -629,31 +653,41 @@ 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. + var conditionValue = 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 + }; + 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)"; - var escapedMatch = (expr.MatchValue ?? string.Empty).ToLowerInvariant().Replace("\\", "\\\\").Replace("\"", "\\\""); - - var ifElseExpression = $"{{{{ if eq {conditionPath} \"{escapedMatch}\" }}}}{whenTrueStr}{{{{ else }}}}{whenFalseStr}{{{{ end }}}}"; + // 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 + // 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); } /// /// 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) { @@ -661,24 +695,55 @@ 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); - } + var helmValue = AllocateParameter(branchParam, TargetResource, isEmbedded: true); + AllocateAdditionalParameter(branchParam, helmValue); } } } + /// + /// Allocates an embedded parameter without adding a synthetic environment variable. + /// + private void AllocateAdditionalParameter(ParameterResource parameter, HelmValue helmValue) + { + var values = parameter.Secret ? AdditionalSecretValues : AdditionalConfigValues; + + // 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. + 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) { var (scheme, _, host, targetPort, _, _, exposedPort) = mapping; @@ -733,7 +798,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(); @@ -744,7 +809,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) @@ -896,6 +965,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 146a26ea583..bb611bbfef8 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs @@ -311,4 +311,110 @@ 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); + 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); + + builder.AddContainer("myapp", "nginx") + .WithEnvironment("SOME_URL", $"http://{host}/test") + .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"); + context.EnvironmentVariables["TLS_SUFFIX"] = ReferenceExpression.CreateConditional( + enableTls.Resource, + bool.TrueString, + ReferenceExpression.Create($",ssl=true"), + ReferenceExpression.Create($",ssl=false")); + }); + + 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( + "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 " + + "--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", + 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); + 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 877570b8647..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); @@ -1573,6 +1577,141 @@ 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"); + // 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") + .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 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"] = "1.0"; + + 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, + "1.0", + 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() + { + 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 70276c6c08e..866b303ce35 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; @@ -855,6 +858,254 @@ 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_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_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_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() + { + 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() { @@ -867,7 +1118,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); @@ -875,7 +1126,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")); @@ -917,13 +1168,64 @@ public async Task PublishAsync_HandlesConditionalReferenceExpression() } [Fact] - public async Task PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition() + 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() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + + 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); @@ -942,6 +1244,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", @@ -1590,17 +1898,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.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml new file mode 100644 index 00000000000..ea89791d03d --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve.verified.yaml @@ -0,0 +1,6 @@ +parameters: + myapp: + enable_tls: "1.0" +config: + myapp: + 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#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..8f7d054ca59 --- /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/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..56214983306 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesDeployTests.EmbeddedParametersInEnvironmentExpressions_EndToEnd_PublishAndResolve.verified.yaml @@ -0,0 +1,8 @@ +config: + myapp: + host: "01" + SOME_URL: "http://01/test" +secrets: + myapp: + token: "1.0" + SECRET_URL: "http://01/test?token=1.0" 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..d46b2fdf36a --- /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: {{ 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 76601b6c4d1..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 @@ -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 | 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_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_HandlesConditionalReferenceExpressionWithParameterCondition#05.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition#05.verified.yaml index 8e285faa648..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 @@ -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 | toString | lower) \u0022true\u0022 }},ssl=true{{ else }},ssl=false{{ end }}" . | quote }} 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: "" 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); +}