// 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 ASPIRECOMPUTE002
#pragma warning disable ASPIRECOMPUTE003
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREPIPELINES002
#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIRECONTAINERRUNTIME001
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Tests;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
namespace Aspire.Hosting.Kubernetes.Tests;
public class KubernetesDeployTests(ITestOutputHelper outputHelper)
{
[Fact]
public void AddKubernetesEnvironment_AddsDefaultHelmEngine()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env");
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.NotNull(env.DeploymentEngineStepsFactory);
}
[Fact]
public void WithHelm_ReplacesExistingEngine()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var envBuilder = builder.AddKubernetesEnvironment("env");
// The default engine is added by AddKubernetesEnvironment
// Calling WithHelm should replace it
envBuilder.WithHelm();
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.NotNull(env.DeploymentEngineStepsFactory);
}
[Fact]
public void WithHelm_ConfiguresNamespace()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithNamespace("production"));
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.True(env.TryGetLastAnnotation<KubernetesNamespaceAnnotation>(out var nsAnnotation));
Assert.NotNull(nsAnnotation.Namespace);
}
[Fact]
public void WithHelm_ConfiguresReleaseName()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithReleaseName("my-release"));
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.True(env.TryGetLastAnnotation<HelmReleaseNameAnnotation>(out var annotation));
Assert.NotNull(annotation.ReleaseName);
}
[Fact]
public void WithHelm_ConfiguresChartVersion()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithChartVersion("2.0.0"));
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.True(env.TryGetLastAnnotation<HelmChartVersionAnnotation>(out var annotation));
Assert.NotNull(annotation.Version);
}
[Fact]
public void WithHelm_ConfiguresAllSettings()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
helm.WithNamespace("staging");
helm.WithReleaseName("my-app");
helm.WithChartVersion("1.2.3");
});
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.True(env.TryGetLastAnnotation<KubernetesNamespaceAnnotation>(out _));
Assert.True(env.TryGetLastAnnotation<HelmReleaseNameAnnotation>(out _));
Assert.True(env.TryGetLastAnnotation<HelmChartVersionAnnotation>(out _));
}
[Fact]
public void WithHelm_NamespaceAcceptsParameter()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var nsParam = builder.AddParameter("k8s-namespace");
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithNamespace(nsParam));
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.True(env.TryGetLastAnnotation<KubernetesNamespaceAnnotation>(out var annotation));
Assert.NotNull(annotation.Namespace);
}
[Fact]
public void WithHelm_ReleaseNameAcceptsParameter()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var releaseParam = builder.AddParameter("helm-release");
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithReleaseName(releaseParam));
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.True(env.TryGetLastAnnotation<HelmReleaseNameAnnotation>(out var annotation));
Assert.NotNull(annotation.ReleaseName);
}
[Fact]
public void WithHelm_ChartVersionAcceptsParameter()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var versionParam = builder.AddParameter("chart-version");
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithChartVersion(versionParam));
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.True(env.TryGetLastAnnotation<HelmChartVersionAnnotation>(out var annotation));
Assert.NotNull(annotation.Version);
}
[Fact]
public void WithHelm_ConfiguresChartName()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithChartName("my-chart"));
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.True(env.TryGetLastAnnotation<HelmChartNameAnnotation>(out var annotation));
Assert.NotNull(annotation.Name);
}
[Fact]
public void WithHelm_ConfiguresChartDescription()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithChartDescription("My chart description"));
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.True(env.TryGetLastAnnotation<HelmChartDescriptionAnnotation>(out var annotation));
Assert.NotNull(annotation.Description);
}
[Fact]
public void WithHelm_ChartNameAcceptsParameter()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var nameParam = builder.AddParameter("chart-name");
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithChartName(nameParam));
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.True(env.TryGetLastAnnotation<HelmChartNameAnnotation>(out var annotation));
Assert.NotNull(annotation.Name);
}
[Fact]
public void WithHelm_ChartDescriptionAcceptsParameter()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var descParam = builder.AddParameter("chart-description");
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithChartDescription(descParam));
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.True(env.TryGetLastAnnotation<HelmChartDescriptionAnnotation>(out var annotation));
Assert.NotNull(annotation.Description);
}
[Fact]
public void WithHelm_ConfiguresAllChartMetadata()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
helm.WithChartName("acme-app");
helm.WithChartVersion("1.2.3");
helm.WithChartDescription("ACME application chart");
});
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.True(env.TryGetLastAnnotation<HelmChartNameAnnotation>(out _));
Assert.True(env.TryGetLastAnnotation<HelmChartVersionAnnotation>(out _));
Assert.True(env.TryGetLastAnnotation<HelmChartDescriptionAnnotation>(out _));
}
[Fact]
public void WithHelm_RepeatedChartNameCallsReplaceAnnotations()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithChartName("first"))
.WithHelm(helm => helm.WithChartName("second"));
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
var annotations = env.Annotations.OfType<HelmChartNameAnnotation>().ToList();
Assert.Single(annotations);
}
[Fact]
public void WithHelm_RepeatedCallsReplaceAnnotations()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithNamespace("first"))
.WithHelm(helm => helm.WithNamespace("second"));
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
// Should have only one namespace annotation (replaced, not stacked)
var nsAnnotations = env.Annotations.OfType<KubernetesNamespaceAnnotation>().ToList();
Assert.Single(nsAnnotations);
}
[Fact]
public void WithHelm_WithoutCallback_StillAddsEngine()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm();
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.NotNull(env.DeploymentEngineStepsFactory);
}
[Fact]
public async Task HelmDeployStepIsCreatedInDiagnosticsMode()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Diagnostics);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("api", "myimage");
using var app = builder.Build();
await app.RunAsync();
var logs = mockActivityReporter.LoggedMessages
.Where(s => s.StepTitle == "diagnostics")
.Select(s => s.Message)
.ToList();
outputHelper.WriteLine("Diagnostics logs:");
foreach (var log in logs)
{
outputHelper.WriteLine($" {log}");
}
// Verify helm-deploy step exists
Assert.Contains(logs, msg => msg.Contains("helm-deploy-env"));
// Verify publish step exists
Assert.Contains(logs, msg => msg.Contains("publish-env"));
// Verify prepare step exists
Assert.Contains(logs, msg => msg.Contains("prepare-env"));
}
[Fact]
public async Task HelmDeployStep_DependsOnPublishStep()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Diagnostics);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("api", "myimage");
using var app = builder.Build();
await app.RunAsync();
var logs = mockActivityReporter.LoggedMessages
.Where(s => s.StepTitle == "diagnostics")
.Select(s => s.Message)
.ToList();
outputHelper.WriteLine("Diagnostics logs:");
foreach (var log in logs)
{
outputHelper.WriteLine($" {log}");
}
// Verify prepare-env depends on publish-env
var prepareLines = logs.Where(l => l.Contains("prepare-env")).ToList();
Assert.Contains(prepareLines, msg => msg.Contains("publish-env"));
// Verify helm-deploy-env depends on prepare-env
var helmDeployLines = logs.Where(l => l.Contains("helm-deploy-env")).ToList();
Assert.Contains(helmDeployLines, msg => msg.Contains("prepare-env"));
}
[Fact]
public async Task HelmDeployStep_DependsOnPushSteps_WhenRegistryConfigured()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Diagnostics);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.AddKubernetesEnvironment("env");
builder.AddContainerRegistry("registry", "myregistry.azurecr.io", "myrepo");
builder.AddProject<Projects.ServiceA>("api")
.PublishAsDockerFile();
using var app = builder.Build();
await app.RunAsync();
var logs = mockActivityReporter.LoggedMessages
.Where(s => s.StepTitle == "diagnostics")
.Select(s => s.Message)
.ToList();
outputHelper.WriteLine("Diagnostics logs:");
foreach (var log in logs)
{
outputHelper.WriteLine($" {log}");
}
// Verify push-api step exists
Assert.Contains(logs, msg => msg.Contains("push-api"));
// Verify helm-deploy-env depends on push-api
var helmDeployLines = logs.Where(l => l.Contains("helm-deploy-env")).ToList();
Assert.Contains(helmDeployLines, msg => msg.Contains("push-api"));
}
[Fact]
public async Task PrintSummaryStepIsCreated()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Diagnostics);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("api", "myimage")
.WithHttpEndpoint(targetPort: 8080);
using var app = builder.Build();
await app.RunAsync();
var logs = mockActivityReporter.LoggedMessages
.Where(s => s.StepTitle == "diagnostics")
.Select(s => s.Message)
.ToList();
outputHelper.WriteLine("Diagnostics logs:");
foreach (var log in logs)
{
outputHelper.WriteLine($" {log}");
}
// Verify print-summary step exists for the api resource
Assert.Contains(logs, msg => msg.Contains("print-api-summary"));
// Verify print-summary depends on helm-deploy
var printLines = logs.Where(l => l.Contains("print-api-summary")).ToList();
Assert.Contains(printLines, msg => msg.Contains("helm-deploy-env"));
}
[Fact]
public async Task HelmUninstallStepIsCreated()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Diagnostics);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("api", "myimage");
using var app = builder.Build();
await app.RunAsync();
var logs = mockActivityReporter.LoggedMessages
.Where(s => s.StepTitle == "diagnostics")
.Select(s => s.Message)
.ToList();
outputHelper.WriteLine("Diagnostics logs:");
foreach (var log in logs)
{
outputHelper.WriteLine($" {log}");
}
// Verify helm-uninstall step exists
Assert.Contains(logs, msg => msg.Contains("helm-uninstall-env"));
}
[Fact]
public async Task HelmUninstallStep_RequiredByDestroy()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Diagnostics);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("api", "myimage");
using var app = builder.Build();
await app.RunAsync();
var logs = mockActivityReporter.LoggedMessages
.Where(s => s.StepTitle == "diagnostics")
.Select(s => s.Message)
.ToList();
outputHelper.WriteLine("Diagnostics logs:");
foreach (var log in logs)
{
outputHelper.WriteLine($" {log}");
}
// Verify helm-uninstall-env depends on destroy-helm-env (the prompt layer)
var helmUninstallLines = logs.Where(l => l.Contains("helm-uninstall-env")).ToList();
Assert.Contains(helmUninstallLines, msg => msg.Contains("destroy-helm-env"));
}
[Fact]
public async Task HelmUninstallStep_DependsOnCheckHelmPrereqs()
{
// Regression coverage for PR #17491 review feedback: direct uninstall
// invokes `helm`, so it must gate on the same prereq check as deploy.
// `destroy-helm-{env}` defers the check until saved state exists so the
// no-state path can still report "Nothing to destroy" without Helm.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Diagnostics);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("api", "myimage");
using var app = builder.Build();
await app.RunAsync();
var logs = mockActivityReporter.LoggedMessages
.Where(s => s.StepTitle == "diagnostics")
.Select(s => s.Message)
.ToList();
var diagnosticLines = string.Join('\n', logs)
.Split('\n')
.Select(l => l.Trim())
.ToList();
var destroyTargetLine = diagnosticLines.IndexOf("If targeting 'destroy-helm-env':");
Assert.InRange(destroyTargetLine, 0, diagnosticLines.Count - 2);
Assert.Equal("Direct dependencies: destroy-prereq", diagnosticLines[destroyTargetLine + 1]);
var uninstallTargetLine = diagnosticLines.IndexOf("If targeting 'helm-uninstall-env':");
Assert.InRange(uninstallTargetLine, 0, diagnosticLines.Count - 2);
Assert.Equal("Direct dependencies: check-helm-prereqs-env", diagnosticLines[uninstallTargetLine + 1]);
}
[Fact]
public async Task PerChartHelmUninstallStep_DependsOnCheckHelmPrereqs()
{
// Regression coverage for PR #17491 review feedback: per-chart
// `helm-uninstall-{name}` steps created by `AddHelmChart(...).WithDestroy()`
// must depend on `check-helm-prereqs-{env}`. The install side is covered
// transitively (via `helm-deploy-{env}`), but the uninstall side previously
// only set `DependsOnSteps = [DestroyPrereq]`, so a missing or too-old
// Helm during chart teardown would bypass the validator and surface as
// the cryptic spawn / unknown-flag error this PR exists to prevent.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Diagnostics);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
var k8s = builder.AddKubernetesEnvironment("env");
k8s.AddHelmChart("podinfo", "oci://ghcr.io/stefanprodan/charts/podinfo", "6.7.1")
.WithDestroy();
using var app = builder.Build();
await app.RunAsync();
var logs = mockActivityReporter.LoggedMessages
.Where(s => s.StepTitle == "diagnostics")
.Select(s => s.Message)
.ToList();
var chartUninstallLines = logs.Where(l => l.Contains("helm-uninstall-podinfo")).ToList();
Assert.NotEmpty(chartUninstallLines);
Assert.Contains(chartUninstallLines, msg => msg.Contains("check-helm-prereqs-env"));
}
[Fact]
public async Task MultipleContainersGenerateMultiplePrintSummarySteps()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Diagnostics);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("api", "myimage")
.WithHttpEndpoint(targetPort: 8080);
builder.AddContainer("web", "mywebimage")
.WithHttpEndpoint(targetPort: 3000);
using var app = builder.Build();
await app.RunAsync();
var logs = mockActivityReporter.LoggedMessages
.Where(s => s.StepTitle == "diagnostics")
.Select(s => s.Message)
.ToList();
outputHelper.WriteLine("Diagnostics logs:");
foreach (var log in logs)
{
outputHelper.WriteLine($" {log}");
}
// Verify both print-summary steps exist
Assert.Contains(logs, msg => msg.Contains("print-api-summary"));
Assert.Contains(logs, msg => msg.Contains("print-web-summary"));
}
[Fact]
public void WithHelm_ThrowsOnNullBuilder()
{
IResourceBuilder<KubernetesEnvironmentResource> builder = null!;
Assert.Throws<ArgumentNullException>(() => builder.WithHelm());
}
[Fact]
public void HelmChartOptions_WithNamespace_ThrowsOnEmpty()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
Assert.Throws<ArgumentException>(() => helm.WithNamespace(string.Empty));
});
}
[Fact]
public void HelmChartOptions_WithReleaseName_ThrowsOnEmpty()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
Assert.Throws<ArgumentException>(() => helm.WithReleaseName(string.Empty));
});
}
[Fact]
public void HelmChartOptions_WithChartVersion_ThrowsOnEmpty()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
Assert.Throws<ArgumentException>(() => helm.WithChartVersion(string.Empty));
});
}
[Fact]
public void HelmChartOptions_WithChartName_ThrowsOnEmpty()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
Assert.Throws<ArgumentException>(() => helm.WithChartName(string.Empty));
});
}
[Fact]
public void HelmChartOptions_WithChartDescription_ThrowsOnEmpty()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
Assert.Throws<ArgumentException>(() => helm.WithChartDescription(string.Empty));
});
}
[Theory]
[InlineData("invalid name")]
[InlineData("with/slash")]
[InlineData("with*star")]
public void HelmChartOptions_WithChartName_ThrowsOnInvalidName(string value)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
Assert.Throws<ArgumentException>(() => helm.WithChartName(value));
});
}
[Fact]
public void HelmChartOptions_WithChartName_ThrowsWhenTooLong()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
Assert.Throws<ArgumentException>(() => helm.WithChartName(new string('a', 251)));
});
}
[Theory]
[InlineData("simple-chart")]
[InlineData("chart_with_underscore")]
[InlineData("chart.with.dots")]
[InlineData("Chart-Mixed-Case")]
public void HelmChartOptions_WithChartName_AcceptsValidNames(string value)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithChartName(value));
var app = builder.Build();
var env = app.Services.GetRequiredService<DistributedApplicationModel>()
.Resources.OfType<KubernetesEnvironmentResource>().Single();
Assert.True(env.TryGetLastAnnotation<HelmChartNameAnnotation>(out _));
}
[Theory]
[InlineData("Production")]
[InlineData("my_namespace")]
[InlineData("-staging")]
[InlineData("staging-")]
public void HelmChartOptions_WithNamespace_ThrowsOnInvalidName(string value)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
Assert.Throws<ArgumentException>(() => helm.WithNamespace(value));
});
}
[Theory]
[InlineData("MyRelease")]
[InlineData("release_name")]
[InlineData("-release")]
[InlineData("release-")]
public void HelmChartOptions_WithReleaseName_ThrowsOnInvalidName(string value)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
Assert.Throws<ArgumentException>(() => helm.WithReleaseName(value));
});
}
[Theory]
[InlineData("latest")]
[InlineData("release-candidate")]
[InlineData("1.0.0.0")]
[InlineData("01.2.3")]
public void HelmChartOptions_WithChartVersion_ThrowsOnInvalidVersion(string value)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
Assert.Throws<ArgumentException>(() => helm.WithChartVersion(value));
});
}
[Theory]
[InlineData("1")]
[InlineData("1.2")]
[InlineData("1.2.3")]
[InlineData("1.2.3-beta.1")]
[InlineData("1.2.3-beta.1+ef365")]
[InlineData("v1")]
[InlineData("v1.2")]
[InlineData("v1.2.3")]
[InlineData("V1.2.3")]
public void HelmChartOptions_WithChartVersion_AcceptsHelmCompatibleVersions(string value)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
helm.WithChartVersion(value);
});
}
[Fact]
public async Task WithHelm_PublishThrowsWhenResolvedParameterChartVersionIsInvalid()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var versionParam = builder.AddParameter("chart-version", "not-a-version");
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithChartVersion(versionParam));
builder.AddContainer("svc", "nginx");
using var app = builder.Build();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => ExecutePipelineAsync(app));
Assert.Contains("not-a-version", ex.Message);
Assert.IsType<ArgumentException>(ex.InnerException);
}
private static Task ExecutePipelineAsync(DistributedApplication app)
{
var pipeline = app.Services.GetRequiredService<IDistributedApplicationPipeline>();
var context = new PipelineContext(
app.Services.GetRequiredService<DistributedApplicationModel>(),
app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
app.Services,
app.Services.GetRequiredService<Microsoft.Extensions.Logging.ILogger<KubernetesDeployTests>>(),
CancellationToken.None);
return pipeline.ExecuteAsync(context);
}
[Fact]
public async Task WithHelm_PublishWritesChartMetadataToChartYaml()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
helm.WithChartName("acme-app");
helm.WithChartVersion("2.5.0");
helm.WithChartDescription("ACME application chart");
});
builder.AddContainer("svc", "nginx");
using var app = builder.Build();
await app.RunAsync();
var chartYaml = await File.ReadAllTextAsync(Path.Combine(workspace.Path, "Chart.yaml"));
Assert.Contains("name: \"acme-app\"", chartYaml);
Assert.Contains("version: \"2.5.0\"", chartYaml);
Assert.Contains("appVersion: \"2.5.0\"", chartYaml);
Assert.Contains("description: \"ACME application chart\"", chartYaml);
}
[Fact]
public async Task WithHelm_PublishResolvesParameterChartMetadata()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var nameParam = builder.AddParameter("chart-name", "param-chart");
var versionParam = builder.AddParameter("chart-version", "3.0.0");
var descParam = builder.AddParameter("chart-description", "Param-resolved description");
builder.AddKubernetesEnvironment("env")
.WithHelm(helm =>
{
helm.WithChartName(nameParam);
helm.WithChartVersion(versionParam);
helm.WithChartDescription(descParam);
});
builder.AddContainer("svc", "nginx");
using var app = builder.Build();
await app.RunAsync();
var chartYaml = await File.ReadAllTextAsync(Path.Combine(workspace.Path, "Chart.yaml"));
Assert.Contains("name: \"param-chart\"", chartYaml);
Assert.Contains("version: \"3.0.0\"", chartYaml);
Assert.Contains("appVersion: \"3.0.0\"", chartYaml);
Assert.Contains("description: \"Param-resolved description\"", chartYaml);
}
[Fact]
public async Task PublishWritesChartNameTemplateLabelOnResources()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env")
.WithHelm(helm => helm.WithChartName("custom-chart"));
builder.AddContainer("svc", "nginx");
using var app = builder.Build();
await app.RunAsync();
var deploymentYaml = await File.ReadAllTextAsync(Path.Combine(workspace.Path, "templates", "svc", "deployment.yaml"));
// The label should use the Helm template variable, not the literal chart name.
Assert.Contains("app.kubernetes.io/name: \"{{ .Chart.Name }}\"", deploymentYaml);
Assert.DoesNotContain("app.kubernetes.io/name: \"custom-chart\"", deploymentYaml);
}
[Fact]
public async Task ContainerRegistryIsWiredIntoDeploymentTarget()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Diagnostics);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.AddKubernetesEnvironment("env");
builder.AddContainerRegistry("registry", "myregistry.azurecr.io", "myrepo");
var containerBuilder = builder.AddContainer("api", "myimage")
.WithHttpEndpoint(targetPort: 8080);
using var app = builder.Build();
// Get the resource before running (service provider gets disposed after run)
var container = containerBuilder.Resource;
await app.RunAsync();
Assert.True(container.TryGetLastAnnotation<DeploymentTargetAnnotation>(out var dta));
Assert.NotNull(dta.ComputeEnvironment);
}
[Fact]
public async Task PrepareAsync_ResolvesSecretParameterValues()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var outputPath = Path.Combine(workspace.Path, "env");
Directory.CreateDirectory(outputPath);
// Write a values.yaml with empty secret placeholders
var valuesYaml = """
parameters: {}
secrets:
myapp:
password: ""
config: {}
""";
await File.WriteAllTextAsync(Path.Combine(outputPath, "values.yaml"), valuesYaml);
var environment = new KubernetesEnvironmentResource("env");
// Create a parameter with a known value and add it to CapturedHelmValues
var paramResource = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(
TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish),
"mypassword",
special: false);
environment.CapturedHelmValues.Add(
new KubernetesEnvironmentResource.CapturedHelmValue(
"secrets", "myapp", "password", paramResource));
// Act
await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync(
outputPath, environment, CancellationToken.None);
// Assert: values.env.yaml should exist with the resolved value
var deployValuesPath = Path.Combine(outputPath, HelmDeploymentEngine.GetDeployValuesFileName("env"));
Assert.True(File.Exists(deployValuesPath), "values.env.yaml should be created");
var content = await File.ReadAllTextAsync(deployValuesPath);
Assert.Contains("secrets:", content);
Assert.Contains("myapp:", content);
Assert.Contains("password:", content);
// The password should NOT be empty
Assert.DoesNotContain("password: \"\"", content);
Assert.DoesNotContain("password: ''", content);
}
[Fact]
public async Task PrepareAsync_NoCapturedValues_DoesNotCreateDeployFile()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var outputPath = Path.Combine(workspace.Path, "env");
Directory.CreateDirectory(outputPath);
await File.WriteAllTextAsync(Path.Combine(outputPath, "values.yaml"), "parameters: {}\nsecrets: {}\nconfig: {}");
var environment = new KubernetesEnvironmentResource("env");
// Act: no captured values
await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync(
outputPath, environment, CancellationToken.None);
// Assert: no override file created
var deployValuesPath = Path.Combine(outputPath, HelmDeploymentEngine.GetDeployValuesFileName("env"));
Assert.False(File.Exists(deployValuesPath), "values.env.yaml should not be created when there are no captured values");
}
[Fact]
public async Task PrepareAsync_ResolvesMultipleParametersAcrossResources()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var outputPath = Path.Combine(workspace.Path, "env");
Directory.CreateDirectory(outputPath);
await File.WriteAllTextAsync(Path.Combine(outputPath, "values.yaml"), "parameters: {}\nsecrets: {}\nconfig: {}");
var environment = new KubernetesEnvironmentResource("env");
var appBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
// Create multiple parameters for different resources
var param1 = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(appBuilder, "cache-password", special: false);
var param2 = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(appBuilder, "db-password", special: false);
environment.CapturedHelmValues.Add(
new KubernetesEnvironmentResource.CapturedHelmValue("secrets", "cache", "password", param1));
environment.CapturedHelmValues.Add(
new KubernetesEnvironmentResource.CapturedHelmValue("secrets", "database", "password", param2));
// Act
await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync(
outputPath, environment, CancellationToken.None);
// Assert: both secrets should be in the deploy values file
var deployValuesPath = Path.Combine(outputPath, HelmDeploymentEngine.GetDeployValuesFileName("env"));
Assert.True(File.Exists(deployValuesPath));
var content = await File.ReadAllTextAsync(deployValuesPath);
Assert.Contains("cache:", content);
Assert.Contains("database:", content);
}
[Fact]
public void ResolveHelmExpressions_SubstitutesDirectReferences()
{
var lookup = new Dictionary<string, string>
{
["secrets.cache.password"] = "s3cret!"
};
var template = "{{ .Values.secrets.cache.password }}";
var result = HelmDeploymentEngine.ResolveHelmExpressions(template, lookup);
Assert.Equal("s3cret!", result);
}
[Fact]
public void ResolveHelmExpressions_SubstitutesCompositeConnectionString()
{
var lookup = new Dictionary<string, string>
{
["secrets.cache.password"] = "myP@ss"
};
var template = "cache:6379,password={{ .Values.secrets.cache.password }}";
var result = HelmDeploymentEngine.ResolveHelmExpressions(template, lookup);
Assert.Equal("cache:6379,password=myP@ss", result);
}
[Fact]
public void ResolveHelmExpressions_PreservesUnresolvedExpressions()
{
var lookup = new Dictionary<string, string>();
var template = "host={{ .Values.config.myapp.host }},password={{ .Values.secrets.cache.password }}";
var result = HelmDeploymentEngine.ResolveHelmExpressions(template, lookup);
// Unresolved expressions are preserved as-is
Assert.Equal(template, result);
}
[Fact]
public async Task PrepareAsync_ResolvesCrossResourceReferences()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var outputPath = Path.Combine(workspace.Path, "env");
Directory.CreateDirectory(outputPath);
await File.WriteAllTextAsync(Path.Combine(outputPath, "values.yaml"), "parameters: {}\nsecrets: {}\nconfig: {}");
var environment = new KubernetesEnvironmentResource("env");
var appBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
// Direct secret parameter for cache resource
var cachePassword = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(appBuilder, "cache-password", special: false);
environment.CapturedHelmValues.Add(
new KubernetesEnvironmentResource.CapturedHelmValue("secrets", "cache", "password", cachePassword));
// Cross-resource reference: server's connection string references cache's password
environment.CapturedHelmCrossReferences.Add(
new KubernetesEnvironmentResource.CapturedHelmCrossReference(
"secrets", "server", "ConnectionStrings__cache",
"cache:6379,password={{ .Values.secrets.cache.password }}"));
// Act
await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync(
outputPath, environment, CancellationToken.None);
// Assert
var deployValuesPath = Path.Combine(outputPath, HelmDeploymentEngine.GetDeployValuesFileName("env"));
Assert.True(File.Exists(deployValuesPath));
var content = await File.ReadAllTextAsync(deployValuesPath);
// Both the direct secret and the cross-reference should be present
Assert.Contains("cache:", content);
Assert.Contains("server:", content);
// The cross-reference should contain the resolved password, not the Helm expression
Assert.DoesNotContain("{{ .Values", content);
}
[Fact]
public void DeployValuesFileName_IncludesEnvironmentName()
{
Assert.Equal("values.myenv.yaml", HelmDeploymentEngine.GetDeployValuesFileName("myenv"));
Assert.Equal("values.k8s.yaml", HelmDeploymentEngine.GetDeployValuesFileName("k8s"));
}
[Fact]
public async Task PublishCapturesSecretParameterMappings()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Publish);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
var envBuilder = builder.AddKubernetesEnvironment("env");
var password = builder.AddParameter("my-password", secret: true);
builder.AddContainer("api", "myimage")
.WithHttpEndpoint(targetPort: 8080)
.WithEnvironment("DB_PASSWORD", password);
using var app = builder.Build();
var env = envBuilder.Resource;
await app.RunAsync();
// After publish, secret parameter mappings should be captured
Assert.NotEmpty(env.CapturedHelmValues);
Assert.Contains(env.CapturedHelmValues, c =>
c.Section == "secrets" &&
c.Parameter.Name == "my-password");
}
[Fact]
public async Task PublishCapturesImageReferencesForProjectResources()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Publish);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.AddContainerRegistry("registry", "myregistry.azurecr.io", "myrepo");
var envBuilder = builder.AddKubernetesEnvironment("env");
builder.AddProject<Projects.ServiceA>("api");
using var app = builder.Build();
var env = envBuilder.Resource;
await app.RunAsync();
// After publish, project resources should have image references captured for registry resolution
Assert.NotEmpty(env.CapturedHelmImageReferences);
Assert.Contains(env.CapturedHelmImageReferences, c =>
c.Section == "parameters" &&
c.ValueKey == "api_image" &&
c.Resource.Name == "api");
}
[Fact]
public void HelmValue_ImageResource_IsSetForProjectResources()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var env = builder.AddKubernetesEnvironment("env");
var project = builder.AddProject<Projects.ServiceA>("api");
var app = builder.Build();
var envResource = env.Resource;
// Create a KubernetesResource to test GetContainerImageName
var k8sResource = new KubernetesResource("api-k8s", project.Resource, envResource);
var imageName = k8sResource.GetContainerImageName(project.Resource);
// Should return a Helm expression
Assert.Contains("{{ .Values.", imageName);
// The Parameters dictionary should have the image entry with ImageResource set
var imageParam = k8sResource.Parameters.Values.SingleOrDefault(p => p.ImageResource is not null);
Assert.NotNull(imageParam);
Assert.Same(project.Resource, imageParam.ImageResource);
}
[Fact]
public void HelmValue_ImageResource_IsNotSetForContainerResources()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var env = builder.AddKubernetesEnvironment("env");
var container = builder.AddContainer("cache", "redis");
var app = builder.Build();
var envResource = env.Resource;
// Create a KubernetesResource to test GetContainerImageName
var k8sResource = new KubernetesResource("cache-k8s", container.Resource, envResource);
var imageName = k8sResource.GetContainerImageName(container.Resource);
// Container resources with existing images return the literal image name, not a Helm expression
Assert.DoesNotContain("{{ .Values.", imageName);
// No parameters should have ImageResource set (image is pre-existing, no build needed)
Assert.DoesNotContain(k8sResource.Parameters.Values, p => p.ImageResource is not null);
}
[Fact]
public async Task PrepareAsync_ResolvesImageReferencesWithRegistryPrefix()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var outputPath = Path.Combine(workspace.Path, "env");
Directory.CreateDirectory(outputPath);
// Write a values.yaml with the default image placeholder
var valuesYaml = """
parameters:
api:
api_image: "api:latest"
secrets: {}
config: {}
""";
await File.WriteAllTextAsync(Path.Combine(outputPath, "values.yaml"), valuesYaml);
// Set up a builder with a container registry and a project resource
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddContainerRegistry("registry", "myregistry.azurecr.io", "myrepo");
var project = builder.AddProject<Projects.ServiceA>("api");
using var app = builder.Build();
// Trigger BeforeStartEvent to propagate RegistryTargetAnnotation
await app.StartAsync();
await app.StopAsync();
var environment = new KubernetesEnvironmentResource("env");
// Simulate what publish captures: an image reference for the project resource
environment.CapturedHelmImageReferences.Add(
new KubernetesEnvironmentResource.CapturedHelmImageReference(
"parameters", "api", "api_image", project.Resource));
// Act
await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync(
outputPath, environment, CancellationToken.None);
// Assert: values.env.yaml should exist with registry-prefixed image
var deployValuesPath = Path.Combine(outputPath, HelmDeploymentEngine.GetDeployValuesFileName("env"));
Assert.True(File.Exists(deployValuesPath), "values.env.yaml should be created");
var content = await File.ReadAllTextAsync(deployValuesPath);
outputHelper.WriteLine(content);
// The image should be prefixed with the registry endpoint and repository
Assert.Contains("myregistry.azurecr.io/myrepo/api:latest", content);
}
[Fact]
public async Task ResolveAndWriteDeployValuesAsync_NoOverrideFileWhenNoCaptures()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var outputPath = Path.Combine(workspace.Path, "env");
Directory.CreateDirectory(outputPath);
var environment = new KubernetesEnvironmentResource("env");
// No captured values, cross-references, or image references
await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync(
outputPath, environment, CancellationToken.None);
// No override file should be created
var deployValuesPath = Path.Combine(outputPath, HelmDeploymentEngine.GetDeployValuesFileName("env"));
Assert.False(File.Exists(deployValuesPath));
}
[Fact]
public async Task CrossResourceSecretResolution_ValuesYamlKeysMatchTemplateReferences()
{
// Simulates the Redis+server scenario: cache has a password, server references it.
// The values.yaml keys must match the Helm expression paths in templates.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Publish);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
var envBuilder = builder.AddKubernetesEnvironment("env");
var cachePassword = builder.AddParameter("cache-password", secret: true);
// Cache container with a password
var cache = builder.AddContainer("cache", "redis")
.WithEndpoint(targetPort: 6379, name: "tcp")
.WithEnvironment("REDIS_PASSWORD", cachePassword);
// Server container that directly references the cache password
builder.AddContainer("server", "myserver")
.WithHttpEndpoint(targetPort: 8080)
.WithEnvironment("CACHE_PASSWORD", cachePassword);
using var app = builder.Build();
var env = envBuilder.Resource;
await app.RunAsync();
// Verify: values.yaml should have cache_password key (parameter name) under both cache and server
var valuesPath = Path.Combine(workspace.Path, "values.yaml");
Assert.True(File.Exists(valuesPath), "values.yaml should exist");
var valuesContent = await File.ReadAllTextAsync(valuesPath);
outputHelper.WriteLine("=== values.yaml ===");
outputHelper.WriteLine(valuesContent);
// The key should be "cache_password" (from parameter name via ValuesKey), not "CACHE_PASSWORD" or "REDIS_PASSWORD"
Assert.Contains("cache_password", valuesContent);
// Verify: CapturedHelmValues should include entries for BOTH resources
Assert.Contains(env.CapturedHelmValues, c =>
c.Section == "secrets" &&
c.ResourceKey == "cache" &&
c.ValueKey == "cache_password" &&
c.Parameter.Name == "cache-password");
Assert.Contains(env.CapturedHelmValues, c =>
c.Section == "secrets" &&
c.ResourceKey == "server" &&
c.ValueKey == "cache_password" &&
c.Parameter.Name == "cache-password");
// Verify: server's template should reference {{ .Values.secrets.server.cache_password }}
var serverSecretsTemplatePath = Path.Combine(workspace.Path, "templates", "server", "secrets.yaml");
if (File.Exists(serverSecretsTemplatePath))
{
var templateContent = await File.ReadAllTextAsync(serverSecretsTemplatePath);
outputHelper.WriteLine("=== server secrets.yaml template ===");
outputHelper.WriteLine(templateContent);
// Template should reference the correct path
Assert.Contains(".Values.secrets.server.cache_password", templateContent);
}
}
[Fact]
public async Task CrossResourceSecretResolution_ReferenceExpressionWrappedParameter_PreservesValuesKey()
{
// Reproduces the real Redis+WithReference scenario where the password env var
// is provided as ReferenceExpression.Create($"{PasswordParameter}") — a {0} wrapper.
// Without the fix, the {0} passthrough converts HelmValue to string, losing ValuesKey.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Publish);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
var envBuilder = builder.AddKubernetesEnvironment("env");
var cachePassword = builder.AddParameter("cache-password", secret: true);
// Cache container with direct password env var
builder.AddContainer("cache", "redis")
.WithEndpoint(targetPort: 6379, name: "tcp")
.WithEnvironment("REDIS_PASSWORD", cachePassword);
// Server container uses ReferenceExpression wrapping (like real WithReference does)
// This is the key difference: ReferenceExpression.Create($"{param}") wraps in {0} format
builder.AddContainer("server", "myserver")
.WithHttpEndpoint(targetPort: 8080)
.WithEnvironment(context =>
{
// Mimic what Redis's GetConnectionProperties yields: Password wrapped in ReferenceExpression
context.EnvironmentVariables["CACHE_PASSWORD"] = ReferenceExpression.Create($"{cachePassword.Resource}");
});
using var app = builder.Build();
var env = envBuilder.Resource;
await app.RunAsync();
var valuesPath = Path.Combine(workspace.Path, "values.yaml");
Assert.True(File.Exists(valuesPath), "values.yaml should exist");
var valuesContent = await File.ReadAllTextAsync(valuesPath);
outputHelper.WriteLine("=== values.yaml ===");
outputHelper.WriteLine(valuesContent);
// Critical: the key must be "cache_password" (from parameter name), NOT "CACHE_PASSWORD" (env var name)
// Without the fix, the {0} passthrough converts HelmValue to string, losing ValuesKey,
// and the fallback key is the env var name "CACHE_PASSWORD" (case mismatch with template).
Assert.DoesNotContain("CACHE_PASSWORD", valuesContent);
Assert.Contains("cache_password", valuesContent);
// Server's CapturedHelmValue should use the parameter name as the value key
Assert.Contains(env.CapturedHelmValues, c =>
c.Section == "secrets" &&
c.ResourceKey == "server" &&
c.ValueKey == "cache_password" &&
c.Parameter.Name == "cache-password");
}
[Fact]
public async Task CrossResourceSecretResolution_OverrideFileResolvesAllPaths()
{
// Verifies that Phase 1 and Phase 2 resolution produces a correct override file.
// Phase 1: Resolves direct ParameterResource values (both cache and server entries)
// Phase 2: Substitutes Helm expressions in cross-reference templates with Phase 1 values
using var workspace = TemporaryWorkspace.Create(outputHelper);
var outputPath = Path.Combine(workspace.Path, "deploy");
Directory.CreateDirectory(outputPath);
var environment = new KubernetesEnvironmentResource("myenv");
// Create a parameter with a known value callback
var cachePasswordParam = new ParameterResource("cache-password", _ => "test-password-123", secret: true);
environment.CapturedHelmValues.Add(
new KubernetesEnvironmentResource.CapturedHelmValue(
"secrets", "cache", "cache_password", cachePasswordParam));
environment.CapturedHelmValues.Add(
new KubernetesEnvironmentResource.CapturedHelmValue(
"secrets", "server", "cache_password", cachePasswordParam));
// Simulate cross-reference: server's connection string embeds the cache password
environment.CapturedHelmCrossReferences.Add(
new KubernetesEnvironmentResource.CapturedHelmCrossReference(
"secrets", "server", "connectionstrings__cache",
"cache-service:6379,password={{ .Values.secrets.server.cache_password }}"));
environment.CapturedHelmCrossReferences.Add(
new KubernetesEnvironmentResource.CapturedHelmCrossReference(
"secrets", "server", "cache_uri",
"redis://:{{ .Values.secrets.server.cache_password }}@cache-service:6379"));
// Act
await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync(
outputPath, environment, CancellationToken.None);
// Assert: override file should exist and have fully resolved values
var overridePath = Path.Combine(outputPath, HelmDeploymentEngine.GetDeployValuesFileName("myenv"));
Assert.True(File.Exists(overridePath), "Override file should be created");
var content = await File.ReadAllTextAsync(overridePath);
outputHelper.WriteLine("=== Override file ===");
outputHelper.WriteLine(content);
// Snapshot tests cover YAML scalar style. These assertions only verify that resolution
// populated every path, so ignore the quotes required to preserve string values for Helm.
content = content.Replace("\"", string.Empty, StringComparison.Ordinal);
// Phase 1: Both cache and server should have the resolved password
Assert.Contains("cache_password: test-password-123", content);
// Phase 2: Cross-references should be fully resolved (no {{ .Values... }} expressions)
Assert.DoesNotContain("{{ .Values.", content);
// Phase 2: Connection string should be fully resolved
Assert.Contains("connectionstrings__cache: cache-service:6379,password=test-password-123", content);
Assert.Contains("cache_uri: redis://:test-password-123@cache-service:6379", content);
}
[Fact]
public async Task CrossResourceSecretResolution_EndToEnd_PublishAndResolve()
{
// Full end-to-end: publish generates correct captures, then resolve produces correct override
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Publish);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
var envBuilder = builder.AddKubernetesEnvironment("env");
var cachePassword = builder.AddParameter("cache-password", "e2e-test-pw-42", secret: true);
// Cache with password
builder.AddContainer("cache", "redis")
.WithEndpoint(targetPort: 6379, name: "tcp")
.WithEnvironment("REDIS_PASSWORD", cachePassword);
// Server with direct password reference
builder.AddContainer("server", "myserver")
.WithHttpEndpoint(targetPort: 8080)
.WithEnvironment("CACHE_PASSWORD", cachePassword);
using var app = builder.Build();
var env = envBuilder.Resource;
await app.RunAsync();
// Now simulate deploy-time resolution
await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync(
workspace.Path, env, CancellationToken.None);
var overridePath = Path.Combine(workspace.Path, HelmDeploymentEngine.GetDeployValuesFileName("env"));
Assert.True(File.Exists(overridePath), "Override file should be created");
var content = await File.ReadAllTextAsync(overridePath);
outputHelper.WriteLine("=== Override file (E2E) ===");
outputHelper.WriteLine(content);
// The override file should NOT contain any unresolved Helm expressions
Assert.DoesNotContain("{{ .Values.", content);
// Both cache and server should have resolved password values
Assert.Contains("cache:", content); // cache resource section
Assert.Contains("server:", content); // server resource section
Assert.Contains("cache_password:", content);
// Verify the actual password is in the resolved values
Assert.Contains("e2e-test-pw-42", content);
}
[Fact]
public async Task EmbeddedParametersInEnvironmentExpressions_EndToEnd_PublishAndResolve()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Publish);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
var envBuilder = builder.AddKubernetesEnvironment("env");
// Numeric-looking strings must retain their lexical form when the deploy values file is
// parsed by Helm instead of being normalized as numeric YAML scalars.
var host = builder.AddParameter("host", "01", publishValueAsDefault: true);
var token = builder.AddParameter("token", "1.0", secret: true);
builder.AddContainer("myapp", "nginx")
.WithEnvironment("SOME_URL", $"http://{host}/test")
.WithEnvironment("SECRET_URL", $"http://{host}/test?token={token}");
using var app = builder.Build();
var env = envBuilder.Resource;
await app.RunAsync();
Assert.Contains(env.CapturedHelmValues, captured =>
captured.Section == "config" &&
captured.ResourceKey == "myapp" &&
captured.ValueKey == "host" &&
captured.Parameter == host.Resource);
Assert.Contains(env.CapturedHelmValues, captured =>
captured.Section == "secrets" &&
captured.ResourceKey == "myapp" &&
captured.ValueKey == "token" &&
captured.Parameter == token.Resource);
await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync(
workspace.Path, env, CancellationToken.None);
var overridePath = Path.Combine(workspace.Path, HelmDeploymentEngine.GetDeployValuesFileName("env"));
await Verify(await File.ReadAllTextAsync(overridePath), "yaml");
}
[Fact]
public async Task ConditionalParameterWithoutDefault_EndToEnd_PublishAndResolve()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Publish);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.Configuration["Parameters:enable-tls"] = "1.0";
var envBuilder = builder.AddKubernetesEnvironment("env");
var enableTls = builder.AddParameter("enable-tls");
builder.AddContainer("myapp", "nginx")
.WithEnvironment(context =>
{
context.EnvironmentVariables["TLS_SUFFIX"] = ReferenceExpression.CreateConditional(
enableTls.Resource,
"1.0",
ReferenceExpression.Create($",ssl=true"),
ReferenceExpression.Create($",ssl=false"));
});
using var app = builder.Build();
var env = envBuilder.Resource;
await app.RunAsync();
Assert.Contains(env.CapturedHelmValues, captured =>
captured.Section == "parameters" &&
captured.ResourceKey == "myapp" &&
captured.ValueKey == "enable_tls" &&
captured.Parameter == enableTls.Resource);
await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync(
workspace.Path, env, CancellationToken.None);
var overridePath = Path.Combine(workspace.Path, HelmDeploymentEngine.GetDeployValuesFileName("env"));
await Verify(await File.ReadAllTextAsync(overridePath), "yaml");
}
[Fact]
public async Task DeferredValueProvider_EndToEnd_PublishAndResolve()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Publish);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
var envBuilder = builder.AddKubernetesEnvironment("env");
var provider = new TestValueProvider("resolved-value", "{outputs.deferred}");
builder.AddContainer("myapp", "nginx")
.WithEnvironment(context =>
{
context.EnvironmentVariables["DEFERRED_VALUE"] = provider;
});
using var app = builder.Build();
var env = envBuilder.Resource;
await app.RunAsync();
Assert.Contains(env.CapturedHelmValueProviders, captured =>
captured.Section == "config" &&
captured.ResourceKey == "myapp" &&
captured.ValueKey == "DEFERRED_VALUE" &&
captured.ValueProvider == provider);
await HelmDeploymentEngine.ResolveAndWriteDeployValuesAsync(
workspace.Path, env, CancellationToken.None);
var overridePath = Path.Combine(workspace.Path, HelmDeploymentEngine.GetDeployValuesFileName("env"));
await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "values.yaml")), "yaml")
.AppendContentAsFile(
await File.ReadAllTextAsync(Path.Combine(workspace.Path, "templates", "myapp", "config.yaml")),
"yaml")
.AppendContentAsFile(await File.ReadAllTextAsync(overridePath), "yaml");
}
[Fact]
public void AddKubernetesEnvironment_CreatesDashboardByDefault()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var envBuilder = builder.AddKubernetesEnvironment("env");
var env = envBuilder.Resource;
Assert.True(env.DashboardEnabled);
Assert.NotNull(env.Dashboard);
Assert.IsType<KubernetesAspireDashboardResource>(env.Dashboard.Resource);
}
[Fact]
public void WithDashboard_Disabled_SetsDashboardEnabledFalse()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var envBuilder = builder.AddKubernetesEnvironment("env")
.WithDashboard(false);
var env = envBuilder.Resource;
Assert.False(env.DashboardEnabled);
}
[Fact]
public void WithDashboard_Configure_AllowsCustomization()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var envBuilder = builder.AddKubernetesEnvironment("env")
.WithDashboard(dashboard => dashboard.WithServicePort(9999));
var env = envBuilder.Resource;
Assert.True(env.DashboardEnabled);
Assert.NotNull(env.Dashboard);
// Verify the host port was configured
var httpEndpoint = env.Dashboard.Resource.PrimaryEndpoint;
Assert.NotNull(httpEndpoint);
}
[Fact]
public async Task Dashboard_OtlpConfigured_ForComputeResources()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Publish);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
var envBuilder = builder.AddKubernetesEnvironment("env");
// Use a project resource — projects get OtlpExporterAnnotation by default
builder.AddProject<Projects.ServiceA>("api");
using var app = builder.Build();
await app.RunAsync();
// Check that values.yaml contains OTLP configuration for the project resource
var valuesPath = Path.Combine(workspace.Path, "values.yaml");
Assert.True(File.Exists(valuesPath));
var content = await File.ReadAllTextAsync(valuesPath);
outputHelper.WriteLine(content);
Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", content);
Assert.Contains("env-dashboard-service:18889", content);
Assert.Contains("OTEL_EXPORTER_OTLP_PROTOCOL", content);
Assert.Contains("OTEL_SERVICE_NAME", content);
}
[Fact]
public async Task Dashboard_Disabled_NoOtlpConfiguration()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Publish);
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.AddKubernetesEnvironment("env")
.WithDashboard(false);
// Use a project resource — projects get OtlpExporterAnnotation by default
builder.AddProject<Projects.ServiceA>("api");
using var app = builder.Build();
await app.RunAsync();
// Check that values.yaml does NOT contain OTLP configuration
var valuesPath = Path.Combine(workspace.Path, "values.yaml");
Assert.True(File.Exists(valuesPath));
var content = await File.ReadAllTextAsync(valuesPath);
outputHelper.WriteLine(content);
Assert.DoesNotContain("OTEL_EXPORTER_OTLP_ENDPOINT", content);
Assert.DoesNotContain("OTEL_SERVICE_NAME", content);
}
[Fact]
public void Dashboard_ResourceHasCorrectEndpoints()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var envBuilder = builder.AddKubernetesEnvironment("env");
var dashboard = envBuilder.Resource.Dashboard!.Resource;
Assert.NotNull(dashboard.PrimaryEndpoint);
Assert.NotNull(dashboard.OtlpGrpcEndpoint);
Assert.Equal("http", dashboard.PrimaryEndpoint.EndpointName);
Assert.Equal("otlp-grpc", dashboard.OtlpGrpcEndpoint.EndpointName);
}
[Fact]
public async Task DestroyHelm_WithState_RunsHelmUninstall()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var fakeHelm = new FakeHelmRunner();
var stateManager = new InMemoryDeploymentStateManager();
stateManager.SetSection("Helm:env", new JsonObject
{
["ReleaseName"] = "my-release",
["Namespace"] = "my-namespace"
});
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Destroy);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.Services.AddSingleton<IDeploymentStateManager>(stateManager);
builder.Services.AddSingleton<IHelmRunner>(fakeHelm);
builder.Services.Configure<PipelineOptions>(o => o.SkipConfirmation = true);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("api", "myimage");
using var app = builder.Build();
await app.RunAsync();
// Verify helm uninstall was called with saved state values
Assert.True(fakeHelm.WasUninstallCalled);
Assert.Contains("my-release", fakeHelm.LastArguments!);
Assert.Contains("my-namespace", fakeHelm.LastArguments!);
}
[Fact]
public async Task DestroyHelm_WithNoState_ReportsNothingToDestroy()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var fakeHelm = new FakeHelmRunner { ThrowOnVersion = true };
var stateManager = new InMemoryDeploymentStateManager();
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Destroy);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.Services.AddSingleton<IDeploymentStateManager>(stateManager);
builder.Services.AddSingleton<IHelmRunner>(fakeHelm);
builder.Services.Configure<PipelineOptions>(o => o.SkipConfirmation = true);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("api", "myimage");
using var app = builder.Build();
await app.RunAsync();
// Verify helm was NOT called
Assert.False(fakeHelm.WasVersionCalled);
Assert.False(fakeHelm.WasUninstallCalled);
// Verify it reported nothing to destroy
var completedSteps = mockActivityReporter.CompletedSteps;
Assert.Contains(completedSteps, s => s.CompletionText.Contains("Nothing to destroy", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task DestroyHelm_WhenUninstallFails_PreservesState()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var fakeHelm = new FakeHelmRunner { ExitCode = 1 };
var stateManager = new InMemoryDeploymentStateManager();
stateManager.SetSection("Helm:env", new JsonObject
{
["ReleaseName"] = "my-release",
["Namespace"] = "my-namespace"
});
var mockActivityReporter = new TestPipelineActivityReporter(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path,
step: WellKnownPipelineSteps.Destroy);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IPipelineActivityReporter>(mockActivityReporter);
builder.Services.AddSingleton<IDeploymentStateManager>(stateManager);
builder.Services.AddSingleton<IHelmRunner>(fakeHelm);
builder.Services.Configure<PipelineOptions>(o => o.SkipConfirmation = true);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("api", "myimage");
using var app = builder.Build();
await app.RunAsync();
// Verify helm uninstall was attempted
Assert.True(fakeHelm.WasUninstallCalled);
// Verify state was NOT deleted (preserved for retry)
var stateSection = await stateManager.AcquireSectionAsync("Helm:env");
Assert.Equal("my-release", stateSection.Data["ReleaseName"]?.ToString());
}
}