File: Deployment\HelmVersionValidator.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.
 
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
 
namespace Aspire.Hosting.Kubernetes;
 
/// <summary>
/// Validates that the installed Helm CLI is new enough for the flags and behaviors
/// Aspire's Kubernetes deployment pipeline depends on.
/// </summary>
/// <remarks>
/// Aspire authors `helm upgrade --install` invocations against Helm 4.x. In particular
/// the `--server-side=true --force-conflicts` combination emitted by
/// <c>WithForceConflicts()</c> matches the Helm 4 form of <c>--server-side</c> (string
/// valued: <c>"true" | "false" | "auto"</c>, https://github.com/helm/helm/pull/13649).
/// Validating up-front turns errors like <c>unknown flag: --force-conflicts</c> or
/// <c>Flag --force has been deprecated, use --force-replace instead</c> into a single
/// clear actionable message.
/// </remarks>
internal static partial class HelmVersionValidator
{
    /// <summary>
    /// Minimum supported Helm version. See class remarks for rationale.
    /// </summary>
    public static readonly Version MinimumHelmVersion = new(4, 2, 0);
 
    private const string InstallDocsUrl = "https://helm.sh/docs/intro/install/";
 
    // `helm version --short` returns a single line in the shape
    //   v4.2.0+gfa15ec0
    //   v4.0.0
    //   v3.18.0+gb88f836
    // Match the optional `v` followed by MAJOR.MINOR.PATCH; ignore any `+gitsha`
    // build metadata. Intentionally unanchored: some shells / wrappers (oh-my-zsh
    // plugins, asdf shims, alias output, etc.) can prefix banner or shim lines
    // before the actual version token, so we accept the first valid token anywhere
    // in the captured output rather than requiring it at column 0.
    [GeneratedRegex(@"v?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)")]
    private static partial Regex HelmVersionRegex();
 
    /// <summary>
    /// Runs <c>helm version --short</c>, parses the SemVer, and throws
    /// <see cref="InvalidOperationException"/> if the installed version is older than
    /// <see cref="MinimumHelmVersion"/> or if the output cannot be parsed.
    /// </summary>
    /// <remarks>
    /// We deliberately do not pass <c>--client</c>. That flag existed in Helm 2 (where
    /// Tiller meant there was a separate server version), was kept as a no-op in
    /// Helm 3, and was removed entirely in Helm 4 — which is our minimum. Passing
    /// <c>--client</c> against Helm 4 fails with <c>Error: unknown flag: --client</c>,
    /// which is exactly the cryptic failure mode this validator exists to prevent.
    /// </remarks>
    public static async Task EnsureMinimumVersionAsync(
        IHelmRunner helmRunner,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(helmRunner);
 
        var stdout = new StringBuilder();
        var stderr = new StringBuilder();
 
        int exitCode;
        try
        {
            exitCode = await helmRunner.RunAsync(
                "version --short",
                onOutputData: line => stdout.AppendLine(line),
                onErrorData: line => stderr.AppendLine(line),
                cancellationToken: cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            // ProcessUtil throws when the process itself can't be spawned (helm not on
            // PATH, permission denied, etc.). The exception message from the runner
            // is typically a low-level "No such file or directory" or
            // "permission denied" that doesn't tell users what to do, so wrap it.
            throw new InvalidOperationException(
                $"Helm CLI not found or could not be invoked. Aspire requires Helm {MinimumHelmVersion} or later. Install it from {InstallDocsUrl} and ensure it is available on your PATH.",
                ex);
        }
 
        if (exitCode != 0)
        {
            var errorText = stderr.ToString().Trim();
            var detail = string.IsNullOrEmpty(errorText) ? $"exit code {exitCode}" : errorText;
            throw new InvalidOperationException(
                $"'helm version --short' failed ({detail}). Aspire requires Helm {MinimumHelmVersion} or later. See {InstallDocsUrl}.");
        }
 
        var rawOutput = stdout.ToString().Trim();
        if (!TryParseHelmVersion(rawOutput, out var detected))
        {
            throw new InvalidOperationException(
                $"Could not parse Helm version from 'helm version --short' output: '{rawOutput}'. Aspire requires Helm {MinimumHelmVersion} or later. See {InstallDocsUrl}.");
        }
 
        if (detected < MinimumHelmVersion)
        {
            throw new InvalidOperationException(
                string.Format(
                    CultureInfo.InvariantCulture,
                    "Helm {0} was detected, but Aspire requires Helm {1} or later to deploy Kubernetes resources. Upgrade Helm from {2}.",
                    detected,
                    MinimumHelmVersion,
                    InstallDocsUrl));
        }
    }
 
    /// <summary>
    /// Extracts the first <c>MAJOR.MINOR.PATCH</c> token from the given Helm version
    /// output. Returns <see langword="false"/> if no version token is present.
    /// </summary>
    internal static bool TryParseHelmVersion(string output, out Version version)
    {
        version = new Version(0, 0, 0);
        if (string.IsNullOrWhiteSpace(output))
        {
            return false;
        }
 
        var match = HelmVersionRegex().Match(output);
        if (!match.Success)
        {
            return false;
        }
 
        var major = int.Parse(match.Groups["major"].ValueSpan, CultureInfo.InvariantCulture);
        var minor = int.Parse(match.Groups["minor"].ValueSpan, CultureInfo.InvariantCulture);
        var patch = int.Parse(match.Groups["patch"].ValueSpan, CultureInfo.InvariantCulture);
        version = new Version(major, minor, patch);
        return true;
    }
}