File: KubernetesHelmChartExtensions.cs
Web Access
Project: src\src\Aspire.Hosting.Kubernetes\Aspire.Hosting.Kubernetes.csproj (Aspire.Hosting.Kubernetes)
// 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 ASPIREPIPELINES002 // IDeploymentStateManager is for evaluation purposes only.
 
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Kubernetes;
using Aspire.Hosting.Pipelines;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Provides extension methods for adding and configuring external Helm charts
/// in a Kubernetes environment.
/// </summary>
public static partial class KubernetesHelmChartExtensions
{
    /// <summary>
    /// Adds an external Helm chart to be installed in the Kubernetes environment.
    /// The chart is installed via <c>helm upgrade --install</c> as a pipeline step
    /// after the main application Helm chart is deployed.
    /// </summary>
    /// <param name="builder">The Kubernetes environment resource builder.</param>
    /// <param name="name">The name of the Helm chart resource (used as release name and namespace if not overridden).</param>
    /// <param name="chartReference">
    /// The Helm chart reference. Can be an OCI registry URL (e.g., <c>oci://quay.io/jetstack/charts/cert-manager</c>)
    /// or a chart name from an added repository.
    /// </param>
    /// <param name="chartVersion">The chart version to install.</param>
    /// <returns>A resource builder for the Helm chart resource.</returns>
    /// <remarks>
    /// <para>
    /// The chart is installed in a dedicated namespace (defaulting to the chart resource name).
    /// Use <see cref="WithNamespace"/> to override the namespace, and <see cref="WithHelmValue"/>
    /// to set chart values.
    /// </para>
    /// <para>
    /// By default, <c>aspire destroy</c> does <em>not</em> uninstall the external chart, because
    /// it may be shared with workloads outside the Aspire app. Opt in to destroy-time uninstall
    /// by chaining <see cref="WithDestroy"/>.
    /// </para>
    /// </remarks>
    /// <example>
    /// <code>
    /// var k8s = builder.AddKubernetesEnvironment("k8s");
    ///
    /// // Install cert-manager from OCI registry
    /// k8s.AddHelmChart("cert-manager", "oci://quay.io/jetstack/charts/cert-manager", "1.17.0")
    ///     .WithHelmValue("crds.enabled", "true");
    /// </code>
    /// </example>
    [AspireExport]
    public static IResourceBuilder<KubernetesHelmChartResource> AddHelmChart(
        this IResourceBuilder<KubernetesEnvironmentResource> builder,
        [ResourceName] string name,
        string chartReference,
        string chartVersion)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrEmpty(name);
        ArgumentException.ThrowIfNullOrEmpty(chartReference);
        ArgumentException.ThrowIfNullOrEmpty(chartVersion);
 
        ValidateChartReference(chartReference, nameof(chartReference));
        HelmChartOptions.ValidateChartVersion(chartVersion, nameof(chartVersion));
 
        var environment = builder.Resource;
        var resource = new KubernetesHelmChartResource(name, environment, chartReference, chartVersion);
 
        // Helm chart installation is a publish/deploy-time concern only. In run mode the
        // parent KubernetesEnvironmentResource isn't added to the model (see
        // AddKubernetesEnvironment), so any helm-install step that depends on
        // helm-deploy-{env.Name} would fail step validation with a missing-dependency error.
        // Mirror AddIngress/AddGateway and skip model registration entirely in run mode.
        if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
        {
            return builder.ApplicationBuilder.CreateResourceBuilder(resource);
        }
 
        var chartBuilder = builder.ApplicationBuilder.AddResource(resource)
            .WithIconName("Archive");
 
