// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#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 ASPIREPROJECTS001 // ProjectLaunchDefaultsAnnotation is experimental.
using System.Globalization;
using System.Net.Sockets;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Kubernetes.Annotations;
using Aspire.Hosting.Kubernetes.Extensions;
using Aspire.Hosting.Kubernetes.Resources;
using Aspire.Hosting.Pipelines;
namespace Aspire.Hosting.Kubernetes;
/// <summary>
/// Represents a compute resource for Kubernetes.
/// </summary>
[AspireExport(ExposeProperties = true)]
public partial class KubernetesResource(string name, IResource resource, KubernetesEnvironmentResource kubernetesEnvironmentResource) : Resource(name), IResourceWithParent<KubernetesEnvironmentResource>
{
private const long DefaultPersistentVolumeFsGroup = 2000;
private const string DefaultPersistentVolumeFsGroupChangePolicy = "OnRootMismatch";
/// <inheritdoc/>
public KubernetesEnvironmentResource Parent => kubernetesEnvironmentResource;
internal void AddPrintSummaryStep()
{
Annotations.Add(new PipelineStepAnnotation(_ =>
{
var printResourceSummary = new PipelineStep
{
Name = $"print-{resource.Name}-summary",
Description = $"Retrieves deployment status for {resource.Name}.",
Action = async ctx => await HelmDeploymentEngine.PrintResourceSummaryAsync(ctx, kubernetesEnvironmentResource, resource, this).ConfigureAwait(false),
Tags = [HelmDeploymentEngine.PrintSummaryTag],
RequiredBySteps = [WellKnownPipelineSteps.Deploy]
};
return new List<PipelineStep> { printResourceSummary };
}));
}
internal record EndpointMapping(string Scheme, string Protocol, string Host, HelmValue Port, string Name, string? HelmExpression = null, HelmValue? ServicePort = null);
internal Dictionary<string, EndpointMapping> EndpointMappings { get; } = [];
internal Dictionary<string, HelmValue> EnvironmentVariables { get; } = [];
internal Dictionary<string, HelmValue> Secrets { get; } = [];
internal Dictionary<string, HelmValue> Parameters { get; } = [];
internal Dictionary<string, HelmValue> AdditionalConfigValues { get; } = [];
internal Dictionary<string, HelmValue> AdditionalSecretValues { get; } = [];
internal Dictionary<string, string> Labels { get; private set; } = [];
internal List<string> Commands { get; } = [];
internal List<VolumeMountV1> Volumes { get; } = [];
internal List<PersistentVolumeClaim> PersistentVolumeClaims { get; } = [];
#pragma warning disable ASPIREPROBES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
internal List<(ProbeType Type, ProbeV1 Probe)> Probes { get; } = [];
#pragma warning restore ASPIREPROBES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
/// <summary>
/// </summary>
public Workload? Workload { get; set; }
/// <summary>
/// Gets or sets the Kubernetes ConfigMap associated with this resource.
/// </summary>
public ConfigMap? ConfigMap { get; set; }
/// <summary>
/// Gets or sets the Kubernetes Secret associated with this resource.
/// </summary>
public Secret? Secret { get; set; }
/// <summary>
/// Gets or sets the Kubernetes Service associated with this resource.
/// </summary>
public Service? Service { get; set; }
/// <summary>
/// Additional resources that are part of this Kubernetes service.
/// </summary>
[AspireExportIgnore(Reason = "Kubernetes manifest resource types are C#-only customization objects and are not part of the polyglot SDK surface.")]
public List<BaseKubernetesResource> AdditionalResources { get; } = [];
/// <summary>
/// Adds an arbitrary Kubernetes manifest to this service's generated Helm chart for polyglot callers.
/// </summary>
/// <param name="apiVersion">The Kubernetes API version for the manifest.</param>
/// <param name="kind">The Kubernetes resource kind for the manifest.</param>
/// <param name="name">The Kubernetes metadata name for the manifest.</param>
/// <param name="configure">A callback that configures the manifest fields.</param>
/// <returns>The added Kubernetes manifest resource.</returns>
[AspireExport(RunSyncOnBackgroundThread = true)]
internal KubernetesManifestResource AddManifest(string apiVersion, string kind, string name, Action<KubernetesManifestResource>? configure = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(apiVersion);
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
var manifest = new KubernetesManifestResource(apiVersion, kind, name);
configure?.Invoke(manifest);
AdditionalResources.Add(manifest);
return manifest;
}
/// <summary>
/// Gets the resource that is the target of this Kubernetes service.
/// </summary>
internal IResource TargetResource => resource;
internal IEnumerable<BaseKubernetesResource> GetTemplatedResources()
{
if (Workload is not null)
{
yield return Workload;
}
if (ConfigMap is not null)
{
yield return ConfigMap;
}
if (Secret is not null)
{
yield return Secret;
}
if (Service is not null)
{
yield return Service;
}
foreach (var volumeClaim in PersistentVolumeClaims)
{
yield return volumeClaim;
}
foreach (var resource in AdditionalResources)
{
foreach (var label in Labels)
{
resource.Metadata.Labels.TryAdd(label.Key, label.Value);
}
yield return resource;
}
}
private void BuildKubernetesResources()
{
SetLabels();
CreateApplication();
ConfigMap = resource.ToConfigMap(this);
Secret = resource.ToSecret(this);
Service = resource.ToService(this);
}
private void SetLabels()
{
Labels = new()
{
["app.kubernetes.io/name"] = ".Chart.Name".ToHelmExpression(),
["app.kubernetes.io/component"] = resource.Name,
["app.kubernetes.io/instance"] = ".Release.Name".ToHelmExpression(),
};
}
private void CreateApplication()
{
var hasPersistentVolumeBinding = resource.HasAnnotationOfType<KubernetesPersistentVolumeBindingAnnotation>();
// Promote to a StatefulSet when the workload is bound to a first-class persistent
// volume — Kubernetes requires stable identity and ordered rollout for pods that
// share named PVCs. The historical IResourceWithConnectionString rule remains so
// existing integrations that imply state continue to render as StatefulSets.
if (resource is IResourceWithConnectionString || hasPersistentVolumeBinding)
{
Workload = resource.ToStatefulSet(this);
}
else
{
Workload = resource.ToDeployment(this);
}
if (hasPersistentVolumeBinding)
{
// fsGroup is a supplemental group, not the image's primary GID. A stable
// publisher-owned value lets non-root images access supported volumes without
// coupling the manifest to image-specific identities. Kubernetes customization
// callbacks run after this default is applied and can replace or remove it.
var securityContext = Workload.PodTemplate.Spec.SecurityContext ??= new();
securityContext.FsGroup ??= DefaultPersistentVolumeFsGroup;
securityContext.FsGroupChangePolicy ??= DefaultPersistentVolumeFsGroupChangePolicy;
}
}
internal string GetContainerImageName(IResource resourceInstance)
{
if (!resourceInstance.TryGetLastAnnotation<DockerfileBuildAnnotation>(out _) && resourceInstance is not ProjectResource)
{
if (resourceInstance.TryGetContainerImageName(out var containerImageName))
{
return containerImageName;
}
}
var imageEnvName = $"{resourceInstance.Name.ToHelmValuesSectionName()}_image";
var value = $"{resourceInstance.Name}:latest";
var expression = new HelmValue(imageEnvName.ToHelmParameterExpression(resource.Name), value)
{
ImageResource = resourceInstance
};
Parameters[imageEnvName] = expression;
return expression.ToScalar();
}
internal async Task ProcessResourceAsync(KubernetesEnvironmentContext context, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
{
ProcessEndpoints();
ProcessVolumes();
ProcessProbes();
await ProcessEnvironmentAsync(context, executionContext, cancellationToken).ConfigureAwait(false);
await ProcessArgumentsAsync(context, executionContext, cancellationToken).ConfigureAwait(false);
BuildKubernetesResources();
}
private void ProcessEndpoints()
{
var resolvedEndpoints = resource.ResolveEndpoints(Parent.PortAllocator);
foreach (var resolved in resolvedEndpoints)
{
var endpoint = resolved.Endpoint;
if (resolved.TargetPort.Value is null)
{
// Default endpoint for ProjectResource - deployment tool assigns port.
// Skip the default https endpoint — the container won't listen on HTTPS.
// In Kubernetes, TLS termination is handled by ingress or service mesh.
// We still create an EndpointMapping (needed for service discovery env vars)
// but reuse the http endpoint's HelmValue so no duplicate K8s port is generated.
// This matches the core framework's SetBothPortsEnvVariables() behavior,
// which skips DefaultHttpsEndpoint when setting HTTPS_PORTS.
// See: https://github.com/microsoft/aspire/issues/14029
if (resource.TryGetLastAnnotation<ProjectLaunchDefaultsAnnotation>(out var launchDefaults) &&
endpoint == launchDefaults.DefaultHttpsEndpoint)
{
// Find the existing http endpoint's HelmValue to share it
var httpMapping = EndpointMappings.Values.FirstOrDefault(m => m.Scheme == "http");
if (httpMapping is not null)
{
EndpointMappings[endpoint.Name] = new(endpoint.UriScheme, GetKubernetesProtocolName(endpoint.Protocol), resource.Name.ToServiceName(), httpMapping.Port, endpoint.Name);
continue;
}
}
GenerateDefaultProjectEndpointMapping(endpoint);
continue;
}
var portValue = resolved.TargetPort.Value.Value.ToString(CultureInfo.InvariantCulture);
// Capture the exposed (service) port when it differs from the target (container) port.
// This allows WithServicePort to set a different Kubernetes Service port than the container port.
HelmValue? servicePort = null;
if (resolved.ExposedPort.Value is int exposedPortValue &&
exposedPortValue != resolved.TargetPort.Value.Value)
{
servicePort = HelmValue.Literal(exposedPortValue.ToString(CultureInfo.InvariantCulture));
}
EndpointMappings[endpoint.Name] = new(endpoint.UriScheme, GetKubernetesProtocolName(endpoint.Protocol), resource.Name.ToServiceName(), HelmValue.Literal(portValue), endpoint.Name, ServicePort: servicePort);
}
}
private void GenerateDefaultProjectEndpointMapping(EndpointAnnotation endpoint)
{
const int defaultPort = 8080;
// Create a Helm parameter for the container port
var paramName = $"port_{endpoint.Name}".ToHelmValuesSectionName();
var helmValue = new HelmValue(
paramName.ToHelmParameterExpression(resource.Name),
defaultPort
);
Parameters[paramName] = helmValue;
EndpointMappings[endpoint.Name] = new(endpoint.UriScheme, GetKubernetesProtocolName(endpoint.Protocol), resource.Name.ToServiceName(), helmValue, endpoint.Name);
}
private void ProcessVolumes()
{
if (!resource.TryGetContainerMounts(out var mounts))
{
return;
}
foreach (var volume in mounts)
{
if (volume.Source is null || volume.Target is null)
{
throw new InvalidOperationException("Volume source and target must be set");
}
if (volume.Type == ContainerMountType.BindMount)
{
throw new InvalidOperationException("Bind mounts are not supported by the Kubernetes publisher");
}
var newVolume = new VolumeMountV1
{
Name = volume.Source,
ReadOnly = volume.IsReadOnly,
MountPath = volume.Target,
};
Volumes.Add(newVolume);
}
}
#pragma warning disable ASPIREPROBES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
private void ProcessProbes()
{
if (!resource.TryGetAnnotationsOfType<ProbeAnnotation>(out var probeAnnotations))
{
return;
}
foreach (var probeAnnotation in probeAnnotations)
{
ProbeV1? probe = null;
if (probeAnnotation is EndpointProbeAnnotation endpointProbeAnnotation
&& EndpointMappings.TryGetValue(endpointProbeAnnotation.EndpointReference.EndpointName, out var endpointMapping))
{
probe = new ProbeV1()
{
HttpGet = new()
{
Path = endpointProbeAnnotation.Path,
Port = GetEndpointValue(endpointMapping, EndpointProperty.TargetPort),
Scheme = endpointMapping.Scheme,
},
};
}
if (probe is not null)
{
probe.InitialDelaySeconds = probeAnnotation.InitialDelaySeconds;
probe.PeriodSeconds = probeAnnotation.PeriodSeconds;
probe.TimeoutSeconds = probeAnnotation.TimeoutSeconds;
probe.FailureThreshold = probeAnnotation.FailureThreshold;
probe.SuccessThreshold = probeAnnotation.SuccessThreshold;
Probes.Add((probeAnnotation.Type, probe));
}
}
}
#pragma warning restore ASPIREPROBES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
private async Task ProcessArgumentsAsync(KubernetesEnvironmentContext environmentContext, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
{
if (resource.TryGetAnnotationsOfType<CommandLineArgsCallbackAnnotation>(out var commandLineArgsCallbackAnnotations))
{
var context = new CommandLineArgsCallbackContext([], resource, cancellationToken: cancellationToken)
{
ExecutionContext = executionContext
};
foreach (var c in commandLineArgsCallbackAnnotations)
{
await c.Callback(context).ConfigureAwait(false);
}
foreach (var arg in context.Args)
{
var value = await ProcessValueAsync(environmentContext, executionContext, arg).ConfigureAwait(false);
if (value is not string str)
{
throw new NotSupportedException("Command line args must be strings");
}
Commands.Add(new(str));
}
}
}
private async Task ProcessEnvironmentAsync(KubernetesEnvironmentContext environmentContext, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
{
if (resource.TryGetAnnotationsOfType<EnvironmentCallbackAnnotation>(out var environmentCallbacks))
{
var context = new EnvironmentCallbackContext(executionContext, resource, cancellationToken: cancellationToken);
foreach (var c in environmentCallbacks)
{
await c.Callback(context).ConfigureAwait(false);
}
// Remove HTTPS service discovery variables — containers in Kubernetes don't have TLS certificates.
// TLS termination is handled externally by ingress controllers or service mesh.
// This matches the Docker Compose behavior in RemoveHttpsServiceDiscoveryVariables.
RemoveHttpsServiceDiscoveryVariables(context.EnvironmentVariables);
foreach (var environmentVariable in context.EnvironmentVariables)
{
var key = environmentVariable.Key;
// Check if the value contains deferred providers (e.g., Bicep outputs)
// that can only be resolved after infrastructure provisioning.
// If so, create a deferred HelmValue with the env var key name.
if (IsUnresolvedAtPublishTime(environmentVariable.Value) &&
environmentVariable.Value is IValueProvider deferredVp)
{
var deferredHelmValue = CreateDeferredHelmValue(key, deferredVp);
ProcessEnvironmentHelmExpression(deferredHelmValue, key);
continue;
}
var value = await ProcessValueAsync(environmentContext, executionContext, environmentVariable.Value).ConfigureAwait(false);
switch (value)
{
case HelmValue helmExpression:
ProcessEnvironmentHelmExpression(helmExpression, key);
continue;
case string stringValue:
ProcessEnvironmentStringValue(stringValue, key, resource.Name);
continue;
default:
ProcessEnvironmentDefaultValue(value, key, resource.Name);
break;
}
}
}
}
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;
case { ExpressionContainsHelmSecretExpression: false, ValueContainsSecretValuesExpression: false }:
EnvironmentVariables[key] = helmExpression;
break;
}
}
private void ProcessEnvironmentStringValue(string stringValue, string key, string resourceName)
{
if (stringValue.ContainsHelmFlowControlExpression())
{
stringValue = stringValue.ToQuotedHelmTemplateExpression();
}
if (stringValue.ContainsHelmValuesSecretExpression())
{
var secretExpression = stringValue.ToHelmSecretExpression(resourceName);
Secrets[key] = new(secretExpression, stringValue);
return;
}
var configExpression = key.ToHelmConfigExpression(resourceName);
EnvironmentVariables[key] = new(configExpression, stringValue);
}
private void ProcessEnvironmentDefaultValue(object value, string key, string resourceName)
{
var configExpression = key.ToHelmConfigExpression(resourceName);
EnvironmentVariables[key] = new(configExpression, value.ToString() ?? string.Empty);
}
private static void RemoveHttpsServiceDiscoveryVariables(Dictionary<string, object> environmentVariables)
{
var keysToRemove = environmentVariables
.Where(kvp => kvp.Value is EndpointReference epRef && epRef.Scheme == "https" && kvp.Key.StartsWith("services__", StringComparison.Ordinal))
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in keysToRemove)
{
environmentVariables.Remove(key);
}
}
private async Task<object> ProcessValueAsync(KubernetesEnvironmentContext context, DistributedApplicationExecutionContext executionContext, object value, bool embedded = false)
{
while (true)
{
if (value is string s)
{
return s;
}
// Handle scalar/primitive types (bool, numerics, DateTimeOffset, TimeSpan, Uri, etc.)
// These can appear when third-party integrations set environment variables to non-string values.
if (value is bool boolValue)
{
return boolValue ? "true" : "false";
}
if (value is IFormattable formattable)
{
return formattable.ToString(null, CultureInfo.InvariantCulture);
}
if (value is EndpointReference ep)
{
// The referenced endpoint may belong to a resource deployed to a different compute
// environment (for example a Foundry hosted agent). In that case delegate to the owning
// compute environment instead of looking it up in this environment's local endpoint map.
if (ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression(
ep, [kubernetesEnvironmentResource, kubernetesEnvironmentResource.OwningComputeEnvironment], out var crossExpr))
{
value = crossExpr;
continue;
}
var referencedResource = ep.Resource == this
? this
: await context.CreateKubernetesResourceAsync(ep.Resource, executionContext, default).ConfigureAwait(false);
var mapping = referencedResource.EndpointMappings[ep.EndpointName];
var url = GetEndpointValue(mapping, EndpointProperty.Url);
return url;
}
if (value is ParameterResource param)
{
var helmValue = AllocateParameter(param, TargetResource, embedded);
if (embedded)
{
AllocateAdditionalParameter(param, helmValue);
}
return helmValue;
}
if (value is ConnectionStringReference cs)
{
value = cs.Resource.ConnectionStringExpression;
continue;
}
if (value is IResourceWithConnectionString csrs)
{
value = csrs.ConnectionStringExpression;
continue;
}
if (value is EndpointReferenceExpression epExpr)
{
if (ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression(
epExpr, [kubernetesEnvironmentResource, kubernetesEnvironmentResource.OwningComputeEnvironment], out var crossExpr))
{
value = crossExpr;
continue;
}
var referencedResource = epExpr.Endpoint.Resource == this
? this
: await context.CreateKubernetesResourceAsync(epExpr.Endpoint.Resource, executionContext, default).ConfigureAwait(false);
var mapping = referencedResource.EndpointMappings[epExpr.Endpoint.EndpointName];
var val = GetEndpointValue(mapping, epExpr.Property, embedded && epExpr.Property is EndpointProperty.Port or EndpointProperty.TargetPort);
return val;
}
if (value is ReferenceExpression expr)
{
if (expr.IsConditional)
{
// When the condition is a parameter, use Helm flow control to defer
// evaluation to helm install/upgrade time.
if (expr.Condition is ParameterResource conditionParam)
{
return await BuildHelmConditional(context, executionContext, expr, conditionParam, embedded).ConfigureAwait(false);
}
// For non-parameter conditions, resolve statically at generation time.
var conditionContext = new ValueProviderContext { ExecutionContext = executionContext };
var conditionStr = await expr.Condition!.GetValueAsync(conditionContext, default).ConfigureAwait(false);
var branch = string.Equals(conditionStr, expr.MatchValue, StringComparison.OrdinalIgnoreCase)
? expr.WhenTrue!
: expr.WhenFalse!;
return await ProcessValueAsync(context, executionContext, branch, embedded).ConfigureAwait(false);
}
if (expr is { Format: "{0}", ValueProviders.Count: 1 })
{
var inner = await ProcessValueAsync(context, executionContext, expr.ValueProviders[0], embedded).ConfigureAwait(false);
// When embedded in a larger format string, convert to string for interpolation.
// When at the top level, preserve the original object (e.g., HelmValue with ValuesKey)
// so ProcessEnvironmentHelmExpression handles it correctly.
return embedded ? inner?.ToString() ?? string.Empty : inner;
}
var args = new object[expr.ValueProviders.Count];
var index = 0;
foreach (var vp in expr.ValueProviders)
{
var val = await ProcessValueAsync(context, executionContext, vp, true).ConfigureAwait(false);
args[index++] = val ?? throw new InvalidOperationException("Value is null");
}
return string.Format(CultureInfo.InvariantCulture, expr.Format, args);
}
// If we don't know how to process the value, we just return it as an external reference
if (value is IManifestExpressionProvider r)
{
context.Logger.NotSupportedResourceWarning(nameof(value), r.GetType().Name);
return ResolveUnknownValue(r, TargetResource);
}
throw new NotSupportedException($"Unsupported value type: {value.GetType().Name}");
}
}
private async Task<object> BuildHelmConditional(KubernetesEnvironmentContext context, DistributedApplicationExecutionContext executionContext, ReferenceExpression expr, ParameterResource conditionParam, bool embedded)
{
// Process both branches to get their rendered values.
var whenTrueResult = await ProcessValueAsync(context, executionContext, expr.WhenTrue!, embedded).ConfigureAwait(false);
var whenFalseResult = await ProcessValueAsync(context, executionContext, expr.WhenFalse!, embedded).ConfigureAwait(false);
var whenTrueStr = whenTrueResult.ToString() ?? string.Empty;
var whenFalseStr = whenFalseResult.ToString() ?? string.Empty;
// Allocate the condition parameter into values.yaml under the parameters section.
var formattedName = conditionParam.Name.ToHelmValuesSectionName();
var paramExpression = formattedName.ToHelmParameterExpression(TargetResource.Name);
// 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)
{
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!);
// 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);
}
/// <summary>
/// Ensures that any <see cref="ParameterResource"/> instances referenced in a branch's
/// value providers are allocated so their values flow to values.yaml.
/// </summary>
private void AllocateBranchParameters(ReferenceExpression branch)
{
foreach (var vp in branch.ValueProviders)
{
if (vp is ParameterResource branchParam)
{
var helmValue = AllocateParameter(branchParam, TargetResource, isEmbedded: true);
AllocateAdditionalParameter(branchParam, helmValue);
}
}
}
/// <summary>
/// Allocates an embedded parameter without adding a synthetic environment variable.
/// </summary>
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<string, HelmValue> 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;
// In Kubernetes a Service publishes `port` and forwards traffic to the pod's `targetPort`.
// Other resources reach this resource through the Service, so the client-facing address must
// use the Service (exposed) port, not the container's listening port. When no distinct
// exposed port was configured (`port == targetPort`, or the deployment tool assigns it),
// ServicePort is null and we fall back to the target port. Only EndpointProperty.TargetPort
// surfaces the container's listening port. This mirrors the Azure Container Apps publisher.
// See: https://github.com/microsoft/aspire/issues/18321
var servicePort = exposedPort ?? targetPort;
return property switch
{
EndpointProperty.Url => GetHostValue($"{scheme}://", suffix: GetPortSuffix(servicePort)),
EndpointProperty.Host or EndpointProperty.IPV4Host => GetHostValue(),
EndpointProperty.Port => GetPort(servicePort),
EndpointProperty.HostAndPort => GetHostValue(suffix: GetPortSuffix(servicePort)),
EndpointProperty.TargetPort => GetPort(targetPort),
EndpointProperty.Scheme => scheme,
_ => throw new NotSupportedException(),
};
string GetHostValue(string? prefix = null, string? suffix = null)
{
return $"{prefix}{host}{suffix}";
}
string GetPort(HelmValue port)
{
var rawPort = embedded ? port.Expression ?? port.ValueString : port.ToScalar();
return string.IsNullOrWhiteSpace(rawPort)
? string.Empty
: rawPort;
}
string GetPortSuffix(HelmValue port)
{
var portValue = port switch
{
_ when !string.IsNullOrWhiteSpace(port.Expression)
=> port.Expression,
{ ValueString: { } } => port.ValueString,
_ => null
};
return string.IsNullOrWhiteSpace(portValue)
? string.Empty
: $":{portValue}";
}
}
private static HelmValue AllocateParameter(ParameterResource parameter, IResource resource, bool isEmbedded)
{
var formattedName = parameter.Name.ToHelmValuesSectionName();
var expression = parameter.Secret ?
formattedName.ToHelmSecretExpression(resource.Name) :
formattedName.ToHelmConfigExpression(resource.Name);
// 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,
IsEmbeddedParameter = isEmbedded
};
}
private static HelmValue ResolveUnknownValue(IManifestExpressionProvider parameter, IResource resource)
{
var formattedName = parameter.ValueExpression.Replace(HelmExtensions.StartDelimiter, string.Empty)
.Replace(HelmExtensions.EndDelimiter, string.Empty)
.Replace("{", string.Empty)
.Replace("}", string.Empty)
.Replace(".", "_")
.ToHelmValuesSectionName();
var helmExpression = parameter.ValueExpression.ContainsHelmValuesSecretExpression() ?
formattedName.ToHelmSecretExpression(resource.Name) :
formattedName.ToHelmConfigExpression(resource.Name);
var helmValue = new HelmValue(helmExpression, parameter.ValueExpression)
{
// If the expression provider also implements IValueProvider, attach it
// for deploy-time resolution. This handles Bicep output references,
// connection strings, and any other deferred value source.
ValueProviderSource = parameter as IValueProvider
};
return helmValue;
}
/// <summary>
/// Checks if a value contains sub-expressions that cannot be resolved
/// at publish time and need deploy-time resolution via IValueProvider.
/// Recursively checks ReferenceExpression value providers.
/// </summary>
private static bool IsUnresolvedAtPublishTime(object value)
{
return value switch
{
string => false,
EndpointReference => false,
EndpointReferenceExpression => false,
ParameterResource => false,
ConnectionStringReference cs => IsUnresolvedAtPublishTime(cs.Resource.ConnectionStringExpression),
IResourceWithConnectionString csrs => IsUnresolvedAtPublishTime(csrs.ConnectionStringExpression),
ReferenceExpression expr => expr.ValueProviders.Any(IsUnresolvedAtPublishTime),
// Any other IManifestExpressionProvider that also implements IValueProvider
// is a deferred source (e.g., BicepOutputReference)
IManifestExpressionProvider when value is IValueProvider => true,
_ => false
};
}
/// <summary>
/// Creates a HelmValue that defers resolution to deploy time via IValueProvider.
/// </summary>
/// <param name="key">The environment variable or config key name to use as the Helm values path.</param>
/// <param name="valueProvider">The value provider for deploy-time resolution.</param>
private HelmValue CreateDeferredHelmValue(string key, IValueProvider valueProvider)
{
var helmExpression = key.ToHelmConfigExpression(TargetResource.Name);
return new HelmValue(helmExpression, string.Empty)
{
ValueProviderSource = valueProvider
};
}
private static string GetKubernetesProtocolName(ProtocolType type)
=> type switch
{
ProtocolType.Tcp => "TCP",
ProtocolType.Udp => "UDP",
_ => throw new InvalidOperationException($"Unsupported protocol type: {type}"),
};
/// <summary>
/// Represents a Helm value, which can be either a literal value, a Helm expression, or a helm expression with a known value.
/// </summary>
internal class HelmValue
{
private HelmValue(object? value)
{
Value = value;
}
/// <summary>
/// Initializes a new instance of the HelmValue class with the specified expression and value.
/// </summary>
/// <param name="expression">The Helm expression associated with the value. Cannot be null.</param>
/// <param name="value">The value to assign. Can be null.</param>
public HelmValue(string expression, object? value)
{
Expression = expression;
Value = value;
ValueType = value?.GetType();
}
/// <summary>
/// Initializes a new instance of the <see cref="HelmValue"/> class with a Helm expression and a parameter source.
/// </summary>
/// <param name="expression"></param>
/// <param name="parameterSource"></param>
public HelmValue(string expression, ParameterResource parameterSource)
{
Expression = expression;
ParameterSource = parameterSource;
}
/// <summary>
/// Gets the Helm expression associated with this HelmValue, if any.
/// </summary>
public string? Expression { get; }
/// <summary>
/// Gets the value associated with this HelmValue, if any.
/// </summary>
public object? Value { get; }
/// <summary>
/// Gets the type of the value associated with this HelmValue, if any.
/// </summary>
protected Type? ValueType { get; }
/// <summary>
/// Gets the string representation of the value, if available.
/// </summary>
public string? ValueString => Value?.ToString();
/// <summary>
/// Gets the parameter resource associated with this HelmValue, if any.
/// </summary>
public ParameterResource? ParameterSource { get; }
/// <summary>
/// Gets the resource associated with a container image reference, if any.
/// When set, the image name is resolved at deploy time via <see cref="ContainerImageReference"/>
/// to include the container registry prefix.
/// </summary>
public IResource? ImageResource { get; init; }
/// <summary>
/// Gets the value provider for deferred resolution at deploy time.
/// When set, the value is resolved via <see cref="IValueProvider"/>
/// during the prepare step, replacing the placeholder in values.yaml.
/// This handles any value provider (e.g., Bicep output references, connection strings).
/// </summary>
public IValueProvider? ValueProviderSource { get; init; }
/// <summary>
/// Gets the key to use when writing this value to the Helm values.yaml file.
/// When set, this overrides the dictionary key to ensure the values.yaml key matches
/// the Helm expression path (e.g., parameter name "cache_password" vs env var name "REDIS_PASSWORD").
/// </summary>
public string? ValuesKey { get; init; }
/// <summary>
/// Gets a value indicating whether this value supplies a parameter embedded in another Helm value.
/// </summary>
public bool IsEmbeddedParameter { get; init; }
/// <summary>
/// Indicates whether the expression contains a Helm secret expression.
/// </summary>
public bool ExpressionContainsHelmSecretExpression
=> Expression?.ContainsHelmValuesSecretExpression() ?? false;
/// <summary>
/// Gets a value indicating whether the value string contains any secret value expressions.
/// </summary>
public bool ValueContainsSecretValuesExpression
=> ValueString?.ContainsHelmValuesSecretExpression() ?? false;
/// <summary>
/// Gets a value indicating whether the current value string contains a Helm values expression.
/// </summary>
public bool ValueContainsHelmExpression
=> ValueString?.ContainsHelmValuesExpression() ?? false;
/// <summary>
/// Returns a string representation of the HelmValue.
/// </summary>
/// <returns>A string that represents the value or expression, or an empty string if neither is set.</returns>
public override string ToString()
{
return ValueString ?? Expression ?? string.Empty;
}
/// <summary>
/// Converts the HelmValue to a scalar value or expression, applying type conversions if necessary.
/// </summary>
/// <returns>
/// A string representing the scalar value or Helm expression.
/// </returns>
public string ToScalar()
{
if (string.IsNullOrWhiteSpace(Expression))
{
return ValueString ?? string.Empty;
}
var expression = HelmExtensions.ScalarExpressionPattern().Match(Expression);
if (expression is not { Success: true } or not { Captures.Count: > 0 })
{
// if its not a scalar expression, use `ToString`
return ToString();
}
var typeConversion = ValueType switch
{
var t when t == typeof(int) => $" {HelmExtensions.PipelineDelimiter} int",
var t when t == typeof(long) => $" {HelmExtensions.PipelineDelimiter} int64",
var t when t == typeof(float)
|| t == typeof(double)
|| t == typeof(decimal) => $" {HelmExtensions.PipelineDelimiter} float64",
_ => string.Empty
};
return $"{expression.Captures[0].Value.Trim()}{typeConversion}".ToHelmExpression();
}
public static HelmValue Literal(object value) => new(value);
}
}