Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions src/Aspire.Hosting.Azure.Kubernetes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Provides extension methods and resource definitions for an Aspire AppHost to con
### Prerequisites

- An Azure subscription - [create one for free](https://azure.microsoft.com/free/)
- [Helm](https://helm.sh/docs/intro/install/) **v4.2.0 or later** on your `PATH`. Aspire shells out to `helm upgrade --install` to deploy the generated chart and any `AddHelmChart(...)` releases, and validates the Helm version up front so missing or older installs produce a clear actionable error instead of cryptic flag failures.
Comment thread
mitchdenny marked this conversation as resolved.
Outdated

### Install the package

Expand Down
31 changes: 14 additions & 17 deletions src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,23 +120,30 @@ internal static Task<IReadOnlyList<PipelineStep>> CreateStepsAsync(
var model = factoryContext.PipelineContext.Model;
var steps = new List<PipelineStep>();

// Step 0: Check prerequisites — verify Helm CLI is available
// Step 0: Check prerequisites — verify Helm CLI is available and meets the
// minimum supported version. Doing this once per environment, before any
// helm invocation in either the main chart deploy or AddHelmChart(...) flows,
// turns confusing low-level errors (unknown-flag, deprecated-flag, raw
// spawn errors) into a single actionable message.
var checkPrereqStep = new PipelineStep
{
Name = $"check-helm-prereqs-{environment.Name}",
Description = $"Verifies Helm CLI is available for {environment.Name}.",
Action = ctx =>
Action = async ctx =>
{
var helmPath = PathLookupHelper.FindFullPathFromPath("helm");
if (helmPath is null)
{
throw new InvalidOperationException(
"Helm CLI not found. Install it from https://helm.sh/docs/intro/install/ " +
$"Helm CLI not found. Aspire requires Helm {HelmVersionValidator.MinimumHelmVersion} or later. " +
"Install it from https://helm.sh/docs/intro/install/ " +
"and ensure it is available on your PATH.");
}

ctx.Logger.LogDebug("Helm CLI found at: {HelmPath}", helmPath);
return Task.CompletedTask;

var helmRunner = ctx.Services.GetRequiredService<IHelmRunner>();
await HelmVersionValidator.EnsureMinimumVersionAsync(helmRunner, ctx.CancellationToken).ConfigureAwait(false);
}
Comment thread
mitchdenny marked this conversation as resolved.
};
steps.Add(checkPrereqStep);
Expand Down Expand Up @@ -415,19 +422,9 @@ private static async Task HelmDeployAsync(PipelineStepContext context, Kubernete
{
var helmRunner = context.Services.GetRequiredService<IHelmRunner>();

// Verify helm is available
try
{
var versionExitCode = await helmRunner.RunAsync("version --short", cancellationToken: context.CancellationToken).ConfigureAwait(false);
if (versionExitCode != 0)
{
throw new InvalidOperationException("'helm' is installed but returned an error. Ensure 'helm' is properly configured and your cluster is accessible.");
}
}
catch (Exception ex) when (ex is not InvalidOperationException and not OperationCanceledException)
{
throw new InvalidOperationException("'helm' was not found. Please install 'helm' and ensure it is available on your PATH to deploy to Kubernetes.", ex);
}
// Helm presence + version validation already ran in
// check-helm-prereqs-{env}, which is a transitive predecessor of this
// step. No need to re-verify here.

var valuesFilePath = Path.Combine(outputPath, "values.yaml");
var arguments = new StringBuilder();
Expand Down
126 changes: 126 additions & 0 deletions src/Aspire.Hosting.Kubernetes/Deployment/HelmVersionValidator.cs
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.
Comment thread
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);
Comment thread
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;
}
}
4 changes: 4 additions & 0 deletions src/Aspire.Hosting.Kubernetes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Provides publishing extensions to Aspire for Kubernetes.

## Getting started

### Prerequisites

- [Helm](https://helm.sh/docs/intro/install/) **v4.2.0 or later** on your `PATH`. Aspire shells out to `helm upgrade --install` to deploy the generated chart, and validates the Helm version up front so missing or older installs produce a clear actionable error instead of cryptic flag failures.

Comment thread
mitchdenny marked this conversation as resolved.
Outdated
### Install the package

In your AppHost project, install the Aspire Kubernetes Hosting library with [NuGet](https://www.nuget.org):
Expand Down
62 changes: 62 additions & 0 deletions tests/Aspire.Hosting.Kubernetes.Tests/FakeHelmRunner.cs
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))
{
Comment thread
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 tests/Aspire.Hosting.Kubernetes.Tests/HelmVersionValidatorTests.cs
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);
}
}
22 changes: 0 additions & 22 deletions tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1686,28 +1686,6 @@ public async Task DestroyHelm_WithNoState_ReportsNothingToDestroy()
Assert.Contains(completedSteps, s => s.CompletionText.Contains("Nothing to destroy", StringComparison.OrdinalIgnoreCase));
}

private sealed class FakeHelmRunner : IHelmRunner
{
public bool WasUninstallCalled { get; private set; }
public string? LastArguments { get; private set; }
public int ExitCode { get; set; }

public Task<int> RunAsync(
string arguments,
string? workingDirectory = null,
Action<string>? onOutputData = null,
Action<string>? onErrorData = null,
CancellationToken cancellationToken = default)
{
LastArguments = arguments;
if (arguments.StartsWith("uninstall", StringComparison.OrdinalIgnoreCase))
{
WasUninstallCalled = true;
}
return Task.FromResult(ExitCode);
}
}

[Fact]
public async Task DestroyHelm_WhenUninstallFails_PreservesState()
{
Expand Down
Loading