        chartBuilder.WithAnnotation(new PipelineStepAnnotation(_ =>
        {
            // Resolve and validate release/namespace at step-creation time so the user
            // gets a clear error before the helm CLI ever runs.
            var (releaseName, @namespace) = ResolveReleaseAndNamespace(resource);
 
            var steps = new List<PipelineStep>();
 
            var installStep = new PipelineStep
            {
                Name = $"helm-install-{name}",
                Description = $"Installs Helm chart '{name}' ({resource.ChartReference}:{resource.ChartVersion})",
                Action = ctx => InstallHelmChartAsync(ctx, environment, resource, releaseName, @namespace)
            };
 
            installStep.DependsOn($"helm-deploy-{environment.Name}");
            installStep.RequiredBy(WellKnownPipelineSteps.Deploy);
            steps.Add(installStep);
 
            if (resource.DestroyOnUninstall)
            {
                var uninstallStep = new PipelineStep
                {
                    Name = $"helm-uninstall-{name}",
                    Description = $"Uninstalls Helm chart '{name}' from namespace '{@namespace}'",
                    Action = ctx => UninstallHelmChartAsync(ctx, environment, resource, releaseName, @namespace),
                    DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq]
                };
                // The uninstall path shells out to `helm uninstall`, so it must observe the same
                // Helm CLI / version preflight as the deploy path. Without this dep, a missing or
                // too-old Helm during teardown would surface as the raw spawn / unknown-flag error
                // the env-wide `check-helm-prereqs-{env}` step exists to convert into an actionable
                // message. Install is already covered transitively via `helm-deploy-{env}`.
                uninstallStep.DependsOn($"check-helm-prereqs-{environment.Name}");
                uninstallStep.RequiredBy(WellKnownPipelineSteps.Destroy);
                steps.Add(uninstallStep);
            }
 
            return Task.FromResult<IEnumerable<PipelineStep>>(steps);
        }));
 
        return chartBuilder;
    }
 
    /// <summary>
    /// Sets a Helm value for the chart installation. Values are passed to <c>helm upgrade --install</c>
    /// via <c>--set</c> flags.
    /// </summary>
    /// <param name="builder">The Helm chart resource builder.</param>
    /// <param name="key">The value key using dot notation (e.g., <c>config.enableGatewayAPI</c>).</param>
    /// <param name="value">The value to set.</param>
    /// <returns>The resource builder for chaining.</returns>
    [AspireExport]
    public static IResourceBuilder<KubernetesHelmChartResource> WithHelmValue(
        this IResourceBuilder<KubernetesHelmChartResource> builder,
        string key,
        string value)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrEmpty(key);
        ArgumentNullException.ThrowIfNull(value);
 
        ValidateHelmSetKey(key, nameof(key));
        ValidateHelmSetValue(value, nameof(value));
 
        builder.Resource.Values[key] = value;
        return builder;
    }
 
    /// <summary>
    /// Sets the Kubernetes namespace for the Helm chart installation.
    /// If not set, the namespace defaults to the chart resource name.
    /// </summary>
    /// <param name="builder">The Helm chart resource builder.</param>
    /// <param name="namespace">The namespace to install the chart into.</param>
    /// <returns>The resource builder for chaining.</returns>
    [AspireExport("withHelmChartNamespace")]
    public static IResourceBuilder<KubernetesHelmChartResource> WithNamespace(
        this IResourceBuilder<KubernetesHelmChartResource> builder,
        string @namespace)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrEmpty(@namespace);
 
        HelmChartOptions.ValidateNamespace(@namespace, nameof(@namespace));
 
        builder.Resource.Namespace = @namespace;
        return builder;
    }
 
    /// <summary>
    /// Sets the Helm release name for the chart installation.
    /// If not set, the release name defaults to the resource name.
    /// </summary>
    /// <param name="builder">The Helm chart resource builder.</param>
    /// <param name="releaseName">The Helm release name.</param>
    /// <returns>The resource builder for chaining.</returns>
    [AspireExport("withHelmChartReleaseName")]
    public static IResourceBuilder<KubernetesHelmChartResource> WithReleaseName(
        this IResourceBuilder<KubernetesHelmChartResource> builder,
        string releaseName)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrEmpty(releaseName);
 
        HelmChartOptions.ValidateReleaseName(releaseName, nameof(releaseName));
 
        builder.Resource.ReleaseName = releaseName;
        return builder;
    }
 
    /// <summary>
    /// Opts the Helm chart in to destroy-time uninstall. When set, <c>aspire destroy</c>
    /// will run <c>helm uninstall</c> for this release as part of the destroy pipeline.
    /// </summary>
    /// <param name="builder">The Helm chart resource builder.</param>
    /// <returns>The resource builder for chaining.</returns>
    /// <remarks>
    /// <para>
    /// External Helm charts are not uninstalled by default because they may be shared
    /// with workloads outside the Aspire app (for example, cert-manager or an ingress
    /// controller installed once for many apps). Opt in only when the chart's lifecycle
    /// is owned by this app.
    /// </para>
    /// </remarks>
    /// <example>
    /// <code>
    /// k8s.AddHelmChart("podinfo", "oci://ghcr.io/stefanprodan/charts/podinfo", "6.7.1")
    ///     .WithDestroy();
    /// </code>
    /// </example>
    [AspireExport("withHelmChartDestroy")]
    public static IResourceBuilder<KubernetesHelmChartResource> WithDestroy(
        this IResourceBuilder<KubernetesHelmChartResource> builder)
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        builder.Resource.DestroyOnUninstall = true;
        return builder;
    }
 
    /// <summary>
    /// Opts the Helm chart in to <c>helm upgrade --install --force-conflicts</c>. When set,
    /// Helm's server-side apply forcibly takes over any fields owned by another field
    /// manager instead of failing with a conflict.
    /// </summary>
    /// <param name="builder">The Helm chart resource builder.</param>
    /// <returns>The resource builder for chaining.</returns>
    /// <remarks>
    /// <para>
    /// This is most commonly needed for charts whose templates ship admission webhooks
    /// (cert-manager, kyverno, gatekeeper, opa-gatekeeper, etc.) on clusters where another
    /// admission controller — such as the AKS <c>admissionsenforcer</c> field manager
    /// installed by the Azure Policy add-on or Deployment Safeguards — mutates the webhook
    /// configuration after install. Helm's Server-Side Apply (used by default for charts
    /// that opt in, including cert-manager) refuses to overwrite fields owned by another
    /// field manager. Without <c>--force-conflicts</c>, the next <c>helm upgrade</c> fails
    /// with a "conflict with admissionsenforcer" error on the webhook's
    /// <c>namespaceSelector</c> (or similar).
    /// See
    /// <see href="https://learn.microsoft.com/azure/aks/deployment-safeguards">Deployment Safeguards in AKS</see>
    /// and
    /// <see href="https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts">Server-Side Apply conflicts</see>
    /// for background.
    /// </para>
    /// <para>
    /// Unlike the deprecated <c>--force</c> / <c>--force-replace</c> (which delete and
    /// recreate the resource and are incompatible with Server-Side Apply),
    /// <c>--force-conflicts</c> is non-destructive — it only changes which field manager
    /// owns the conflicting field. No resources are deleted or recreated. This flag is
    /// also distinct from Helm's <c>--take-ownership</c>, which transfers ownership of an
    /// entire resource between Helm releases and does not address field-level conflicts.
    /// </para>
    /// <para>
    /// Requires Helm v3.18 or later (the version that introduced <c>--force-conflicts</c>
    /// for <c>helm upgrade --install</c>). Older Helm versions fail with
    /// <c>Error: unknown flag: --force-conflicts</c>.
    /// </para>
    /// </remarks>
    /// <example>
    /// <code>
    /// // cert-manager on AKS clusters with Azure Policy / Deployment Safeguards enabled.
    /// k8s.AddHelmChart("cert-manager", "oci://quay.io/jetstack/charts/cert-manager", "v1.18.2")
    ///     .WithHelmValue("crds.enabled", "true")
    ///     .WithForceConflicts();
    /// </code>
    /// </example>
    [AspireExport("withHelmChartForceConflicts")]
    public static IResourceBuilder<KubernetesHelmChartResource> WithForceConflicts(
        this IResourceBuilder<KubernetesHelmChartResource> builder)
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        builder.Resource.ForceConflicts = true;
        return builder;
    }
 
    private static async Task InstallHelmChartAsync(
        PipelineStepContext context,
        KubernetesEnvironmentResource environment,
        KubernetesHelmChartResource chart,
        string releaseName,
        string @namespace)
    {
        var logger = context.Services.GetRequiredService<ILogger<KubernetesHelmChartResource>>();
        var helmRunner = context.Services.GetRequiredService<IHelmRunner>();
 
        logger.LogInformation(
            "Installing Helm chart '{ChartName}' ({ChartRef}:{ChartVersion}) into namespace '{Namespace}'.",
            chart.Name, chart.ChartReference, chart.ChartVersion, @namespace);
 
        var arguments = new StringBuilder();
        arguments.Append(CultureInfo.InvariantCulture, $"upgrade --install {releaseName} {QuoteArg(chart.ChartReference)}");
        arguments.Append(CultureInfo.InvariantCulture, $" --namespace {@namespace}");
        arguments.Append(" --create-namespace");
        arguments.Append(" --wait");
 
        arguments.Append(CultureInfo.InvariantCulture, $" --version {chart.ChartVersion}");
 
        if (chart.ForceConflicts)
        {
            // --force-conflicts tells helm's server-side apply to forcibly take over fields
            // owned by other field managers instead of failing with a conflict. Required
            // for charts whose admission webhooks are mutated by the AKS admissionsenforcer
            // / Azure Policy add-on after install — without it, helm's SSA fails on
            // .webhooks[*].namespaceSelector. Non-destructive (no resource recreate).
            // Equivalent to `kubectl apply --force-conflicts` and distinct from
            // --take-ownership (which transfers helm release ownership) and the
            // deprecated --force / --force-replace (which delete + recreate resources
            // and are incompatible with SSA).
            // See KubernetesHelmChartExtensions.WithForceConflicts for the full rationale.
            //
            // --server-side is REQUIRED alongside --force-conflicts: helm only registers
            // the --force-conflicts flag in server-side-apply mode. Both flags arrived
            // together in helm v3.18.
            //
            // We pass --server-side=true (with explicit value) rather than the bare
            // --server-side flag because helm v4 changed --server-side from a bool flag
            // to a string flag with "true"|"false"|"auto" values
            // (https://github.com/helm/helm/pull/13649). The bare form would consume the
            // following --force-conflicts as the flag value under v4, poisoning the
            // release metadata with the literal string "--force-conflicts" and breaking
            // every subsequent `helm upgrade` with
            // "invalid/unknown release server-side apply method: --force-conflicts".
            // The =true form parses identically under helm v3.18+ (where the bool flag
            // also accepts an explicit value) and helm v4.
            arguments.Append(" --server-side=true --force-conflicts");
        }
 
        if (environment.KubeConfigPath is not null)
        {
            arguments.Append(CultureInfo.InvariantCulture, $" --kubeconfig {QuoteArg(environment.KubeConfigPath)}");
        }
 
        foreach (var (key, value) in chart.Values)
        {
            arguments.Append(CultureInfo.InvariantCulture, $" --set {QuoteArg($"{key}={value}")}");
        }
 
        var stderrBuilder = new StringBuilder();
 
        var exitCode = await helmRunner.RunAsync(
            arguments.ToString(),
            onOutputData: output => logger.LogDebug("helm (stdout): {Output}", output),
            onErrorData: error =>
            {
                stderrBuilder.AppendLine(error);
                logger.LogDebug("helm (stderr): {Error}", error);
            },
            cancellationToken: context.CancellationToken).ConfigureAwait(false);
 
        if (exitCode != 0)
        {
            var errorOutput = stderrBuilder.ToString().Trim();
            var message = string.IsNullOrEmpty(errorOutput)
                ? $"helm upgrade --install for chart '{chart.Name}' failed with exit code {exitCode}"
                : $"helm upgrade --install for chart '{chart.Name}' failed: {errorOutput}";
 
            throw new InvalidOperationException(message);
        }
 
        if (chart.DestroyOnUninstall)
        {
            // Persist install state so destroy can find this release later, even from a
            // different process where the in-memory resource state is gone.
            var deploymentStateManager = context.Services.GetRequiredService<IDeploymentStateManager>();
            var stateSection = await deploymentStateManager
                .AcquireSectionAsync(GetStateSectionName(environment, chart), context.CancellationToken)
                .ConfigureAwait(false);
            stateSection.Data["ReleaseName"] = releaseName;
            stateSection.Data["Namespace"] = @namespace;
            await deploymentStateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false);
        }
 
        logger.LogInformation(
            "Helm chart '{ChartName}' installed successfully as release '{ReleaseName}' in namespace '{Namespace}'.",
            chart.Name, releaseName, @namespace);
    }
 
    private static async Task UninstallHelmChartAsync(
        PipelineStepContext context,
        KubernetesEnvironmentResource environment,
        KubernetesHelmChartResource chart,
        string defaultReleaseName,
        string defaultNamespace)
    {
        var logger = context.Services.GetRequiredService<ILogger<KubernetesHelmChartResource>>();
        var helmRunner = context.Services.GetRequiredService<IHelmRunner>();
        var deploymentStateManager = context.Services.GetRequiredService<IDeploymentStateManager>();
 
        var stateSection = await deploymentStateManager
            .AcquireSectionAsync(GetStateSectionName(environment, chart), context.CancellationToken)
            .ConfigureAwait(false);
        var savedReleaseName = stateSection.Data["ReleaseName"]?.ToString();
        var savedNamespace = stateSection.Data["Namespace"]?.ToString();
 
        // Fall back to the values resolved at step-creation time when no state was persisted
        // (e.g., the user opted in to destroy after deploying without it). This is best-effort.
        var releaseName = !string.IsNullOrEmpty(savedReleaseName) ? savedReleaseName : defaultReleaseName;
        var @namespace = !string.IsNullOrEmpty(savedNamespace) ? savedNamespace : defaultNamespace;
 
        logger.LogInformation(
            "Uninstalling Helm release '{ReleaseName}' for chart '{ChartName}' from namespace '{Namespace}'.",
            releaseName, chart.Name, @namespace);
 
        var arguments = new StringBuilder();
        arguments.Append(CultureInfo.InvariantCulture, $"uninstall {releaseName} --namespace {@namespace}");
 
        if (environment.KubeConfigPath is not null)
        {
            arguments.Append(CultureInfo.InvariantCulture, $" --kubeconfig {QuoteArg(environment.KubeConfigPath)}");
        }
 
        var stderrBuilder = new StringBuilder();
 
        var exitCode = await helmRunner.RunAsync(
            arguments.ToString(),
            onOutputData: output => logger.LogDebug("helm (stdout): {Output}", output),
            onErrorData: error =>
            {
                stderrBuilder.AppendLine(error);
                logger.LogDebug("helm (stderr): {Error}", error);
            },
            cancellationToken: context.CancellationToken).ConfigureAwait(false);
 
        if (exitCode != 0)
        {
            var errorOutput = stderrBuilder.ToString().Trim();
            var message = string.IsNullOrEmpty(errorOutput)
                ? $"helm uninstall for chart '{chart.Name}' failed with exit code {exitCode}"
                : $"helm uninstall for chart '{chart.Name}' failed: {errorOutput}";
 
            throw new InvalidOperationException(message);
        }
 
        await deploymentStateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false);
 
        logger.LogInformation(
            "Helm release '{ReleaseName}' uninstalled from namespace '{Namespace}'.",
            releaseName, @namespace);
    }
 
    private static (string ReleaseName, string Namespace) ResolveReleaseAndNamespace(KubernetesHelmChartResource chart)
    {
        var releaseName = chart.ReleaseName ?? chart.Name;
        var @namespace = chart.Namespace ?? chart.Name;
 
        // The fallback to chart.Name can produce a value that isn't a valid Helm release name or
        // Kubernetes namespace (uppercase, too long, etc.) — Aspire resource names allow more than
        // DNS labels do. Validate here so the caller gets a clear ArgumentException pointing at
        // WithReleaseName / WithNamespace instead of an opaque helm CLI failure.
        if (chart.ReleaseName is null)
        {
            try
            {
                HelmChartOptions.ValidateReleaseName(releaseName, nameof(chart.ReleaseName));
            }
            catch (ArgumentException ex)
            {
                throw new InvalidOperationException(
                    $"Cannot derive a Helm release name from resource name '{chart.Name}'. " +
                    $"Set an explicit release name via WithReleaseName(...). {ex.Message}",
                    ex);
            }
        }
 
        if (chart.Namespace is null)
        {
            try
            {
                HelmChartOptions.ValidateNamespace(@namespace, nameof(chart.Namespace));
            }
            catch (ArgumentException ex)
            {
                throw new InvalidOperationException(
                    $"Cannot derive a Kubernetes namespace from resource name '{chart.Name}'. " +
                    $"Set an explicit namespace via WithNamespace(...). {ex.Message}",
                    ex);
            }
        }
 
        return (releaseName, @namespace);
    }
 
    private static string GetStateSectionName(KubernetesEnvironmentResource environment, KubernetesHelmChartResource chart)
        => $"HelmChart:{environment.Name}:{chart.Name}";
 
    // Allowlist for Helm chart references. Covers OCI URLs (oci://host/path), HTTP/HTTPS URLs,
    // local paths, plain chart names ("repo/chart"), and packaged chart filenames. Rejects anything
    // that could break helm argument tokenization (whitespace, quotes, control chars).
    [GeneratedRegex(@"^[A-Za-z0-9_./:@+~\-]+$")]
    private static partial Regex ChartReferencePattern();
 
    // Disallowed in helm --set keys: anything that would break the key=value tokenization or
    // interact with helm's escape syntax. Allow alphanumerics plus dot, dash, underscore,
    // and brackets (for indexed/array paths like "args[0]").
    [GeneratedRegex(@"^[A-Za-z0-9_.\-\[\]]+$")]
    private static partial Regex HelmSetKeyPattern();
 
    private static void ValidateChartReference(string chartReference, string paramName)
    {
        if (!ChartReferencePattern().IsMatch(chartReference))
        {
            throw new ArgumentException(
                $"Helm chart reference '{chartReference}' is invalid. Use OCI/HTTP URLs, repo/chart names, or local paths containing only letters, digits, '.', '-', '_', '/', ':', '@', '+', '~'.",
                paramName);
        }
    }
 
    private static void ValidateHelmSetKey(string key, string paramName)
    {
        if (!HelmSetKeyPattern().IsMatch(key))
        {
            throw new ArgumentException(
                $"Helm value key '{key}' is invalid. Use letters, digits, '.', '-', '_', or brackets for indexed paths.",
                paramName);
        }
    }
 
    private static void ValidateHelmSetValue(string value, string paramName)
    {
        // Reject control characters (newlines, tabs) and double-quotes that would break the
        // surrounding quoted argument we hand to the OS process. Helm itself supports richer
        // value syntax via --set-file / --set-string, which users can wire up later if needed.
        foreach (var c in value)
        {
            if (c == '"' || c == '\\' || char.IsControl(c))
            {
                throw new ArgumentException(
                    $"Helm value contains an unsupported character (0x{(int)c:X2}). Avoid quotes, backslashes, and control characters; use --values files for complex values.",
                    paramName);
            }
        }
    }
 
    // Wraps an already-validated argument fragment in double quotes for safe interpolation
    // into the helm arguments string. Callers must validate that the value contains no
    // embedded quotes, backslashes, or control characters first.
    private static string QuoteArg(string value) => $"\"{value}\"";
}