-
Notifications
You must be signed in to change notification settings - Fork 892
Validate Helm CLI version (>= 4.2.0) before Kubernetes deploy #17491
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mitchdenny
merged 7 commits into
main
from
mitchdenny/issue-16977-validate-helm-is-installed-and-assert-co-a823a9
May 27, 2026
Merged
Changes from 1 commit
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
3a088b9
Validate Helm CLI version (>= 4.2.0) before Kubernetes deploy
mitchdenny ced45a6
Drop --client flag from helm version invocation
mitchdenny d03916c
Address PR review feedback
mitchdenny 1401438
Bump E2E Helm install version to v4.2.0 to match new floor
mitchdenny df5b207
Bump deployment-tests.yml Helm pin to v4.2.0
mitchdenny b5c7535
Gate AddHelmChart uninstall on prereqs and unify Helm CLI check
mitchdenny 647c241
Defer Helm version check in destroy-helm-{env} until state is found
mitchdenny File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
126 changes: 126 additions & 0 deletions
126
src/Aspire.Hosting.Kubernetes/Deployment/HelmVersionValidator.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| // 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 --client` returns a single line in the shape | ||
| // v4.2.0+gfa15ec0 | ||
| // v4.0.0 | ||
| // v3.18.0+gb88f836 | ||
| // Match the leading optional `v` followed by MAJOR.MINOR.PATCH; ignore any | ||
| // `+gitsha` build metadata. Anchored at the start because some shells/wrappers | ||
| // can prepend banner lines. | ||
|
mitchdenny marked this conversation as resolved.
Outdated
|
||
| [GeneratedRegex(@"v?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)")] | ||
| private static partial Regex HelmVersionRegex(); | ||
|
|
||
| /// <summary> | ||
| /// Runs <c>helm version --short --client</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> | ||
| 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 --client", | ||
| onOutputData: line => stdout.AppendLine(line), | ||
| onErrorData: line => stderr.AppendLine(line), | ||
| cancellationToken: cancellationToken).ConfigureAwait(false); | ||
|
mitchdenny marked this conversation as resolved.
|
||
| } | ||
| catch (Exception ex) when (ex is not OperationCanceledException) | ||
| { | ||
| // ProcessUtil throws when the process itself can't be spawned (helm not on | ||
| // PATH on some platforms, permission denied, etc.). The prereq step's PATH | ||
| // lookup runs before us, so this is a defensive fallback. | ||
| throw new InvalidOperationException( | ||
| $"Failed to invoke 'helm version --short --client'. Install Helm {MinimumHelmVersion} or later 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 --client' 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 --client' 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; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| namespace Aspire.Hosting.Kubernetes.Tests; | ||
|
|
||
| /// <summary> | ||
| /// In-memory fake of <see cref="IHelmRunner"/> for tests. Records arguments, | ||
| /// returns a configurable exit code, and emits canned stdout for | ||
| /// <c>helm version</c> probes so the prereq validator sees a valid response by | ||
| /// default. | ||
| /// </summary> | ||
| internal sealed class FakeHelmRunner : IHelmRunner | ||
| { | ||
| public bool WasUninstallCalled { get; private set; } | ||
|
|
||
| public string? LastArguments { get; private set; } | ||
|
|
||
| public int ExitCode { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Output emitted to <c>onOutputData</c> when arguments start with | ||
| /// <c>"version"</c>. Defaults to a recent stable Helm 4.x release so the | ||
| /// prereq version validator passes. | ||
| /// </summary> | ||
| public string VersionOutput { get; set; } = "v4.2.0+gfa15ec0"; | ||
|
|
||
| /// <summary> | ||
| /// Exit code returned specifically for <c>helm version</c> calls. Defaults to | ||
| /// 0 so version probing succeeds regardless of <see cref="ExitCode"/>, which | ||
| /// is used to model failures in the main command under test. | ||
| /// </summary> | ||
| public int VersionExitCode { get; set; } | ||
|
|
||
| public Task<int> RunAsync( | ||
| string arguments, | ||
| string? workingDirectory = null, | ||
| Action<string>? onOutputData = null, | ||
| Action<string>? onErrorData = null, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| LastArguments = arguments; | ||
|
|
||
| // Match any `helm version ...` probe (the validator passes | ||
| // `version --short --client`). | ||
| if (arguments.StartsWith("version", StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| if (onOutputData is not null && !string.IsNullOrEmpty(VersionOutput)) | ||
| { | ||
|
mitchdenny marked this conversation as resolved.
|
||
| onOutputData(VersionOutput); | ||
| } | ||
|
|
||
| return Task.FromResult(VersionExitCode); | ||
| } | ||
|
|
||
| if (arguments.StartsWith("uninstall", StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| WasUninstallCalled = true; | ||
| } | ||
|
|
||
| return Task.FromResult(ExitCode); | ||
| } | ||
| } | ||
88 changes: 88 additions & 0 deletions
88
tests/Aspire.Hosting.Kubernetes.Tests/HelmVersionValidatorTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| namespace Aspire.Hosting.Kubernetes.Tests; | ||
|
|
||
| public class HelmVersionValidatorTests | ||
| { | ||
| [Theory] | ||
| [InlineData("v4.2.0+gfa15ec0", 4, 2, 0)] | ||
| [InlineData("v4.0.0", 4, 0, 0)] | ||
| [InlineData("v4.5.1+g123abc", 4, 5, 1)] | ||
| [InlineData("v5.0.0", 5, 0, 0)] | ||
| [InlineData("v3.18.0+gb88f836", 3, 18, 0)] | ||
| [InlineData("4.2.0", 4, 2, 0)] | ||
| public void TryParseHelmVersion_ValidOutput_ReturnsTrueAndVersion(string output, int major, int minor, int patch) | ||
| { | ||
| Assert.True(HelmVersionValidator.TryParseHelmVersion(output, out var version)); | ||
| Assert.Equal(new Version(major, minor, patch), version); | ||
| } | ||
|
|
||
| [Theory] | ||
| [InlineData("")] | ||
| [InlineData(" ")] | ||
| [InlineData("not a version")] | ||
| [InlineData("helm: command not found")] | ||
| public void TryParseHelmVersion_InvalidOutput_ReturnsFalse(string output) | ||
| { | ||
| Assert.False(HelmVersionValidator.TryParseHelmVersion(output, out _)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task EnsureMinimumVersionAsync_AtMinimum_Passes() | ||
| { | ||
| var runner = new FakeHelmRunner { VersionOutput = "v4.2.0+gfa15ec0" }; | ||
| await HelmVersionValidator.EnsureMinimumVersionAsync(runner, CancellationToken.None); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task EnsureMinimumVersionAsync_NewerVersion_Passes() | ||
| { | ||
| var runner = new FakeHelmRunner { VersionOutput = "v5.1.0+g111111" }; | ||
| await HelmVersionValidator.EnsureMinimumVersionAsync(runner, CancellationToken.None); | ||
| } | ||
|
|
||
| [Theory] | ||
| [InlineData("v4.1.0+gfa15ec0")] | ||
| [InlineData("v4.0.0")] | ||
| [InlineData("v3.18.0+gb88f836")] | ||
| [InlineData("v3.14.4+gb88f836")] | ||
| public async Task EnsureMinimumVersionAsync_TooOld_ThrowsWithDetectedAndRequired(string oldVersion) | ||
| { | ||
| var runner = new FakeHelmRunner { VersionOutput = oldVersion }; | ||
| var ex = await Assert.ThrowsAsync<InvalidOperationException>( | ||
| () => HelmVersionValidator.EnsureMinimumVersionAsync(runner, CancellationToken.None)); | ||
|
|
||
| // Detected version (without leading 'v' and without build metadata) | ||
| var trimmed = oldVersion.TrimStart('v').Split('+')[0]; | ||
| Assert.Contains(trimmed, ex.Message, StringComparison.Ordinal); | ||
| Assert.Contains(HelmVersionValidator.MinimumHelmVersion.ToString(), ex.Message, StringComparison.Ordinal); | ||
| Assert.Contains("https://helm.sh/docs/intro/install/", ex.Message, StringComparison.Ordinal); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task EnsureMinimumVersionAsync_UnparseableOutput_ThrowsWithRawOutput() | ||
| { | ||
| var runner = new FakeHelmRunner { VersionOutput = "garbage banner" }; | ||
| var ex = await Assert.ThrowsAsync<InvalidOperationException>( | ||
| () => HelmVersionValidator.EnsureMinimumVersionAsync(runner, CancellationToken.None)); | ||
|
|
||
| Assert.Contains("garbage banner", ex.Message, StringComparison.Ordinal); | ||
| Assert.Contains(HelmVersionValidator.MinimumHelmVersion.ToString(), ex.Message, StringComparison.Ordinal); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task EnsureMinimumVersionAsync_NonZeroExitCode_ThrowsWithInstallHint() | ||
| { | ||
| var runner = new FakeHelmRunner | ||
| { | ||
| VersionOutput = string.Empty, | ||
| VersionExitCode = 1, | ||
| }; | ||
|
|
||
| var ex = await Assert.ThrowsAsync<InvalidOperationException>( | ||
| () => HelmVersionValidator.EnsureMinimumVersionAsync(runner, CancellationToken.None)); | ||
|
|
||
| Assert.Contains("https://helm.sh/docs/intro/install/", ex.Message, StringComparison.Ordinal); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.