Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5c80dc4
feat(Kind): add AddManifest resource for kubectl apply after cluster …
Jul 25, 2026
7be3aac
fix: make manifest cluster-ready timeout configurable
Jul 29, 2026
5d81fe0
fix: harden Helm installs for CRD races
Jul 29, 2026
a008d83
feat: expose Kind node config and earlier cleanup
Jul 29, 2026
61fff8b
docs: describe new Kind manifest and Helm options
Jul 29, 2026
d1f5f16
docs: show new Kind APIs in sample AppHost
Jul 29, 2026
c6a4ff2
docs: expand Kind API reference examples
Jul 29, 2026
5fe2af9
fix: broaden Kind cleanup signals
Jul 29, 2026
ff31891
test: verify manifest readiness timeout wiring
Jul 29, 2026
21d02d8
fix: normalize node mounts and helm value precedence
Jul 29, 2026
6b1db26
docs: clarify Kind cleanup and mount usage
Jul 29, 2026
9f3e530
test: make Kind node mount assertions cross-platform
Jul 29, 2026
ddd90d1
test: enable plain dotnet test for Kind projects
Jul 29, 2026
02c1973
chore: align Kind formatting and API baselines
Jul 29, 2026
75c0b50
docs: expand Kind XML API comments
tamirdresher Jul 29, 2026
983ae85
fix: address Kind Copilot review feedback
tamirdresher Jul 29, 2026
219b58c
fix: address Matt's review — node image conflict, manifest path valid…
tamirdresher Aug 5, 2026
532150f
refactor: simplify Kind shutdown cleanup to ApplicationStopping only …
tamirdresher Aug 5, 2026
027032b
refactor: standardize Kind retry/backoff on Microsoft.Extensions.Resi…
tamirdresher Aug 5, 2026
32f6e1e
refactor: require explicit opt-in for Helm CRD-wait retry, remove aut…
tamirdresher Aug 5, 2026
d8d9591
refactor: move K8sManifestResource config to annotations for API evol…
tamirdresher Aug 5, 2026
88606e3
refactor: fold CrdWaitBehavior into cohesive wait-policy config (per …
tamirdresher Aug 5, 2026
bd95376
refactor: clarify KubectlManager validation placement and resource-lo…
tamirdresher Aug 5, 2026
36f508f
fix: stop redacting kubeconfig paths in debug logs (per review)
tamirdresher Aug 6, 2026
a58e491
Remove generated Kind API baseline
tamirdresher Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="$(DotNetExtensionsVersion)" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
<!-- .NET packages -->
<PackageVersion Include="Microsoft.Extensions.Resilience" Version="9.9.0" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.9.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="$(ServiceDiscoveryVersion)" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="$(DotNetExtensionsVersion)" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@
<ItemGroup>
<PackageReference Include="MessagePack" />
</ItemGroup>

<ItemGroup>
<None Include="manifests\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
var builder = DistributedApplication.CreateBuilder(args);
var manifestMountSource = Path.Combine(builder.AppHostDirectory, "manifests");

// Kind cluster as a managed dependency (F5 mode).
// The cluster appears in the Aspire dashboard, your apps get KUBECONFIG injected.
var cluster = builder.AddKindCluster("kind-cluster")
.WithKubernetesVersion("v1.32.2");
.WithNodeImage("kindest/node:v1.32.2")
// Configuration example: mount the same manifest directory into each Kind node.
// This sample still applies manifests from the host path below rather than from inside a workload.
.WithNodeMount(manifestMountSource, "/var/local/aspire/manifests", readOnly: true);

// Run Headlamp (a lightweight Kubernetes web UI) as an Aspire-managed container
// connected to the Kind cluster.
Expand All @@ -17,14 +21,43 @@
.WithHelmValue("replica.replicaCount", "0")
.WithHelmValue("master.service.type", "NodePort")
.WithHelmValue("master.service.nodePorts.redis", "30379")
.WithHelmStringValue("auth.password", "000123")
.WithCrdWaitRetry(maxAttempts: 3, backoff: TimeSpan.FromSeconds(5))
.WithNamespace("cache");

// Apply raw Kubernetes YAML from the same host directory mounted into each Kind node.
var manifestResource = cluster.AddManifest("extra-config", Path.Combine(manifestMountSource, "extra-config.yaml"))
.WithClusterReadyTimeout(TimeSpan.FromMinutes(2))
.WithNamespace("aspire-demo");

// Demonstrate recursive directory apply for manifest folders.
cluster.AddManifest("recursive-config", manifestMountSource)
.WithRecursive();

// Demonstrate server-side apply with conflict forcing and a stable field manager.
cluster.AddManifest("ssa-config", Path.Combine(manifestMountSource, "extra-config.yaml"))
.WithServerSideApply(forceConflicts: true)
.WithFieldManager("aspire-example");

// Demonstrate best-effort CRD waiting when a local demo should continue after a CRD wait timeout.
cluster.AddManifest("best-effort-crds", Path.Combine(manifestMountSource, "extra-config.yaml"))
.WithCrdWaitBehavior(CrdWaitBehavior.BestEffort);

cluster.AddManifestFromContent("demo-ns", """
apiVersion: v1
kind: Namespace
metadata:
name: aspire-demo
""");

// Test Aspire-container → Kind-workload connectivity by pinging Redis
// through the Kind container network on the NodePort.
builder.AddContainer("redis-ping", "nicolaka/netshoot")
.WithKindNetwork()
.WaitFor(cluster)
// Wait for the manifest resource before starting a downstream container.
.WaitFor(manifestResource)
.WithEntrypoint("sh")
.WithArgs("-c", "while true; do nc -zv kind-cluster-control-plane 30379; sleep 5; done");

builder.Build().Run();
builder.Build().Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# This file demonstrates a multi-document manifest:
# 1. A Namespace for grouping demo resources.
# 2. A ConfigMap scoped to that namespace.
apiVersion: v1
kind: Namespace
metadata:
name: aspire-demo
---
apiVersion: v1
kind: ConfigMap
metadata:
name: extra-config
namespace: aspire-demo
data:
greeting: hello-kind
purpose: aspire-demo
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<PackageReference Include="Aspire.Hosting" />
<PackageReference Include="Aspire.Hosting.Kubernetes" />
<PackageReference Include="KubernetesClient" />
<PackageReference Include="Microsoft.Extensions.Resilience" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>

Expand Down
26 changes: 26 additions & 0 deletions src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// 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 ASPIREATS001 // AspireExport APIs are experimental

[assembly: Aspire.Hosting.AspireExport(typeof(Aspire.Hosting.CrdWaitBehavior))]

namespace Aspire.Hosting;

/// <summary>
/// Specifies how Kind manifest resources handle CRD Established-condition wait failures.
/// </summary>
public enum CrdWaitBehavior
{
Comment thread
tamirdresher marked this conversation as resolved.
/// <summary>
/// Fail the manifest resource when waiting for applied CRDs fails or times out.
/// </summary>
Fail,

/// <summary>
/// Log a warning and continue when waiting for applied CRDs fails or times out.
/// </summary>
BestEffort,
}

#pragma warning restore ASPIREATS001
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public async Task<ProcessResult> RunAsync(
IReadOnlyList<string> arguments,
string? workingDirectory = null,
IReadOnlyDictionary<string, string>? environmentVariables = null,
string? standardInput = null,
CancellationToken cancellationToken = default)
{
logger.LogDebug("Executing: {FileName} {Arguments}", fileName, string.Join(' ', arguments));
Expand All @@ -29,6 +30,7 @@ public async Task<ProcessResult> RunAsync(
FileName = fileName,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = standardInput is not null,
UseShellExecute = false,
CreateNoWindow = true
};
Expand Down Expand Up @@ -79,6 +81,12 @@ public async Task<ProcessResult> RunAsync(

try
{
if (standardInput is not null)
{
await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken).ConfigureAwait(false);
process.StandardInput.Close();
}

// WaitForExitAsync observes process termination; WaitForExit() then lets async stdout/stderr event handlers finish draining redirected output.
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
process.WaitForExit();
Expand Down
193 changes: 180 additions & 13 deletions src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,145 @@

using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Logging;
using Polly;
using Polly.Retry;

namespace CommunityToolkit.Aspire.Hosting.Kind;

/// <summary>
/// Manages Helm chart deployments to a Kind cluster by orchestrating Helm CLI calls.
/// </summary>
internal sealed class HelmManager(IProcessRunner processRunner)
internal sealed class HelmManager(
IProcessRunner processRunner,
Func<TimeSpan, CancellationToken, Task>? delayAsync = null,
KubectlManager? kubectlManager = null)
{
private readonly Func<TimeSpan, CancellationToken, Task> _delayAsync = delayAsync ?? Task.Delay;
private readonly KubectlManager _kubectlManager = kubectlManager ?? new KubectlManager(processRunner, delayAsync);

/// <summary>
/// Installs or upgrades the Helm release.
/// Callers should pass the Helm resource's scoped logger from <see cref="ResourceLoggerService"/>.
/// </summary>
public async Task InstallAsync(KindHelmChartResource resource, ILogger logger, CancellationToken cancellationToken)
public async Task InstallAsync(KindHelmChartResource resource, ILogger resourceLogger, CancellationToken cancellationToken)
{
var args = CreateInstallArguments(resource);
var maxAttempts = resource.CrdWaitRetryMaxAttempts;
if (maxAttempts <= 1)
{
resourceLogger.LogInformation(
"Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}' (attempt 1/1)...",
resource.ChartRef,
resource.ReleaseName,
resource.Parent.Name);

var result = await processRunner.RunAsync(
resourceLogger,
"helm",
args,
cancellationToken: cancellationToken).ConfigureAwait(false);

if (result.ExitCode != 0)
{
throw new InvalidOperationException(
$"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {FormatFailureOutput(result)}");
}

resourceLogger.LogInformation(
"Helm release '{ReleaseName}' installed successfully.", resource.ReleaseName);
return;
}

IReadOnlySet<string> knownCrds = maxAttempts > 1
? await TryGetCustomResourceDefinitionsAsync(resource, resourceLogger, cancellationToken).ConfigureAwait(false)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var attempt = 0;
var pipeline = new ResiliencePipelineBuilder<HelmInstallAttemptResult>()
.AddRetry(new RetryStrategyOptions<HelmInstallAttemptResult>
{
MaxRetryAttempts = Math.Max(0, maxAttempts - 1),
Delay = TimeSpan.Zero,
UseJitter = false,
ShouldHandle = new PredicateBuilder<HelmInstallAttemptResult>()
.HandleResult(static result => result.ShouldRetry),
OnRetry = async arguments =>
{
var retryResult = arguments.Outcome.Result!;
if (retryResult.NewCrds.Length > 0)
{
resourceLogger.LogWarning(
"Helm release '{ReleaseName}' failed and discovered {CrdCount} new CRD(s). Waiting for them to become Established before retrying.",
resource.ReleaseName,
retryResult.NewCrds.Length);

await _kubectlManager.WaitForCrdsAsync(
retryResult.NewCrds,
resource.Parent.KubeconfigPath,
resource.CrdWaitRetryTimeout,
resourceLogger,
arguments.Context.CancellationToken).ConfigureAwait(false);
}
else
{
resourceLogger.LogWarning(
"Helm release '{ReleaseName}' failed. Retrying because {MethodName} is enabled.",
resource.ReleaseName,
"WithCrdWaitRetry");
}

knownCrds = retryResult.DiscoveredCrds;
var backoff = ComputeRetryBackoff(resource.CrdWaitRetryBackoff, arguments.AttemptNumber + 1);
resourceLogger.LogInformation(
"Retrying Helm release '{ReleaseName}' in {DelaySeconds:n1}s.",
resource.ReleaseName,
backoff.TotalSeconds);
await _delayAsync(backoff, arguments.Context.CancellationToken).ConfigureAwait(false);
}
})
.Build();

var finalResult = await pipeline.ExecuteAsync(async token =>
{
attempt++;
resourceLogger.LogInformation(
"Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}' (attempt {Attempt}/{MaxAttempts})...",
resource.ChartRef,
resource.ReleaseName,
resource.Parent.Name,
attempt,
maxAttempts);

var result = await processRunner.RunAsync(
resourceLogger,
"helm",
args,
cancellationToken: token).ConfigureAwait(false);

if (result.ExitCode == 0)
{
return HelmInstallAttemptResult.Success(result);
}

logger.LogInformation(
"Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}'...",
resource.ChartRef, resource.ReleaseName, resource.Parent.Name);
if (attempt >= maxAttempts)
{
return HelmInstallAttemptResult.Fail(result);
}

var result = await processRunner.RunAsync(
logger,
"helm",
args,
cancellationToken: cancellationToken).ConfigureAwait(false);
var discoveredCrds = await TryGetCustomResourceDefinitionsAsync(resource, resourceLogger, token).ConfigureAwait(false);
var newCrds = discoveredCrds
.Except(knownCrds, StringComparer.OrdinalIgnoreCase)
.ToArray();

if (result.ExitCode != 0)
return HelmInstallAttemptResult.Retry(result, discoveredCrds, newCrds);
}, cancellationToken).ConfigureAwait(false);

if (finalResult.Result.ExitCode != 0)
{
throw new InvalidOperationException(
$"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {result.Error}");
$"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {FormatFailureOutput(finalResult.Result)}");
}

logger.LogInformation(
resourceLogger.LogInformation(
"Helm release '{ReleaseName}' installed successfully.", resource.ReleaseName);
}

Expand Down Expand Up @@ -70,6 +177,12 @@ internal static IReadOnlyList<string> CreateInstallArguments(KindHelmChartResour
arguments.Add($"{key}={value}");
}

foreach (var (key, value) in resource.StringValues)
{
arguments.Add("--set-string");
arguments.Add($"{key}={value}");
}

foreach (string valuesFile in resource.ValuesFiles)
{
arguments.Add("-f");
Expand All @@ -78,4 +191,58 @@ internal static IReadOnlyList<string> CreateInstallArguments(KindHelmChartResour

return arguments;
}

internal static TimeSpan ComputeRetryBackoff(TimeSpan initialBackoff, int failureCount)
Comment thread
tamirdresher marked this conversation as resolved.
{
ArgumentOutOfRangeException.ThrowIfLessThan(failureCount, 1);

if (failureCount >= 64)
{
return TimeSpan.MaxValue;
}

var multiplier = 1L << (failureCount - 1);
return initialBackoff.Ticks > TimeSpan.MaxValue.Ticks / multiplier
? TimeSpan.MaxValue
: TimeSpan.FromTicks(initialBackoff.Ticks * multiplier);
}

private static string FormatFailureOutput(ProcessResult result)
{
return string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
}

private async Task<IReadOnlySet<string>> TryGetCustomResourceDefinitionsAsync(
KindHelmChartResource resource,
ILogger resourceLogger,
CancellationToken cancellationToken)
{
try
{
return await _kubectlManager.GetCustomResourceDefinitionsAsync(
resource.Parent.KubeconfigPath,
resourceLogger,
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
resourceLogger.LogDebug(
ex,
"Unable to snapshot CRDs for Helm release '{ReleaseName}'.",
resource.ReleaseName);
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
}

private sealed record HelmInstallAttemptResult(ProcessResult Result, bool ShouldRetry, IReadOnlySet<string> DiscoveredCrds, string[] NewCrds)
{
public static HelmInstallAttemptResult Success(ProcessResult result) =>
new(result, ShouldRetry: false, new HashSet<string>(StringComparer.OrdinalIgnoreCase), []);

public static HelmInstallAttemptResult Fail(ProcessResult result) =>
new(result, ShouldRetry: false, new HashSet<string>(StringComparer.OrdinalIgnoreCase), []);

public static HelmInstallAttemptResult Retry(ProcessResult result, IReadOnlySet<string> discoveredCrds, string[] newCrds) =>
new(result, ShouldRetry: true, discoveredCrds, newCrds);
}
}
1 change: 1 addition & 0 deletions src/CommunityToolkit.Aspire.Hosting.Kind/IProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ Task<ProcessResult> RunAsync(
IReadOnlyList<string> arguments,
string? workingDirectory = null,
IReadOnlyDictionary<string, string>? environmentVariables = null,
string? standardInput = null,
CancellationToken cancellationToken = default);
}
Loading
Loading