// 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 ASPIREPIPELINES002
#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.
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Kubernetes.Extensions;
using Aspire.Hosting.Kubernetes.Resources;
using Aspire.Hosting.Kubernetes.Yaml;
using Aspire.Hosting.Yaml;
using Microsoft.Extensions.Logging;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Aspire.Hosting.Kubernetes;
internal sealed class KubernetesPublishingContext(
DistributedApplicationExecutionContext executionContext,
string outputPath,
ILogger logger,
KubernetesEnvironmentResource? environment = null,
CancellationToken cancellationToken = default)
{
public readonly string OutputPath = outputPath;
private readonly Dictionary<string, Dictionary<string, object>> _helmValues = new()
{
[HelmExtensions.ParametersKey] = new Dictionary<string, object>(),
[HelmExtensions.SecretsKey] = new Dictionary<string, object>(),
[HelmExtensions.ConfigKey] = new Dictionary<string, object>(),
};
private readonly ISerializer _serializer = new SerializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeConverter(new ByteArrayStringYamlConverter())
.WithTypeConverter(new IntOrStringYamlConverter())
.WithTypeConverter(new KubernetesManifestResourceYamlConverter())
.WithEventEmitter(nextEmitter => new ForceQuotedStringsEventEmitter(nextEmitter, HelmExtensions.ShouldDoubleQuoteString))
.WithEventEmitter(e => new FloatEmitter(e))
.WithEmissionPhaseObjectGraphVisitor(args => new YamlIEnumerableSkipEmptyObjectGraphVisitor(args.InnerVisitor))
.ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
.WithNewLine("\n")
.WithIndentedSequences()
.Build();
internal async Task WriteModelAsync(DistributedApplicationModel model, KubernetesEnvironmentResource environment)
{
if (!executionContext.IsPublishMode)
{
return;
}
logger.StartGeneratingKubernetes();
ArgumentNullException.ThrowIfNull(model);
ArgumentNullException.ThrowIfNull(OutputPath);
if (model.Resources.Count == 0)
{
logger.EmptyModel();
return;
}
await WriteKubernetesOutputAsync(model, environment).ConfigureAwait(false);
logger.FinishGeneratingKubernetes(OutputPath);
}
private async Task WriteKubernetesOutputAsync(DistributedApplicationModel model, KubernetesEnvironmentResource environment)
{
// Include the dashboard resource alongside model resources so its templates are generated.
// This mirrors the Docker Compose pattern in DockerComposePublishingContext.
IEnumerable<IResource> resources = environment.DashboardEnabled && environment.Dashboard?.Resource is IResource dashboardResource
? [dashboardResource, .. model.Resources]
: model.Resources;
foreach (var resource in resources)
{
// Check for deployment target matching this environment or its parent (e.g., AKS)
var targetEnv = (IComputeEnvironmentResource?)environment.OwningComputeEnvironment ?? environment;
if (resource.GetDeploymentTargetAnnotation(targetEnv)?.DeploymentTarget is KubernetesResource serviceResource)
{
// Materialize Dockerfile factory if present
if (serviceResource.TargetResource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out var dockerfileBuildAnnotation) &&
dockerfileBuildAnnotation.DockerfileFactory is not null)
{
var dockerfileContext = new DockerfileFactoryContext
{
Services = executionContext.Services,
Resource = serviceResource.TargetResource,
CancellationToken = cancellationToken
};
// Copy to a resource-specific path in the output folder for publishing
var resourceDockerfilePath = Path.Combine(OutputPath, $"{serviceResource.TargetResource.Name}.Dockerfile");
await dockerfileBuildAnnotation.EmitDockerfileArtifactsAsync(dockerfileContext, resourceDockerfilePath).ConfigureAwait(false);
}
if (serviceResource.TargetResource.TryGetAnnotationsOfType<KubernetesServiceCustomizationAnnotation>(out var annotations))
{
foreach (var a in annotations)
{
a.Configure(serviceResource);
}
}
// Apply node pool nodeSelector if the resource has a node pool annotation
if (serviceResource.TargetResource.TryGetLastAnnotation<KubernetesNodePoolAnnotation>(out var nodePoolAnnotation))
{
ApplyNodePoolSelector(serviceResource, nodePoolAnnotation.NodePool);
}
await WriteKubernetesTemplatesForResource(resource, serviceResource.GetTemplatedResources()).ConfigureAwait(false);
await AppendResourceContextToHelmValuesAsync(resource, serviceResource).ConfigureAwait(false);
}
}
// Write Ingress resources as standalone templates.
foreach (var ingressResource in resources.OfType<KubernetesIngressResource>())
{
if (ingressResource.Parent == environment && ingressResource.GeneratedIngress is { } generatedIngress)
{
await WriteKubernetesTemplatesForResource(ingressResource, [generatedIngress]).ConfigureAwait(false);
}
}
// Write Gateway API resources (Gateway + HTTPRoutes) as standalone templates.
foreach (var gatewayResource in resources.OfType<KubernetesGatewayResource>())
{
if (gatewayResource.Parent == environment && gatewayResource.GeneratedGateway is { } generatedGateway)
{
var gatewayObjects = new List<BaseKubernetesResource> { generatedGateway };
gatewayObjects.AddRange(gatewayResource.GeneratedHttpRoutes);
await WriteKubernetesTemplatesForResource(gatewayResource, gatewayObjects).ConfigureAwait(false);
}
}
// Write first-class persistent volume resources as standalone PVC templates.
foreach (var volumeResource in resources.OfType<KubernetesPersistentVolumeResource>())
{
if (volumeResource.Parent == environment && volumeResource.GeneratedClaim is { } generatedClaim)
{
await WriteKubernetesTemplatesForResource(volumeResource, [generatedClaim]).ConfigureAwait(false);
}
}
await WriteKubernetesHelmChartAsync(environment).ConfigureAwait(false);
// Drain any captured Helm values from ingress/gateway resources that don't go through
// AppendResourceContextToHelmValuesAsync (compute resources do). Without this, values.yaml
// would be missing placeholders for parameters referenced by WithIngressClass(parameter),
// WithHostname(parameter), WithTls(parameter), WithGatewayClass(parameter), etc., and
// `helm template` would render `<no value>` for those Helm references.
EnsureCapturedHelmValuePlaceholders(environment);
await WriteKubernetesHelmValuesAsync().ConfigureAwait(false);
}
/// <summary>
/// Adds empty placeholders to <see cref="_helmValues"/> for any captured Helm value
/// whose (section, resourceKey, valueKey) is not already present. Compute resources
/// populate their placeholders during <see cref="AppendResourceContextToHelmValuesAsync"/>;
/// ingress and gateway resources rely on this pass.
/// </summary>
private void EnsureCapturedHelmValuePlaceholders(KubernetesEnvironmentResource environment)
{
foreach (var captured in environment.CapturedHelmValues)
{
if (!_helmValues.TryGetValue(captured.Section, out var section))
{
continue;
}
if (!section.TryGetValue(captured.ResourceKey, out var resourceObj) ||
resourceObj is not Dictionary<string, object> resourceSection)
{
resourceSection = new Dictionary<string, object>();
section[captured.ResourceKey] = resourceSection;
}
resourceSection.TryAdd(captured.ValueKey, string.Empty);
}
}
private async Task AppendResourceContextToHelmValuesAsync(IResource resource, KubernetesResource resourceContext)
{
var parameterItems = MergeHelmValueMappings(
resource,
HelmExtensions.ParametersKey,
(resourceContext.Parameters, "condition parameter"));
// Embedded parameters need values.yaml entries for their Helm references, but they must
// not become additional environment variables in the generated ConfigMap or Secret.
var configItems = MergeHelmValueMappings(
resource,
HelmExtensions.ConfigKey,
(resourceContext.EnvironmentVariables, "environment value"),
(resourceContext.AdditionalConfigValues, "embedded parameter"));
var secretItems = MergeHelmValueMappings(
resource,
HelmExtensions.SecretsKey,
(resourceContext.Secrets, "environment value"),
(resourceContext.AdditionalSecretValues, "embedded parameter"));
await AddValuesToHelmSectionAsync(resource, parameterItems, HelmExtensions.ParametersKey).ConfigureAwait(false);
await AddValuesToHelmSectionAsync(resource, configItems, HelmExtensions.ConfigKey).ConfigureAwait(false);
await AddValuesToHelmSectionAsync(resource, secretItems, HelmExtensions.SecretsKey).ConfigureAwait(false);
}
private static Dictionary<string, KubernetesResource.HelmValue> MergeHelmValueMappings(
IResource resource,
string helmKey,
params (IReadOnlyDictionary<string, KubernetesResource.HelmValue> Values, string OriginKind)[] mappingGroups)
{
var resourceKey = resource.Name.ToHelmValuesSectionName();
var result = new Dictionary<string, KubernetesResource.HelmValue>(StringComparer.Ordinal);
var origins = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var (values, originKind) in mappingGroups)
{
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.");
}
}
return result;
}
private async Task AddValuesToHelmSectionAsync(
IResource resource,
Dictionary<string, KubernetesResource.HelmValue> contextItems,
string helmKey)
{
if (contextItems.Count <= 0 || _helmValues[helmKey] is not Dictionary<string, object> helmSection)
{
return;
}
var paramValues = new Dictionary<string, object>();
foreach (var (key, helmExpressionWithValue) in contextItems)
{
// Use ValuesKey when available to ensure values.yaml key matches the Helm expression path.
// This matters when the dictionary key (env var name, e.g., "REDIS_PASSWORD") differs from
// the parameter name used in the Helm expression (e.g., "cache_password").
var valuesKey = helmExpressionWithValue.ValuesKey ?? key.ToHelmValuesSectionName();
// Cross-resource secret references have their Value set to a string containing
// Helm expressions (e.g., "cache:6379,password={{ .Values.secrets.cache.password }}").
// These need empty placeholders in values.yaml so the YAML section structure exists,
// and the actual values are resolved at deploy time from the captured cross-reference.
if (helmExpressionWithValue.ValueContainsHelmExpression)
{
paramValues[valuesKey] = string.Empty;
environment?.CapturedHelmCrossReferences.Add(
new KubernetesEnvironmentResource.CapturedHelmCrossReference(
helmKey,
resource.Name.ToHelmValuesSectionName(),
valuesKey,
helmExpressionWithValue.ValueString!));
continue;
}
object? value;
// If there's a parameter source, resolve its value asynchronously
if (helmExpressionWithValue.ParameterSource is ParameterResource parameter)
{
if (parameter.Secret || parameter.Default is null)
{
// Don't resolve secrets or parameters without defaults during publish.
value = string.Empty;
}
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 = helmExpressionWithValue.Value;
// If the value has an IValueProvider source, capture it for deploy-time
// resolution. Write an empty placeholder now and resolve at deploy time.
// This handles Bicep output references, connection strings, and any other
// deferred value source without requiring Azure-specific knowledge.
if (helmExpressionWithValue.ValueProviderSource is { } valueProvider)
{
value = string.Empty;
environment?.CapturedHelmValueProviders.Add(
new KubernetesEnvironmentResource.CapturedHelmValueProvider(
helmKey,
resource.Name.ToHelmValuesSectionName(),
valuesKey,
valueProvider));
}
}
paramValues[valuesKey] = value ?? string.Empty;
// Capture container image references for deploy-time registry resolution.
// During publish, the default image name (e.g., "server:latest") is written to values.yaml.
// During deploy, the ContainerImageReference resolves the full registry-prefixed name
// (e.g., "myregistry.azurecr.io/server:latest") and writes it to the override file.
if (helmExpressionWithValue.ImageResource is not null)
{
environment?.CapturedHelmImageReferences.Add(
new KubernetesEnvironmentResource.CapturedHelmImageReference(
helmKey,
resource.Name.ToHelmValuesSectionName(),
valuesKey,
helmExpressionWithValue.ImageResource));
}
}
if (paramValues.Count > 0)
{
helmSection[resource.Name.ToHelmValuesSectionName()] = paramValues;
}
}
private async Task WriteKubernetesTemplatesForResource(IResource resource, IEnumerable<BaseKubernetesResource> templatedItems)
{
var templatesFolder = Path.Combine(OutputPath, "templates", resource.Name);
Directory.CreateDirectory(templatesFolder);
foreach (var templatedItem in templatedItems)
{
var fileName = GetFilename(resource.Name, templatedItem);
var outputFile = Path.Combine(templatesFolder, fileName);
var yaml = _serializer.Serialize(templatedItem);
using var writer = new StreamWriter(outputFile);
await writer.WriteLineAsync(HelmExtensions.TemplateFileSeparator).ConfigureAwait(false);
await writer.WriteAsync(yaml).ConfigureAwait(false);
}
}
private static string GetFilename(string baseName, BaseKubernetesResource templatedItem)
{
if (string.IsNullOrWhiteSpace(templatedItem.Metadata.Name))
{
return $"{templatedItem.GetType().Name.ToLowerInvariant()}.yaml";
}
var resourceName = templatedItem.Metadata.Name;
if (resourceName.StartsWith($"{baseName.ToLowerInvariant()}-", StringComparison.Ordinal))
{
resourceName = resourceName.Substring(baseName.Length + 1); // +1 for the hyphen
}
return $"{resourceName}.yaml";
}
private async Task WriteKubernetesHelmValuesAsync()
{
var valuesYaml = _serializer.Serialize(_helmValues);
var outputFile = Path.Combine(OutputPath!, "values.yaml");
Directory.CreateDirectory(OutputPath!);
await File.WriteAllTextAsync(outputFile, valuesYaml, cancellationToken).ConfigureAwait(false);
}
private async Task WriteKubernetesHelmChartAsync(KubernetesEnvironmentResource environment)
{
await ResolveHelmChartMetadataAsync(environment).ConfigureAwait(false);
var helmChart = new HelmChart
{
Name = environment.HelmChartName,
Version = environment.HelmChartVersion,
AppVersion = environment.HelmChartVersion,
Description = environment.HelmChartDescription,
Type = "application",
ApiVersion = "v2",
Keywords = ["aspire", "kubernetes"],
KubeVersion = ">= 1.18.0-0",
};
var chartYaml = _serializer.Serialize(helmChart);
var outputFile = Path.Combine(OutputPath, "Chart.yaml");
Directory.CreateDirectory(OutputPath);
await File.WriteAllTextAsync(outputFile, chartYaml, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Resolves Helm chart name/version/description annotations (set via
/// <see cref="HelmChartOptions"/>) and assigns the resolved values onto the
/// environment's internal <c>HelmChart*</c> properties so that Chart.yaml is
/// generated with the user-configured values on the first write.
/// </summary>
private async Task ResolveHelmChartMetadataAsync(KubernetesEnvironmentResource environment)
{
if (environment.TryGetLastAnnotation<HelmChartNameAnnotation>(out var nameAnnotation))
{
var name = await nameAnnotation.Name.GetValueAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(name))
{
HelmChartOptions.ValidateChartName(name, nameof(HelmChartOptions.WithChartName));
environment.HelmChartName = name;
}
}
if (environment.TryGetLastAnnotation<HelmChartVersionAnnotation>(out var versionAnnotation))
{
var version = await versionAnnotation.Version.GetValueAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(version))
{
HelmChartOptions.ValidateChartVersion(version, nameof(HelmChartOptions.WithChartVersion));
environment.HelmChartVersion = version;
}
}
if (environment.TryGetLastAnnotation<HelmChartDescriptionAnnotation>(out var descriptionAnnotation))
{
var description = await descriptionAnnotation.Description.GetValueAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(description))
{
HelmChartOptions.ValidateChartDescription(description, nameof(HelmChartOptions.WithChartDescription));
environment.HelmChartDescription = description;
}
}
}
private static void ApplyNodePoolSelector(KubernetesResource serviceResource, KubernetesNodePoolResource nodePool)
{
var podSpec = serviceResource.Workload?.PodTemplate?.Spec;
if (podSpec is null)
{
return;
}
podSpec.NodeSelector[nodePool.NodeSelectorLabelKey] = nodePool.Name;
}
}