File: KubernetesHelmChartTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Kubernetes.Tests\Aspire.Hosting.Kubernetes.Tests.csproj (Aspire.Hosting.Kubernetes.Tests)
// 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
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Testing;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.Kubernetes.Tests;
 
public class KubernetesHelmChartTests
{
    [Fact]
    public void AddHelmChart_CreatesResourceWithCorrectProperties()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart("cert-manager", "oci://quay.io/jetstack/charts/cert-manager", "1.17.0");
 
        Assert.Equal("cert-manager", chart.Resource.Name);
        Assert.Equal("oci://quay.io/jetstack/charts/cert-manager", chart.Resource.ChartReference);
        Assert.Equal("1.17.0", chart.Resource.ChartVersion);
        Assert.Equal("env", chart.Resource.Parent.Name);
    }
 
    [Fact]
    public void AddHelmChart_WithHelmValues_StoresValues()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart("cert-manager", "oci://quay.io/jetstack/charts/cert-manager", "1.17.0")
            .WithHelmValue("crds.enabled", "true")
            .WithHelmValue("config.enableGatewayAPI", "true");
 
        Assert.Equal(2, chart.Resource.Values.Count);
        Assert.Equal("true", chart.Resource.Values["crds.enabled"]);
        Assert.Equal("true", chart.Resource.Values["config.enableGatewayAPI"]);
    }
 
    [Fact]
    public void AddHelmChart_WithNamespace_SetsNamespace()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart("nginx", "oci://ghcr.io/nginx/charts/nginx-ingress", "1.5.0")
            .WithNamespace("ingress-nginx");
 
        Assert.Equal("ingress-nginx", chart.Resource.Namespace);
    }
 
    [Fact]
    public void AddHelmChart_WithReleaseName_SetsReleaseName()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart("nginx", "oci://ghcr.io/nginx/charts/nginx-ingress", "1.5.0")
            .WithReleaseName("my-nginx");
 
        Assert.Equal("my-nginx", chart.Resource.ReleaseName);
    }
 
    [Fact]
    public void AddHelmChart_DefaultsNamespaceAndReleaseNameToNull()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart("test", "oci://example.com/charts/test", "1.0.0");
 
        Assert.Null(chart.Resource.Namespace);
        Assert.Null(chart.Resource.ReleaseName);
    }
 
    [Fact]
    public void AddHelmChart_HasPipelineStepAnnotation()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart("cert-manager", "oci://quay.io/jetstack/charts/cert-manager", "1.17.0");
 
        Assert.True(
            chart.Resource.TryGetAnnotationsOfType<PipelineStepAnnotation>(out var annotations),
            "Helm chart resource should have a PipelineStepAnnotation");
        Assert.Single(annotations);
    }
 
    [Fact]
    public void AddHelmChart_MultipleCharts_AllRegistered()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart1 = k8s.AddHelmChart("cert-manager", "oci://quay.io/jetstack/charts/cert-manager", "1.17.0");
        var chart2 = k8s.AddHelmChart("nginx", "oci://ghcr.io/nginx/charts/nginx-ingress", "1.5.0");
 
        Assert.NotEqual(chart1.Resource.Name, chart2.Resource.Name);
        Assert.Equal("env", chart1.Resource.Parent.Name);
        Assert.Equal("env", chart2.Resource.Parent.Name);
    }
 
    [Fact]
    public void AddHelmChart_ThrowsOnNullOrEmptyArguments()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        Assert.Throws<ArgumentException>(() => k8s.AddHelmChart("", "oci://example.com/chart", "1.0.0"));
        Assert.Throws<ArgumentException>(() => k8s.AddHelmChart("test", "", "1.0.0"));
        Assert.Throws<ArgumentException>(() => k8s.AddHelmChart("test", "oci://example.com/chart", ""));
    }
 
    [Fact]
    public void AddHelmChart_ThrowsOnNullArguments()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        Assert.Throws<ArgumentNullException>(() =>
            ((IResourceBuilder<KubernetesEnvironmentResource>)null!).AddHelmChart("test", "oci://example.com/chart", "1.0.0"));
        Assert.Throws<ArgumentNullException>(() => k8s.AddHelmChart(null!, "oci://example.com/chart", "1.0.0"));
        Assert.Throws<ArgumentNullException>(() => k8s.AddHelmChart("test", null!, "1.0.0"));
        Assert.Throws<ArgumentNullException>(() => k8s.AddHelmChart("test", "oci://example.com/chart", null!));
    }
 
    [Theory]
    [InlineData("evil chart")]                  // whitespace
    [InlineData("evil\"chart")]                 // double quote
    [InlineData("evil\nchart")]                 // newline
    [InlineData("--evil")]                      // already validated by chartReference allowlist
    [InlineData("oci://repo/chart;rm -rf /")]   // semicolon
    public void AddHelmChart_RejectsMaliciousChartReference(string chartReference)
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        // Note: the leading '--' case is currently allowed by the allowlist alone; it would
        // still be a positional arg to helm and is harmless (helm rejects unknown flags).
        // Whitespace, quotes, newlines, and semicolons must all be rejected to prevent
        // argument-string injection into the helm process.
        if (chartReference == "--evil")
        {
            // "--evil" passes the character allowlist but isn't a real injection vector
            // because it would be parsed by helm as a chart-reference positional arg.
            return;
        }
 
        Assert.Throws<ArgumentException>(() => k8s.AddHelmChart("test", chartReference, "1.0.0"));
    }
 
    [Theory]
    [InlineData("oci://quay.io/jetstack/charts/cert-manager")]   // OCI URL
    [InlineData("oci://ghcr.io/stefanprodan/charts/podinfo")]    // OCI URL with ghcr.io
    [InlineData("https://charts.example.com/repo/chart")]        // HTTPS URL
    [InlineData("http://charts.example.com/repo/chart")]         // HTTP URL
    [InlineData("myrepo/mychart")]                               // repo/chart
    [InlineData("mychart-1.0.0.tgz")]                            // packaged chart filename
    [InlineData("./local-chart")]                                // relative local path
    [InlineData("chart+extra~tag")]                              // plus and tilde chars
    [InlineData("oci://registry:5000/charts/app")]               // registry with port
    [InlineData("oci://user@registry.io/charts/app")]            // registry with @
    public void AddHelmChart_AcceptsValidChartReferences(string chartReference)
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", chartReference, "1.0.0");
        Assert.Equal(chartReference, chart.Resource.ChartReference);
    }
 
    [Theory]
    [InlineData("not-a-version")]
    [InlineData("1.2.3.4")]
    [InlineData("1.2.3-")]
    [InlineData("01.2.3")]
    public void AddHelmChart_RejectsInvalidChartVersion(string chartVersion)
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        Assert.Throws<ArgumentException>(() => k8s.AddHelmChart("test", "oci://example.com/chart", chartVersion));
    }
 
    [Theory]
    [InlineData("1.2.3")]
    [InlineData("v1.2.3")]
    [InlineData("1")]
    [InlineData("1.2")]
    [InlineData("1.2.3-beta.1+ef365")]
    public void AddHelmChart_AcceptsValidChartVersion(string chartVersion)
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", chartVersion);
        Assert.Equal(chartVersion, chart.Resource.ChartVersion);
    }
 
    [Theory]
    [InlineData("UpperCase")]
    [InlineData("with space")]
    [InlineData("trailing-")]
    [InlineData("-leading")]
    public void WithNamespace_RejectsInvalidDnsLabel(string @namespace)
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0");
        Assert.Throws<ArgumentException>(() => chart.WithNamespace(@namespace));
    }
 
    [Fact]
    public void WithNamespace_RejectsTooLong()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0");
        var tooLong = new string('a', 64);
        Assert.Throws<ArgumentException>(() => chart.WithNamespace(tooLong));
    }
 
    [Theory]
    [InlineData("UpperCase")]
    [InlineData("with space")]
    [InlineData("ends-with-")]
    public void WithReleaseName_RejectsInvalidDnsLabel(string releaseName)
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0");
        Assert.Throws<ArgumentException>(() => chart.WithReleaseName(releaseName));
    }
 
    [Fact]
    public void WithReleaseName_RejectsTooLong()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0");
        var tooLong = new string('a', 54);
        Assert.Throws<ArgumentException>(() => chart.WithReleaseName(tooLong));
    }
 
    [Theory]
    [InlineData("with space")]
    [InlineData("with\"quote")]
    [InlineData("with\nnewline")]
    public void WithHelmValue_RejectsInvalidKey(string key)
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0");
        Assert.Throws<ArgumentException>(() => chart.WithHelmValue(key, "value"));
    }
 
    [Theory]
    [InlineData("evil\"value")]
    [InlineData("evil\\value")]
    [InlineData("evil\nvalue")]
    public void WithHelmValue_RejectsInvalidValue(string value)
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0");
        Assert.Throws<ArgumentException>(() => chart.WithHelmValue("key", value));
    }
 
    [Fact]
    public void WithHelmValue_AcceptsValueWithSpacesAndSpecialChars()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0")
            .WithHelmValue("config.greeting", "hello world!")
            .WithHelmValue("config.list", "a,b,c");
 
        Assert.Equal("hello world!", chart.Resource.Values["config.greeting"]);
        Assert.Equal("a,b,c", chart.Resource.Values["config.list"]);
    }
 
    [Fact]
    public void WithHelmValue_AcceptsBracketKey()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0")
            .WithHelmValue("args[0]", "--foo");
 
        Assert.Equal("--foo", chart.Resource.Values["args[0]"]);
    }
 
    [Fact]
    public void WithDestroy_DefaultsToFalse()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0");
 
        Assert.False(chart.Resource.DestroyOnUninstall);
    }
 
    [Fact]
    public void WithDestroy_OptsIn()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0")
            .WithDestroy();
 
        Assert.True(chart.Resource.DestroyOnUninstall);
    }
 
    [Fact]
    public void WithDestroy_ReturnsBuilderForChaining()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0");
        var returned = chart.WithDestroy();
 
        Assert.Same(chart, returned);
    }
 
    [Fact]
    public void WithDestroy_ThrowsOnNullBuilder()
    {
        Assert.Throws<ArgumentNullException>(() =>
            ((IResourceBuilder<KubernetesHelmChartResource>)null!).WithDestroy());
    }
 
    [Fact]
    public void WithForceConflicts_DefaultsToFalse()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0");
 
        Assert.False(chart.Resource.ForceConflicts);
    }
 
    [Fact]
    public void WithForceConflicts_OptsIn()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0")
            .WithForceConflicts();
 
        Assert.True(chart.Resource.ForceConflicts);
    }
 
    [Fact]
    public void WithForceConflicts_ReturnsBuilderForChaining()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0");
        var returned = chart.WithForceConflicts();
 
        Assert.Same(chart, returned);
    }
 
    [Fact]
    public void WithForceConflicts_ThrowsOnNullBuilder()
    {
        Assert.Throws<ArgumentNullException>(() =>
            ((IResourceBuilder<KubernetesHelmChartResource>)null!).WithForceConflicts());
    }
 
    [Fact]
    public async Task PipelineStepFactory_WithoutDestroy_ProducesOnlyInstallStep()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0");
 
        var steps = await CreateStepsAsync(builder, chart.Resource);
 
        Assert.Single(steps);
        Assert.Equal("helm-install-test", steps[0].Name);
    }
 
    [Fact]
    public async Task PipelineStepFactory_WithDestroy_ProducesInstallAndUninstallSteps()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0")
            .WithDestroy();
 
        var steps = await CreateStepsAsync(builder, chart.Resource);
 
        Assert.Equal(2, steps.Count);
        var installStep = Assert.Single(steps, s => s.Name == "helm-install-test");
        var uninstallStep = Assert.Single(steps, s => s.Name == "helm-uninstall-test");
 
        // Install runs after the env's helm-deploy step and is required by the deploy aggregator.
        Assert.Contains($"helm-deploy-env", installStep.DependsOnSteps);
        Assert.Contains(WellKnownPipelineSteps.Deploy, installStep.RequiredBySteps);
 
        // Uninstall slots into the destroy pipeline.
        Assert.Contains(WellKnownPipelineSteps.DestroyPrereq, uninstallStep.DependsOnSteps);
        Assert.Contains(WellKnownPipelineSteps.Destroy, uninstallStep.RequiredBySteps);
    }
 
    [Fact]
    public async Task PipelineStepFactory_InstallStepDescription_UsesLiveResourceState()
    {
        // Verifies the description is computed at step-creation time from the resource so it
        // can't drift from the actual install args (the resource's chart props are now
        // immutable after construction).
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.2.3");
 
        var steps = await CreateStepsAsync(builder, chart.Resource);
 
        var installStep = Assert.Single(steps, s => s.Name == "helm-install-test");
        Assert.Contains("oci://example.com/chart", installStep.Description);
        Assert.Contains("1.2.3", installStep.Description);
    }
 
    [Fact]
    public async Task PipelineStepFactory_UninstallStepDescription_FallsBackToResourceNameForNamespace()
    {
        // When Namespace is not set, the uninstall step description should fall back to using
        // the resource name as the namespace (the same fallback used at install time).
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart("my-release", "oci://example.com/chart", "1.0.0")
            .WithDestroy();
 
        Assert.Null(chart.Resource.Namespace);
 
        var steps = await CreateStepsAsync(builder, chart.Resource);
 
        var uninstallStep = Assert.Single(steps, s => s.Name == "helm-uninstall-my-release");
        Assert.Contains("my-release", uninstallStep.Description);
    }
 
    [Fact]
    public async Task PipelineStepFactory_UninstallStepDescription_UsesExplicitNamespace()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart("my-release", "oci://example.com/chart", "1.0.0")
            .WithNamespace("custom-ns")
            .WithDestroy();
 
        var steps = await CreateStepsAsync(builder, chart.Resource);
 
        var uninstallStep = Assert.Single(steps, s => s.Name == "helm-uninstall-my-release");
        Assert.Contains("custom-ns", uninstallStep.Description);
    }
 
    [Theory]
    [InlineData("MyChart")]                                                     // uppercase — valid Aspire name, invalid DNS label
    [InlineData("abcdefghij-abcdefghij-abcdefghij-abcdefghij-abcdefghij")]      // 54 chars — valid Aspire (<=64), exceeds Helm release name limit (53)
    public async Task PipelineStepFactory_RejectsResourceNameThatIsNotValidDnsLabel(string resourceName)
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart(resourceName, "oci://example.com/chart", "1.0.0");
 
        // The factory should refuse to derive release/namespace from a resource name that
        // can't be used as a DNS label, surfacing a clear error before helm runs.
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => CreateStepsAsync(builder, chart.Resource));
        Assert.Contains(resourceName, ex.Message);
        Assert.Contains("WithReleaseName", ex.Message, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task PipelineStepFactory_AcceptsInvalidResourceNameWhenOverridesProvided()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart("MyChart", "oci://example.com/chart", "1.0.0")
            .WithReleaseName("my-release")
            .WithNamespace("my-ns");
 
        // With explicit overrides the resource name is never used as a fallback, so the
        // step factory must not throw.
        var steps = await CreateStepsAsync(builder, chart.Resource);
        Assert.Single(steps);
    }
 
    private static async Task<List<PipelineStep>> CreateStepsAsync(
        IDistributedApplicationTestingBuilder builder,
        KubernetesHelmChartResource resource)
    {
        using var serviceProvider = builder.Services.BuildServiceProvider();
        var pipelineContext = new PipelineContext(
            serviceProvider.GetRequiredService<DistributedApplicationModel>(),
            new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish),
            serviceProvider,
            Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance,
            CancellationToken.None);
 
        var results = new List<PipelineStep>();
        foreach (var annotation in resource.Annotations.OfType<PipelineStepAnnotation>())
        {
            results.AddRange(await annotation.CreateStepsAsync(new PipelineStepFactoryContext
            {
                PipelineContext = pipelineContext,
                Resource = resource
            }));
        }
 
        return results;
    }
 
    [Fact]
    public void WithHelmValue_OverwritesExistingKey()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var k8s = builder.AddKubernetesEnvironment("env");
        var chart = k8s.AddHelmChart("test", "oci://example.com/chart", "1.0.0")
            .WithHelmValue("key", "value1")
            .WithHelmValue("key", "value2");
 
        Assert.Single(chart.Resource.Values);
        Assert.Equal("value2", chart.Resource.Values["key"]);
    }
}