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
14 changes: 6 additions & 8 deletions .github/workflows/deployment-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -251,17 +251,15 @@ jobs:
docker info | head -20
echo "✅ Docker is available"

# Pin helm to a version that supports `helm install --server-side`/--force-conflicts
# (added in helm v3.18.0; helm v4 keeps both but `--server-side` is now a string flag
# accepting true|false|auto, which is why our code passes `--server-side=true` rather
# than the bare flag — that form parses identically on v3.18+ and v4). The default
# helm preinstalled on the runner image lags behind and rejects these flags, which
# breaks AKS cert-manager scenarios that need server-side apply to coexist with the
# AKS Azure Policy add-on's webhook mutations.
# Pin Helm to satisfy Aspire.Hosting.Kubernetes' minimum supported version
# (HelmVersionValidator.MinimumHelmVersion, currently v4.2.0). The new
# check-helm-prereqs-{env} pipeline step now fails fast on older Helm
# CLIs, so the runner image's preinstalled Helm — which lags behind —
# would otherwise break every AKS deployment scenario.
- name: Setup Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
with:
version: v4.1.4
version: v4.2.0

- name: Run deployment test (${{ matrix.shortname }})
id: run_tests
Expand Down
3 changes: 3 additions & 0 deletions src/Aspire.Hosting.Azure.Kubernetes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ 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.

### Install the package

Expand Down
49 changes: 24 additions & 25 deletions src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,23 +120,25 @@ 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.
//
// The validator drives everything through IHelmRunner: a missing binary
// surfaces as a spawn failure that the validator wraps with the same
// "install Helm" hint, so we deliberately don't do a separate
// PathLookupHelper probe here. That also lets tests inject a fake runner
// without needing real Helm on PATH.
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/ " +
"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);
}
};
steps.Add(checkPrereqStep);
Expand Down Expand Up @@ -202,6 +204,12 @@ await ctx.ReportingStep.CompleteAsync(
// Use saved state for the confirmation message (more accurate than recomputing)
var @namespace = savedNamespace ?? "default";
await ConfirmDestroyAsync(ctx, $"Uninstall Helm release '{savedReleaseName}' from namespace '{@namespace}'? This action cannot be undone.").ConfigureAwait(false);

var helmRunner = ctx.Services.GetRequiredService<IHelmRunner>();
// Defer the prereq check until state exists so `aspire destroy` against a
// never-deployed environment can still report "Nothing to destroy" without
// requiring Helm on PATH.
await HelmVersionValidator.EnsureMinimumVersionAsync(helmRunner, ctx.CancellationToken).ConfigureAwait(false);
await HelmUninstallAsync(ctx, environment, savedReleaseName, @namespace).ConfigureAwait(false);

ctx.Summary.Add("🗑️ Helm Release", savedReleaseName);
Expand All @@ -223,6 +231,7 @@ await ctx.ReportingStep.CompleteAsync(
Tags = [HelmUninstallTag],
Action = ctx => HelmUninstallAsync(ctx, environment)
};
helmUninstallStep.DependsOn($"check-helm-prereqs-{environment.Name}");
steps.Add(helmUninstallStep);

return Task.FromResult<IReadOnlyList<PipelineStep>>(steps);
Expand Down Expand Up @@ -415,19 +424,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
136 changes: 136 additions & 0 deletions src/Aspire.Hosting.Kubernetes/Deployment/HelmVersionValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ public static IResourceBuilder<KubernetesHelmChartResource> AddHelmChart(
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);
}
Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.Hosting.Kubernetes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ 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.

### Install the package

In your AppHost project, install the Aspire Kubernetes Hosting library with [NuGet](https://www.nuget.org):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ namespace Aspire.Cli.EndToEnd.Tests.Helpers;
/// </summary>
internal static class KubernetesDeployTestHelpers
{
private static string KindVersion => Environment.GetEnvironmentVariable("KIND_VERSION") ?? "v0.31.0";
private static string HelmVersion => Environment.GetEnvironmentVariable("HELM_VERSION") ?? "v3.17.3";
private static string KubectlVersion => Environment.GetEnvironmentVariable("KUBECTL_VERSION") ?? "v1.34.3";
private static string KindVersion => KubernetesE2EVersions.KindVersion;
private static string HelmVersion => KubernetesE2EVersions.HelmVersion;
private static string KubectlVersion => KubernetesE2EVersions.KubectlVersion;

/// <summary>
/// Generates a unique KinD cluster name (max 32 chars).
Expand Down
33 changes: 33 additions & 0 deletions tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesE2EVersions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Cli.EndToEnd.Tests.Helpers;

/// <summary>
/// Single source of truth for the tool versions installed into the E2E test
/// container during Kubernetes scenarios.
/// </summary>
/// <remarks>
/// Each value falls back to an environment variable so CI can pin or bump a
/// version without a code change. The defaults are chosen to satisfy Aspire's
/// own minimums:
/// <list type="bullet">
/// <item>
/// <description>
/// <c>HelmVersion</c> must be at least
/// <c>Aspire.Hosting.Kubernetes.HelmVersionValidator.MinimumHelmVersion</c>
/// (currently <c>4.2.0</c>). The Kubernetes deployment pipeline now
/// fails fast at <c>check-helm-prereqs-{env}</c> for older Helm CLIs,
/// so an older default here would break every <c>DeployK8s*</c> test.
/// </description>
/// </item>
/// </list>
/// </remarks>
internal static class KubernetesE2EVersions
{
public static string KindVersion => Environment.GetEnvironmentVariable("KIND_VERSION") ?? "v0.31.0";

public static string HelmVersion => Environment.GetEnvironmentVariable("HELM_VERSION") ?? "v4.2.0";

public static string KubectlVersion => Environment.GetEnvironmentVariable("KUBECTL_VERSION") ?? "v1.34.3";
}
4 changes: 2 additions & 2 deletions tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ public sealed class KubernetesPublishTests(ITestOutputHelper output)
private const string ProjectName = "AspireKubernetesPublishTest";
private const string ClusterNamePrefix = "aspire-e2e";

private static string KindVersion => Environment.GetEnvironmentVariable("KIND_VERSION") ?? "v0.31.0";
private static string HelmVersion => Environment.GetEnvironmentVariable("HELM_VERSION") ?? "v3.17.3";
private static string KindVersion => KubernetesE2EVersions.KindVersion;
private static string HelmVersion => KubernetesE2EVersions.HelmVersion;

private static string GenerateUniqueClusterName() =>
$"{ClusterNamePrefix}-{Guid.NewGuid():N}"[..32]; // KinD cluster names max 32 chars
Expand Down
Loading
Loading