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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// 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;

/// <summary>
/// An annotation placed on a <see cref="KubernetesEnvironmentResource"/> that specifies
/// the Helm chart description written to the generated <c>Chart.yaml</c>.
/// </summary>
/// <param name="description">A <see cref="ReferenceExpression"/> representing the chart description value.</param>
public sealed class HelmChartDescriptionAnnotation(ReferenceExpression description) : IResourceAnnotation
{
/// <summary>
/// Gets the Helm chart description as a <see cref="ReferenceExpression"/>.
/// </summary>
public ReferenceExpression Description { get; } = description ?? throw new ArgumentNullException(nameof(description));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// 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;

/// <summary>
/// An annotation placed on a <see cref="KubernetesEnvironmentResource"/> that specifies
/// the Helm chart name written to the generated <c>Chart.yaml</c>.
/// </summary>
/// <param name="name">A <see cref="ReferenceExpression"/> representing the chart name value.</param>
public sealed class HelmChartNameAnnotation(ReferenceExpression name) : IResourceAnnotation
{
/// <summary>
/// Gets the Helm chart name as a <see cref="ReferenceExpression"/>.
/// </summary>
public ReferenceExpression Name { get; } = name ?? throw new ArgumentNullException(nameof(name));
}
25 changes: 0 additions & 25 deletions src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -247,31 +247,6 @@ private static async Task PrepareAsync(PipelineStepContext context, KubernetesEn
{
try
{
// Update the chart version if configured via annotation
if (environment.TryGetLastAnnotation<HelmChartVersionAnnotation>(out var versionAnnotation))
{
var version = await versionAnnotation.Version.GetValueAsync(context.CancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(version))
{
environment.HelmChartVersion = version;

// Re-write Chart.yaml with updated version
var chartFilePath = Path.Combine(outputPath, "Chart.yaml");
if (File.Exists(chartFilePath))
{
var chartContent = await File.ReadAllTextAsync(chartFilePath, context.CancellationToken).ConfigureAwait(false);
// Simple replacement of the version line in Chart.yaml.
// Use a MatchEvaluator to avoid interpreting '$' in version as regex backreferences.
chartContent = System.Text.RegularExpressions.Regex.Replace(
chartContent,
@"^version:\s*.*$",
_ => $"version: {version}",
System.Text.RegularExpressions.RegexOptions.Multiline);
await File.WriteAllTextAsync(chartFilePath, chartContent, context.CancellationToken).ConfigureAwait(false);
}
}
}

// Resolve captured parameter/secret values and write a deploy override file.
// During publish, secrets and parameters without defaults are written as empty
// placeholders in values.yaml. During deploy, we resolve them and provide the
Expand Down
135 changes: 130 additions & 5 deletions src/Aspire.Hosting.Kubernetes/HelmChartOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public sealed partial class HelmChartOptions
{
private const int KubernetesNamespaceMaxLength = 63;
private const int HelmReleaseNameMaxLength = 53;
private const int HelmChartNameMaxLength = 250;
private const int HelmChartDescriptionMaxLength = 1024;

internal IResourceBuilder<KubernetesEnvironmentResource> EnvironmentBuilder { get; }

Expand Down Expand Up @@ -119,13 +121,18 @@ internal HelmChartOptions WithReleaseName([AspireUnion(typeof(string), typeof(IR
/// <summary>
/// Sets the Helm chart version for deployment.
/// </summary>
/// <param name="version">The chart version (e.g., "1.0.0").</param>
/// <param name="version">
/// The chart version. Helm accepts strict SemVer 2.0 strings (e.g. <c>"1.2.3"</c>,
/// <c>"1.2.3-beta.1+ef365"</c>) as well as partial versions (<c>"1"</c>, <c>"1.2"</c>) and
/// versions with a leading <c>v</c> (<c>"v1.2.3"</c>), which are coerced to a full
/// semantic version. Leading zeros are not allowed.
/// </param>
/// <returns>This <see cref="HelmChartOptions"/> for chaining.</returns>
[AspireExportIgnore(Reason = "Polyglot app hosts use the union-based withChartVersion dispatcher export.")]
public HelmChartOptions WithChartVersion(string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(version);
ValidateChartVersion(version);
ValidateChartVersion(version, nameof(version));

var expression = ReferenceExpression.Create($"{version}");
EnvironmentBuilder.WithAnnotation(new HelmChartVersionAnnotation(expression), ResourceAnnotationMutationBehavior.Replace);
Expand Down Expand Up @@ -160,6 +167,94 @@ internal HelmChartOptions WithChartVersion([AspireUnion(typeof(string), typeof(I
};
}

/// <summary>
/// Sets the Helm chart name written to the generated <c>Chart.yaml</c>.
/// </summary>
/// <param name="name">The chart name. Must match Helm's chart-name format (alphanumeric, <c>-</c>, <c>_</c>, or <c>.</c>).</param>
/// <returns>This <see cref="HelmChartOptions"/> for chaining.</returns>
[AspireExportIgnore(Reason = "Polyglot app hosts use the union-based withChartName dispatcher export.")]
public HelmChartOptions WithChartName(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ValidateChartName(name, nameof(name));

var expression = ReferenceExpression.Create($"{name}");
EnvironmentBuilder.WithAnnotation(new HelmChartNameAnnotation(expression), ResourceAnnotationMutationBehavior.Replace);
return this;
}

/// <summary>
/// Sets the Helm chart name written to the generated <c>Chart.yaml</c> using a parameter that will be prompted at deploy time.
/// </summary>
/// <param name="name">A parameter resource builder for the chart name value.</param>
/// <returns>This <see cref="HelmChartOptions"/> for chaining.</returns>
[AspireExportIgnore(Reason = "Polyglot app hosts use the union-based withChartName dispatcher export.")]
public HelmChartOptions WithChartName(IResourceBuilder<ParameterResource> name)
{
ArgumentNullException.ThrowIfNull(name);

var expression = ReferenceExpression.Create($"{name.Resource}");
EnvironmentBuilder.WithAnnotation(new HelmChartNameAnnotation(expression), ResourceAnnotationMutationBehavior.Replace);
return this;
}

[AspireExport(MethodName = "withChartName", Description = "Sets the Helm chart name written to the generated Chart.yaml.")]
internal HelmChartOptions WithChartName([AspireUnion(typeof(string), typeof(IResourceBuilder<ParameterResource>))] object name)
{
ArgumentNullException.ThrowIfNull(name);

return name switch
{
string nameValue => WithChartName(nameValue),
IResourceBuilder<ParameterResource> nameParameter => WithChartName(nameParameter),
_ => throw new ArgumentException("Chart name must be a string or a parameter resource builder.", nameof(name))
};
}

/// <summary>
/// Sets the Helm chart description written to the generated <c>Chart.yaml</c>.
/// </summary>
/// <param name="description">The chart description.</param>
/// <returns>This <see cref="HelmChartOptions"/> for chaining.</returns>
[AspireExportIgnore(Reason = "Polyglot app hosts use the union-based withChartDescription dispatcher export.")]
public HelmChartOptions WithChartDescription(string description)
{
ArgumentException.ThrowIfNullOrWhiteSpace(description);
ValidateChartDescription(description, nameof(description));

var expression = ReferenceExpression.Create($"{description}");
EnvironmentBuilder.WithAnnotation(new HelmChartDescriptionAnnotation(expression), ResourceAnnotationMutationBehavior.Replace);
return this;
}

/// <summary>
/// Sets the Helm chart description written to the generated <c>Chart.yaml</c> using a parameter that will be prompted at deploy time.
/// </summary>
/// <param name="description">A parameter resource builder for the chart description value.</param>
/// <returns>This <see cref="HelmChartOptions"/> for chaining.</returns>
[AspireExportIgnore(Reason = "Polyglot app hosts use the union-based withChartDescription dispatcher export.")]
public HelmChartOptions WithChartDescription(IResourceBuilder<ParameterResource> description)
{
ArgumentNullException.ThrowIfNull(description);

var expression = ReferenceExpression.Create($"{description.Resource}");
EnvironmentBuilder.WithAnnotation(new HelmChartDescriptionAnnotation(expression), ResourceAnnotationMutationBehavior.Replace);
return this;
}

[AspireExport(MethodName = "withChartDescription", Description = "Sets the Helm chart description written to the generated Chart.yaml.")]
internal HelmChartOptions WithChartDescription([AspireUnion(typeof(string), typeof(IResourceBuilder<ParameterResource>))] object description)
{
ArgumentNullException.ThrowIfNull(description);

return description switch
{
string descriptionValue => WithChartDescription(descriptionValue),
IResourceBuilder<ParameterResource> descriptionParameter => WithChartDescription(descriptionParameter),
_ => throw new ArgumentException("Chart description must be a string or a parameter resource builder.", nameof(description))
};
}

private static void ValidateDnsLabel(string value, string target, int maxLength, string paramName)
{
if (value.Length > maxLength)
Expand All @@ -173,14 +268,44 @@ private static void ValidateDnsLabel(string value, string target, int maxLength,
}
}

private static void ValidateChartVersion(string version)
// Matches Helm's own chart-version validation, which uses the lenient SemVer parser
// (Masterminds/semver/v3 NewVersion) — see helm/helm pkg/chart/v2/metadata.go isValidSemver.
// Helm accepts a leading "v" and partial versions (e.g. "v1", "1", "1.2"), coercing them
// to a full semantic version. Leading zeros are not allowed.
internal const SemVersionStyles ChartVersionStyles = SemVersionStyles.AllowV | SemVersionStyles.OptionalMinorPatch;

internal static void ValidateChartVersion(string version, string paramName)
{
if (!SemVersion.TryParse(version, SemVersionStyles.Strict, out _))
if (!SemVersion.TryParse(version, ChartVersionStyles, out _))
{
throw new ArgumentException($"Helm chart version '{version}' is invalid. Use a semantic version such as '1.0.0' or '1.0.0-beta.1'.", nameof(version));
throw new ArgumentException($"Helm chart version '{version}' is invalid. Helm accepts versions such as '1.2.3', '1.2.3-beta.1+ef365', '1', '1.2', or 'v1.2.3'.", paramName);
}
}

internal static void ValidateChartName(string name, string paramName)
{
if (name.Length > HelmChartNameMaxLength)
{
throw new ArgumentException($"Helm chart name '{name}' is invalid. It must be {HelmChartNameMaxLength} characters or fewer.", paramName);
}

if (!HelmChartNamePattern().IsMatch(name))
{
throw new ArgumentException($"Helm chart name '{name}' is invalid. Use alphanumeric characters, '-', '_', or '.'.", paramName);
}
}

internal static void ValidateChartDescription(string description, string paramName)
{
if (description.Length > HelmChartDescriptionMaxLength)
{
throw new ArgumentException($"Helm chart description is invalid. It must be {HelmChartDescriptionMaxLength} characters or fewer.", paramName);
}
}

[GeneratedRegex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")]
private static partial Regex DnsLabelPattern();

[GeneratedRegex("^[a-zA-Z0-9._-]+$")]
private static partial Regex HelmChartNamePattern();
}
26 changes: 23 additions & 3 deletions src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,35 @@ public sealed class KubernetesEnvironmentResource : Resource, IComputeEnvironmen
/// <summary>
/// Gets or sets the name of the Helm chart to be generated.
/// </summary>
public string HelmChartName { get; set; } = "aspire";
/// <remarks>
/// Defaults to the application name. Use
/// <see cref="HelmChartOptions.WithChartName(string)"/> via
/// <see cref="KubernetesEnvironmentExtensions.WithHelm(IResourceBuilder{KubernetesEnvironmentResource}, Action{HelmChartOptions})"/>
/// to customize the chart name.
/// </remarks>
internal string HelmChartName { get; set; } = "aspire";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need these fields at all?


/// <summary>
/// Gets or sets the version of the Helm chart to be generated.
/// This property specifies the version number that will be assigned to the Helm chart,
/// typically following semantic versioning conventions.
/// </summary>
public string HelmChartVersion { get; set; } = "0.1.0";
/// <remarks>
/// Use <see cref="HelmChartOptions.WithChartVersion(string)"/> via
/// <see cref="KubernetesEnvironmentExtensions.WithHelm(IResourceBuilder{KubernetesEnvironmentResource}, Action{HelmChartOptions})"/>
/// to customize the chart version.
/// </remarks>
internal string HelmChartVersion { get; set; } = "0.1.0";

/// <summary>
/// Gets or sets the description of the Helm chart being generated.
/// </summary>
public string HelmChartDescription { get; set; } = "Aspire Helm Chart";
/// <remarks>
/// Use <see cref="HelmChartOptions.WithChartDescription(string)"/> via
/// <see cref="KubernetesEnvironmentExtensions.WithHelm(IResourceBuilder{KubernetesEnvironmentResource}, Action{HelmChartOptions})"/>
/// to customize the chart description.
/// </remarks>
internal string HelmChartDescription { get; set; } = "Aspire Helm Chart";

/// <summary>
/// Determines whether to include an Aspire dashboard for telemetry visualization in this environment.
Expand Down Expand Up @@ -215,6 +231,10 @@ public KubernetesEnvironmentResource(string name) : base(name)
Description = $"Publishes the Kubernetes environment configuration for {Name}.",
Action = ctx => PublishAsync(ctx)
};
// Depend on publish-prereq so that process-parameters has run before we
// resolve Helm chart annotations (chart name/version/description) that may
// be backed by ParameterResource values.
publishStep.DependsOn(WellKnownPipelineSteps.PublishPrereq);
publishStep.RequiredBy(WellKnownPipelineSteps.Publish);
steps.Add(publishStep);

Expand Down
41 changes: 41 additions & 0 deletions src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ private async Task WriteKubernetesHelmValuesAsync()

private async Task WriteKubernetesHelmChartAsync(KubernetesEnvironmentResource environment)
{
await ResolveHelmChartMetadataAsync(environment).ConfigureAwait(false);

var helmChart = new HelmChart
{
Name = environment.HelmChartName,
Expand All @@ -317,6 +319,45 @@ private async Task WriteKubernetesHelmChartAsync(KubernetesEnvironmentResource e
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;
Comment on lines +333 to +336
}
}

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;
Comment on lines +352 to +356
}
}
}

private static void ApplyNodePoolSelector(KubernetesResource serviceResource, KubernetesNodePoolResource nodePool)
{
var podSpec = serviceResource.Workload?.PodTemplate?.Spec;
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Hosting.Kubernetes/KubernetesResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ private void SetLabels()
{
Labels = new()
{
["app.kubernetes.io/name"] = Parent.HelmChartName,
["app.kubernetes.io/name"] = ".Chart.Name".ToHelmExpression(),
["app.kubernetes.io/component"] = resource.Name,
["app.kubernetes.io/instance"] = ".Release.Name".ToHelmExpression(),
};
Expand Down
Loading
Loading