Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
{
/// <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
42 changes: 41 additions & 1 deletion src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,17 @@ 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));
logger.LogDebug("Executing: {FileName} {Arguments}", fileName, RedactSensitiveArgs(arguments));

var psi = new ProcessStartInfo
{
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 All @@ -99,4 +107,36 @@ public async Task<ProcessResult> RunAsync(

return new ProcessResult(process.ExitCode, stdout.ToString().TrimEnd(), stderr.ToString().TrimEnd());
}

internal static string RedactSensitiveArgs(IReadOnlyList<string> arguments)
{
ArgumentNullException.ThrowIfNull(arguments);

var redacted = new string[arguments.Count];
var redactNext = false;

for (var i = 0; i < arguments.Count; i++)
{
var argument = arguments[i];

if (redactNext)
{
redacted[i] = "[REDACTED]";
redactNext = false;
}
else if (string.Equals(argument, "--kubeconfig", StringComparison.OrdinalIgnoreCase))
{
redacted[i] = argument;
redactNext = true;
}
else
{
redacted[i] = argument.StartsWith("--kubeconfig=", StringComparison.OrdinalIgnoreCase)
? "--kubeconfig=[REDACTED]"
: argument;
}
}

return string.Join(' ', redacted);
}
}
136 changes: 120 additions & 16 deletions src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,87 @@ 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 static readonly TimeSpan DefaultCrdWaitTimeout = TimeSpan.FromMinutes(5);

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.
/// </summary>
public async Task InstallAsync(KindHelmChartResource resource, ILogger logger, CancellationToken cancellationToken)
{
var args = CreateInstallArguments(resource);
var maxAttempts = resource.CrdWaitRetryMaxAttempts;
IReadOnlySet<string> knownCrds = maxAttempts > 1
? await TryGetCustomResourceDefinitionsAsync(resource, logger, cancellationToken).ConfigureAwait(false)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);

logger.LogInformation(
"Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}'...",
resource.ChartRef, resource.ReleaseName, resource.Parent.Name);
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
logger.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(
logger,
"helm",
args,
cancellationToken: cancellationToken).ConfigureAwait(false);
var result = await processRunner.RunAsync(
logger,
"helm",
args,
cancellationToken: cancellationToken).ConfigureAwait(false);

if (result.ExitCode != 0)
{
throw new InvalidOperationException(
$"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {result.Error}");
}
if (result.ExitCode == 0)
{
logger.LogInformation(
"Helm release '{ReleaseName}' installed successfully.", resource.ReleaseName);
return;
}

if (attempt >= maxAttempts || !ShouldRetryForCrdRace(result))
{
throw new InvalidOperationException(
$"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {result.Error}");
}

var discoveredCrds = await TryGetCustomResourceDefinitionsAsync(resource, logger, cancellationToken).ConfigureAwait(false);
var newCrds = discoveredCrds
.Except(knownCrds, StringComparer.OrdinalIgnoreCase)
.ToArray();

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

logger.LogInformation(
"Helm release '{ReleaseName}' installed successfully.", resource.ReleaseName);
logger.LogWarning(
"Helm release '{ReleaseName}' failed before CRDs finished registering. Waiting for {CrdCount} CRD(s) before retrying.",
resource.ReleaseName,
newCrds.Length);

await _kubectlManager.WaitForCrdsAsync(
newCrds,
resource.Parent.KubeconfigPath,
DefaultCrdWaitTimeout,
logger,
cancellationToken).ConfigureAwait(false);

knownCrds = discoveredCrds;
var backoff = ComputeRetryBackoff(resource.CrdWaitRetryBackoff, attempt);
logger.LogInformation(
"Retrying Helm release '{ReleaseName}' in {DelaySeconds:n1}s.",
resource.ReleaseName,
backoff.TotalSeconds);
await _delayAsync(backoff, cancellationToken).ConfigureAwait(false);
}
}

internal static IReadOnlyList<string> CreateInstallArguments(KindHelmChartResource resource)
Expand Down Expand Up @@ -70,6 +124,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 +138,48 @@ internal static IReadOnlyList<string> CreateInstallArguments(KindHelmChartResour

return arguments;
}

internal static TimeSpan ComputeRetryBackoff(TimeSpan initialBackoff, int failureCount)
{
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 bool ShouldRetryForCrdRace(ProcessResult result)
{
var combined = string.Concat(result.Error, "\n", result.Output);
return combined.Contains("no matches for kind", StringComparison.OrdinalIgnoreCase)
|| combined.Contains("ensure CRDs are installed first", StringComparison.OrdinalIgnoreCase);
}

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