diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/CommunityToolkit.Aspire.Hosting.Kind.AppHost.csproj b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/CommunityToolkit.Aspire.Hosting.Kind.AppHost.csproj
index 86b102f2c..05381e4b2 100644
--- a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/CommunityToolkit.Aspire.Hosting.Kind.AppHost.csproj
+++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/CommunityToolkit.Aspire.Hosting.Kind.AppHost.csproj
@@ -11,4 +11,8 @@
+
+
+
+
diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs
index e5ad85a3e..a1b201e80 100644
--- a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs
+++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs
@@ -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.
@@ -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();
\ No newline at end of file
diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/manifests/extra-config.yaml b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/manifests/extra-config.yaml
new file mode 100644
index 000000000..dafb34631
--- /dev/null
+++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/manifests/extra-config.yaml
@@ -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
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs
new file mode 100644
index 000000000..b3c1a54e1
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs
@@ -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;
+
+///
+/// Specifies how Kind manifest resources handle CRD Established-condition wait failures.
+///
+public enum CrdWaitBehavior
+{
+ ///
+ /// Fail the manifest resource when waiting for applied CRDs fails or times out.
+ ///
+ Fail,
+
+ ///
+ /// Log a warning and continue when waiting for applied CRDs fails or times out.
+ ///
+ BestEffort,
+}
+
+#pragma warning restore ASPIREATS001
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs
index af1093ae1..84ebe8465 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs
@@ -20,15 +20,17 @@ public async Task RunAsync(
IReadOnlyList arguments,
string? workingDirectory = null,
IReadOnlyDictionary? 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
};
@@ -79,6 +81,12 @@ public async Task 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();
@@ -99,4 +107,36 @@ public async Task RunAsync(
return new ProcessResult(process.ExitCode, stdout.ToString().TrimEnd(), stderr.ToString().TrimEnd());
}
+
+ internal static string RedactSensitiveArgs(IReadOnlyList 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);
+ }
}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs
index 950127c61..1ec9a5067 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs
@@ -9,33 +9,87 @@ namespace CommunityToolkit.Aspire.Hosting.Kind;
///
/// Manages Helm chart deployments to a Kind cluster by orchestrating Helm CLI calls.
///
-internal sealed class HelmManager(IProcessRunner processRunner)
+internal sealed class HelmManager(
+ IProcessRunner processRunner,
+ Func? delayAsync = null,
+ KubectlManager? kubectlManager = null)
{
+ private static readonly TimeSpan DefaultCrdWaitTimeout = TimeSpan.FromMinutes(5);
+
+ private readonly Func _delayAsync = delayAsync ?? Task.Delay;
+ private readonly KubectlManager _kubectlManager = kubectlManager ?? new KubectlManager(processRunner, delayAsync);
+
///
/// Installs or upgrades the Helm release.
///
public async Task InstallAsync(KindHelmChartResource resource, ILogger logger, CancellationToken cancellationToken)
{
var args = CreateInstallArguments(resource);
+ var maxAttempts = resource.CrdWaitRetryMaxAttempts;
+ IReadOnlySet knownCrds = maxAttempts > 1
+ ? await TryGetCustomResourceDefinitionsAsync(resource, logger, cancellationToken).ConfigureAwait(false)
+ : new HashSet(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 CreateInstallArguments(KindHelmChartResource resource)
@@ -70,6 +124,12 @@ internal static IReadOnlyList 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");
@@ -78,4 +138,48 @@ internal static IReadOnlyList 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> 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(StringComparer.OrdinalIgnoreCase);
+ }
+ }
}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/IProcessRunner.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/IProcessRunner.cs
index 165cdf03d..eba7808e8 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/IProcessRunner.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/IProcessRunner.cs
@@ -25,5 +25,6 @@ Task RunAsync(
IReadOnlyList arguments,
string? workingDirectory = null,
IReadOnlyDictionary? environmentVariables = null,
+ string? standardInput = null,
CancellationToken cancellationToken = default);
}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs
new file mode 100644
index 000000000..fc264f5e1
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs
@@ -0,0 +1,91 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting;
+
+#pragma warning disable ASPIREATS001 // AspireExport APIs are experimental
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// A Kubernetes manifest applied to a Kind cluster via kubectl apply.
+///
+/// The name of the resource.
+///
+/// Absolute path to a Kubernetes manifest file or directory of manifest files,
+/// or <inline> for manifests provided via standard input.
+///
+/// The parent Kind cluster resource.
+[AspireExport(ExposeProperties = true)]
+public class K8sManifestResource(string name, string manifestPath, KindClusterResource parent)
+ : KindDeployedResource(name, parent)
+{
+ internal const string InlineManifestPath = "";
+
+ ///
+ /// Gets the manifest path passed to kubectl apply.
+ /// Accepts a file path, a directory path, or <inline> for standard input.
+ ///
+ public string ManifestPath { get; } = manifestPath ?? throw new ArgumentNullException(nameof(manifestPath));
+
+ ///
+ /// Gets or sets whether this resource represents a Kustomize overlay directory.
+ ///
+ public bool IsKustomize { get; set; }
+
+ ///
+ /// Gets or sets inline manifest content applied with kubectl apply -f -.
+ ///
+ public string? InlineContent { get; set; }
+
+ ///
+ /// Gets or sets whether to recursively apply manifests in subdirectories
+ /// (maps to kubectl apply --recursive).
+ /// Only meaningful when is a directory.
+ ///
+ public bool Recursive { get; set; }
+
+ ///
+ /// Gets or sets whether to apply the manifest server-side
+ /// (maps to kubectl apply --server-side).
+ /// Server-side apply is required for large CRDs that exceed the client-side annotation size limit.
+ ///
+ public bool ServerSide { get; set; }
+
+ ///
+ /// Gets or sets whether to force conflicts on server-side apply
+ /// (maps to kubectl apply --server-side --force-conflicts).
+ /// Only meaningful when is .
+ ///
+ public bool ForceConflicts { get; set; }
+
+ ///
+ /// Gets the field manager name used with server-side apply
+ /// (maps to kubectl apply --field-manager).
+ /// When , kubectl uses its default (kubectl).
+ ///
+ public string? FieldManager { get; set; }
+
+ ///
+ /// Gets or sets the maximum time to wait for kubectl apply to complete.
+ ///
+ public TimeSpan ApplyTimeout { get; set; } = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Gets or sets the maximum time to wait for the Kubernetes API to become reachable
+ /// before running kubectl apply.
+ ///
+ public TimeSpan ClusterReadyTimeout { get; set; } = TimeSpan.FromSeconds(60);
+
+ ///
+ /// Gets or sets the maximum time to wait for applied CRDs to reach the Established condition.
+ ///
+ public TimeSpan CrdWaitTimeout { get; set; } = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Gets or sets how CRD wait failures are handled.
+ ///
+ public CrdWaitBehavior CrdWaitBehavior { get; set; } = CrdWaitBehavior.Fail;
+}
+
+#pragma warning restore ASPIREATS001
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs
index 81121fa68..288dc5369 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs
@@ -5,12 +5,14 @@
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Lifecycle;
+using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
+using System.Runtime.Loader;
namespace CommunityToolkit.Aspire.Hosting.Kind;
///
-/// Handles cleanup of Kind clusters on application shutdown.
+/// Handles cleanup of Kind clusters on graceful shutdown and best-effort process-exit signals.
/// Clusters with lifetime are deleted;
/// clusters with lifetime are left running.
///
@@ -18,18 +20,118 @@ internal sealed class KindClusterLifecycleHook(
DistributedApplicationModel appModel,
ResourceLoggerService loggerService,
IProcessRunner processRunner,
- IKindContainerRuntimeResolver containerRuntimeResolver) : IDistributedApplicationEventingSubscriber, IAsyncDisposable
+ IKindContainerRuntimeResolver containerRuntimeResolver,
+ IHostApplicationLifetime hostApplicationLifetime) : IDistributedApplicationEventingSubscriber, IAsyncDisposable
{
+ private static readonly TimeSpan SynchronousCleanupWaitTimeout = TimeSpan.FromSeconds(15);
+ private readonly object _cleanupLock = new();
+ private readonly object _registrationLock = new();
+ private Task? _cleanupTask;
+ private CancellationTokenRegistration _applicationStoppingRegistration;
+ private EventHandler? _processExitHandler;
+ private Action? _unloadingHandler;
+ private ConsoleCancelEventHandler? _cancelKeyPressHandler;
+ private bool _terminationHandlersRegistered;
+
///
public Task SubscribeAsync(
IDistributedApplicationEventing eventing,
DistributedApplicationExecutionContext executionContext,
CancellationToken cancellationToken = default)
{
+ ArgumentNullException.ThrowIfNull(eventing);
+
+ RegisterTerminationHandlers();
return Task.CompletedTask;
}
+
///
public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ await EnsureCleanupStarted().ConfigureAwait(false);
+ }
+ finally
+ {
+ UnregisterTerminationHandlers();
+ }
+ }
+
+ private Task EnsureCleanupStarted()
+ {
+ lock (_cleanupLock)
+ {
+ _cleanupTask ??= CleanupClustersAsync();
+ return _cleanupTask;
+ }
+ }
+
+ private void RegisterTerminationHandlers()
+ {
+ lock (_registrationLock)
+ {
+ if (_terminationHandlersRegistered)
+ {
+ return;
+ }
+
+ _applicationStoppingRegistration = hostApplicationLifetime.ApplicationStopping.Register(() => _ = EnsureCleanupStarted());
+ _processExitHandler ??= (_, _) => RunCleanupSynchronously();
+ _unloadingHandler ??= _ => RunCleanupSynchronously();
+ _cancelKeyPressHandler ??= (_, _) => RunCleanupSynchronously();
+ AppDomain.CurrentDomain.ProcessExit += _processExitHandler;
+ AssemblyLoadContext.Default.Unloading += _unloadingHandler;
+ Console.CancelKeyPress += _cancelKeyPressHandler;
+ _terminationHandlersRegistered = true;
+ }
+ }
+
+ private void UnregisterTerminationHandlers()
+ {
+ lock (_registrationLock)
+ {
+ if (!_terminationHandlersRegistered)
+ {
+ return;
+ }
+
+ _applicationStoppingRegistration.Dispose();
+ if (_processExitHandler is not null)
+ {
+ AppDomain.CurrentDomain.ProcessExit -= _processExitHandler;
+ }
+
+ if (_unloadingHandler is not null)
+ {
+ AssemblyLoadContext.Default.Unloading -= _unloadingHandler;
+ }
+
+ if (_cancelKeyPressHandler is not null)
+ {
+ Console.CancelKeyPress -= _cancelKeyPressHandler;
+ }
+
+ _terminationHandlersRegistered = false;
+ }
+ }
+
+ private void RunCleanupSynchronously()
+ {
+ try
+ {
+ var cleanupTask = EnsureCleanupStarted();
+ if (cleanupTask.Wait(SynchronousCleanupWaitTimeout))
+ {
+ cleanupTask.GetAwaiter().GetResult();
+ }
+ }
+ catch
+ {
+ }
+ }
+
+ private async Task CleanupClustersAsync()
{
var clusters = appModel.Resources.OfType();
@@ -58,4 +160,4 @@ public async ValueTask DisposeAsync()
}
}
}
-}
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs
index 943ce78b4..79366beb2 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs
@@ -1,13 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-using System.ComponentModel;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Lifecycle;
using CommunityToolkit.Aspire.Hosting.Kind;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
+using System.ComponentModel;
#pragma warning disable ASPIREATS001 // AspireExport APIs are experimental
@@ -137,6 +137,76 @@ public static IResourceBuilder WithKubernetesVersion(
return builder;
}
+ ///
+ /// Sets the Kind node image for every node in the cluster.
+ ///
+ /// A resource type implementing .
+ /// The resource builder.
+ ///
+ /// The fully qualified Kind node image, such as kindest/node:v1.33.1.
+ /// This overrides the image that would otherwise be derived from .
+ ///
+ /// A reference to the .
+ [AspireExport]
+ public static IResourceBuilder WithNodeImage(
+ this IResourceBuilder builder,
+ string image)
+ where T : IKindResource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(image);
+
+ var annotation = GetOrCreateNodeImageAnnotation(builder.Resource);
+ annotation.Image = image;
+ return builder;
+ }
+
+ ///
+ /// Adds an extra host mount to every Kind node container.
+ ///
+ /// A resource type implementing .
+ /// The resource builder.
+ ///
+ /// The path on the host. Relative paths are resolved against the AppHost directory before being written to the Kind config.
+ ///
+ /// The absolute path inside the Kind node container.
+ /// to mount the path read-only.
+ /// A reference to the .
+ ///
+ /// This configures Kind node-container mounts, which are useful for surfacing host-side assets
+ /// such as local manifest directories, certificates, or other development-time inputs to workloads
+ /// that later mount paths from the node filesystem.
+ ///
+ [AspireExport]
+ public static IResourceBuilder WithNodeMount(
+ this IResourceBuilder builder,
+ string hostPath,
+ string containerPath,
+ bool readOnly = false)
+ where T : IKindResource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(hostPath);
+ ArgumentException.ThrowIfNullOrWhiteSpace(containerPath);
+ if (!containerPath.StartsWith("/", StringComparison.Ordinal))
+ {
+ throw new ArgumentException("Container path must be an absolute Linux path (for example '/var/local/data').", nameof(containerPath));
+ }
+
+ var normalizedHostPath = Path.IsPathRooted(hostPath)
+ ? Path.GetFullPath(hostPath)
+ : Path.GetFullPath(Path.Combine(builder.ApplicationBuilder.AppHostDirectory, hostPath));
+
+ var annotation = GetOrCreateNodeMountsAnnotation(builder.Resource);
+ annotation.Mounts.Add(new KindMountModel
+ {
+ HostPath = normalizedHostPath,
+ ContainerPath = containerPath,
+ ReadOnly = readOnly,
+ });
+ return builder;
+ }
+
///
/// Sets the number of worker nodes for the Kind cluster.
///
@@ -158,8 +228,8 @@ public static IResourceBuilder WithWorkerNodes(
///
/// Sets the cluster lifetime. When (the default),
- /// the cluster is deleted when the AppHost shuts down. When ,
- /// the cluster survives AppHost restarts and is reused on next startup.
+ /// the cluster is deleted on graceful shutdown or other process-exit signals on a best-effort basis.
+ /// When , the cluster survives AppHost restarts and is reused on next startup.
///
/// A resource type implementing .
/// The resource builder.
@@ -211,6 +281,18 @@ private static KindNodeImageAnnotation GetOrCreateNodeImageAnnotation(IResource
return annotation;
}
+ private static KindNodeMountsAnnotation GetOrCreateNodeMountsAnnotation(IResource resource)
+ {
+ if (resource.TryGetLastAnnotation(out var existing))
+ {
+ return existing;
+ }
+
+ var annotation = new KindNodeMountsAnnotation();
+ resource.Annotations.Add(annotation);
+ return annotation;
+ }
+
///
/// Verifies that the Kind CLI is installed and available on PATH.
///
@@ -270,4 +352,4 @@ public static IResourceBuilder WithReference(
}
}
-#pragma warning restore ASPIREATS001
+#pragma warning restore ASPIREATS001
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigAnnotation.cs
index e6ef296c4..e07fff109 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigAnnotation.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigAnnotation.cs
@@ -42,6 +42,12 @@ internal sealed class KindNodeImageAnnotation : IResourceAnnotation
/// Defaults to "kindest/node".
///
public string Registry { get; set; } = "kindest/node";
+
+ ///
+ /// Gets or sets the fully qualified node image.
+ /// When set, it takes precedence over + .
+ ///
+ public string? Image { get; set; }
}
///
@@ -63,3 +69,11 @@ public WorkerNodesAnnotation(int count)
///
public int Count { get; }
}
+
+///
+/// Represents annotations that add extra mounts to every Kind node.
+///
+internal sealed class KindNodeMountsAnnotation : IResourceAnnotation
+{
+ public IList Mounts { get; } = [];
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigGenerator.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigGenerator.cs
index dc5e135f4..fa67edf1c 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigGenerator.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigGenerator.cs
@@ -49,7 +49,15 @@ internal static async Task GenerateConfigAsync(IKindResource resource, C
// Apply Kubernetes version after all config callbacks so every node gets the image,
// regardless of the order WithKubernetesVersion and WithWorkerNodes/WithKindConfig were called.
if (resource.TryGetLastAnnotation(out var imageAnnotation) &&
- imageAnnotation.Version is not null)
+ imageAnnotation.Image is not null)
+ {
+ foreach (var node in config.Nodes)
+ {
+ node.Image ??= imageAnnotation.Image;
+ }
+ }
+ else if (resource.TryGetLastAnnotation(out imageAnnotation) &&
+ imageAnnotation.Version is not null)
{
var image = $"{imageAnnotation.Registry}:{imageAnnotation.Version}";
foreach (var node in config.Nodes)
@@ -58,6 +66,27 @@ internal static async Task GenerateConfigAsync(IKindResource resource, C
}
}
+ if (resource.TryGetLastAnnotation(out var mountsAnnotation) &&
+ mountsAnnotation.Mounts.Count > 0)
+ {
+ foreach (var node in config.Nodes)
+ {
+ node.ExtraMounts ??= [];
+
+ foreach (var mount in mountsAnnotation.Mounts)
+ {
+ node.ExtraMounts.Add(new KindMountModel
+ {
+ HostPath = mount.HostPath,
+ ContainerPath = mount.ContainerPath,
+ ReadOnly = mount.ReadOnly,
+ SelinuxRelabel = mount.SelinuxRelabel,
+ Propagation = mount.Propagation,
+ });
+ }
+ }
+ }
+
var yaml = s_serializer.Serialize(config);
await File.WriteAllTextAsync(configPath, yaml, cancellationToken).ConfigureAwait(false);
return configPath;
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs
index e363579a1..a469c5765 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs
@@ -41,4 +41,13 @@ public class KindHelmChartResource(string name, string chartRef, KindClusterReso
/// Gets the paths to values files (each maps to -f path).
///
public List ValuesFiles { get; } = [];
+
+ ///
+ /// Gets the inline Helm values that must be applied with --set-string key=value.
+ ///
+ public Dictionary StringValues { get; } = [];
+
+ internal int CrdWaitRetryMaxAttempts { get; set; } = 1;
+
+ internal TimeSpan CrdWaitRetryBackoff { get; set; } = TimeSpan.FromSeconds(5);
}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs
index 027fc121d..d5096aec4 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs
@@ -81,7 +81,7 @@ public static IResourceBuilder AddHelmChart(
await notifications.WaitForResourceAsync(resource.Parent.Name, KnownResourceStates.Running, ct);
await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, e.Services), ct);
-
+
await notifications.PublishUpdateAsync(resource,
state => state with { State = KnownResourceStates.Starting });
@@ -149,10 +149,69 @@ public static IResourceBuilder WithHelmValue(
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(value);
+ builder.Resource.StringValues.Remove(key);
builder.Resource.Values[key] = value;
return builder;
}
+ ///
+ /// Sets a Helm value while preserving it as a string (maps to helm install --set-string key=value).
+ ///
+ /// The Helm chart resource builder.
+ /// The Helm value key.
+ /// The Helm value.
+ /// A reference to the .
+ ///
+ /// Use this overload when Helm would otherwise coerce the value into a numeric, boolean,
+ /// or other non-string type. If the same key was previously configured with
+ /// ,
+ /// the string-preserving value replaces it.
+ ///
+ [AspireExport]
+ public static IResourceBuilder WithHelmStringValue(
+ this IResourceBuilder builder,
+ string key,
+ string value)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(key);
+ ArgumentNullException.ThrowIfNull(value);
+
+ builder.Resource.Values.Remove(key);
+ builder.Resource.StringValues[key] = value;
+ return builder;
+ }
+
+ ///
+ /// Retries Helm installs that race newly-created CRDs which have not reached the
+ /// Established condition yet.
+ ///
+ /// The Helm chart resource builder.
+ /// The total number of install attempts. Must be 2 or greater.
+ ///
+ /// The initial delay before retrying. Later retries back off exponentially.
+ /// When , Kind uses a 5 second initial backoff.
+ ///
+ /// A reference to the .
+ ///
+ /// This is useful for charts that create CRDs and immediately render custom resources
+ /// that depend on those CRDs. Between attempts, Kind waits for newly observed CRDs
+ /// to report Established.
+ ///
+ [AspireExport]
+ public static IResourceBuilder WithCrdWaitRetry(
+ this IResourceBuilder builder,
+ int maxAttempts = 3,
+ TimeSpan? backoff = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentOutOfRangeException.ThrowIfLessThan(maxAttempts, 2);
+
+ builder.Resource.CrdWaitRetryMaxAttempts = maxAttempts;
+ builder.Resource.CrdWaitRetryBackoff = KubectlTimeouts.Normalize(backoff ?? TimeSpan.FromSeconds(5), nameof(backoff));
+ return builder;
+ }
+
///
/// Adds a values file (maps to helm install -f path).
///
@@ -174,6 +233,10 @@ public static IResourceBuilder WithHelmValuesFile(
///
/// Sets the Kubernetes namespace for the deployment.
///
+ ///
+ /// Helm chart resources pass --create-namespace. Manifest resources only pass
+ /// --namespace to kubectl apply; they do not create the namespace.
+ ///
/// The deployed resource type.
/// The resource builder.
/// The Kubernetes namespace.
@@ -192,4 +255,4 @@ public static IResourceBuilder WithNamespace(
}
}
-#pragma warning restore ASPIREATS001
+#pragma warning restore ASPIREATS001
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs
new file mode 100644
index 000000000..6cd8ceaa5
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs
@@ -0,0 +1,289 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using CommunityToolkit.Aspire.Hosting.Kind;
+using Microsoft.Extensions.DependencyInjection;
+
+#pragma warning disable ASPIREATS001 // AspireExport APIs are experimental
+
+namespace Aspire.Hosting;
+
+///
+/// Extension methods for adding Kubernetes manifest resources to Kind clusters.
+///
+public static class KindManifestResourceBuilderExtensions
+{
+ ///
+ /// Adds a Kubernetes manifest to be applied to the Kind cluster via kubectl apply.
+ ///
+ /// The Kind cluster resource builder.
+ /// The name of the manifest resource.
+ ///
+ /// Absolute or relative path to a Kubernetes manifest file, a directory of manifest files,
+ /// or a Kustomize overlay directory. Relative paths are resolved against the AppHost project directory.
+ /// URL fetch is not supported; reference a local file or directory.
+ ///
+ /// A reference to the .
+ ///
+ /// The manifest is applied after the parent Kind cluster reaches the
+ /// state. Downstream resources that call
+ /// WaitFor on the manifest resource only start once kubectl apply succeeds.
+ /// Requires kubectl on PATH.
+ ///
+ [AspireExport]
+ public static IResourceBuilder AddManifest(
+ this IResourceBuilder builder,
+ [ResourceName] string name,
+ string manifestPath)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(name);
+ ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
+
+ var absolutePath = System.IO.Path.IsPathRooted(manifestPath)
+ ? manifestPath
+ : System.IO.Path.GetFullPath(
+ System.IO.Path.Combine(builder.ApplicationBuilder.AppHostDirectory, manifestPath));
+
+ var resource = new K8sManifestResource(name, absolutePath, builder.Resource);
+
+ return AddManifestResource(builder, resource);
+ }
+
+ ///
+ /// Adds Kubernetes manifest content to be applied to the Kind cluster via kubectl apply -f -.
+ ///
+ /// The Kind cluster resource builder.
+ /// The name of the manifest resource.
+ /// The Kubernetes manifest YAML content.
+ /// A reference to the .
+ [AspireExport]
+ public static IResourceBuilder AddManifestFromContent(
+ this IResourceBuilder builder,
+ [ResourceName] string name,
+ string content)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(name);
+ ArgumentNullException.ThrowIfNull(content);
+
+ var resource = new K8sManifestResource(name, K8sManifestResource.InlineManifestPath, builder.Resource)
+ {
+ InlineContent = content,
+ };
+
+ return AddManifestResource(builder, resource);
+ }
+
+ private static IResourceBuilder AddManifestResource(
+ IResourceBuilder builder,
+ K8sManifestResource resource)
+ {
+ var resourceBuilder = builder.ApplicationBuilder
+ .AddResource(resource)
+ .ExcludeFromManifest()
+ .WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = "K8s Manifest",
+ State = KnownResourceStates.NotStarted,
+ Properties = [
+ new("ManifestPath", resource.ManifestPath),
+ new("Mode", "apply"),
+ ]
+ });
+
+ resourceBuilder.OnInitializeResource(async (resource, e, ct) =>
+ {
+ var notifications = e.Notifications;
+ var loggerService = e.Services.GetRequiredService();
+ var logger = loggerService.GetLogger(resource);
+
+ // Wait for the parent Kind cluster to be running before applying the manifest.
+ await notifications.WaitForResourceAsync(resource.Parent.Name, KnownResourceStates.Running, ct);
+
+ await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, e.Services), ct);
+
+ await notifications.PublishUpdateAsync(resource,
+ state => state with { State = KnownResourceStates.Starting });
+
+ try
+ {
+ var processRunner = e.Services.GetRequiredService();
+ var kubectlManager = CreateKubectlManager(processRunner, resource);
+ await kubectlManager.ApplyAsync(resource, logger, ct);
+
+ await notifications.PublishUpdateAsync(resource,
+ state => state with
+ {
+ State = KnownResourceStates.Running,
+ Properties = [
+ new("ManifestPath", resource.ManifestPath),
+ new("Namespace", resource.Namespace ?? "(default)"),
+ new("ServerSide", resource.ServerSide.ToString()),
+ new("Mode", resource.IsKustomize ? "kustomize" : "apply"),
+ ]
+ });
+ }
+ catch (Exception)
+ {
+ await notifications.PublishUpdateAsync(resource,
+ state => state with { State = KnownResourceStates.FailedToStart });
+ throw;
+ }
+ });
+
+ return resourceBuilder;
+ }
+
+ internal static KubectlManager CreateKubectlManager(
+ IProcessRunner processRunner,
+ K8sManifestResource resource)
+ {
+ ArgumentNullException.ThrowIfNull(processRunner);
+ ArgumentNullException.ThrowIfNull(resource);
+
+ return new KubectlManager(
+ processRunner,
+ clusterInfoMaxWait: resource.ClusterReadyTimeout);
+ }
+
+ ///
+ /// Recursively applies manifests from subdirectories (maps to kubectl apply --recursive).
+ /// Only meaningful when the manifest path is a directory.
+ ///
+ /// The manifest resource builder.
+ /// A reference to the .
+ [AspireExport]
+ public static IResourceBuilder WithRecursive(
+ this IResourceBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Resource.Recursive = true;
+ return builder;
+ }
+
+ ///
+ /// Applies the manifest server-side (maps to kubectl apply --server-side).
+ /// Required for large CRDs that exceed the client-side annotation size limit.
+ ///
+ /// The manifest resource builder.
+ ///
+ /// When , also passes --force-conflicts to override field ownership
+ /// held by another field manager (e.g., a controller). Defaults to .
+ ///
+ /// A reference to the .
+ [AspireExport]
+ public static IResourceBuilder WithServerSideApply(
+ this IResourceBuilder builder,
+ bool forceConflicts = false)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Resource.ServerSide = true;
+ builder.Resource.ForceConflicts = forceConflicts;
+ return builder;
+ }
+
+ ///
+ /// Sets the field manager name passed to kubectl apply --field-manager.
+ ///
+ ///
+ /// The flag is passed whenever this method is used, but it is primarily meaningful with
+ /// server-side apply because Kubernetes records managed fields for server-side operations.
+ ///
+ /// The manifest resource builder.
+ /// The field manager name.
+ /// A reference to the .
+ [AspireExport]
+ public static IResourceBuilder WithFieldManager(
+ this IResourceBuilder builder,
+ string fieldManager)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(fieldManager);
+
+ builder.Resource.FieldManager = fieldManager;
+ return builder;
+ }
+
+ ///
+ /// Sets the maximum time to wait for the Kubernetes API to become reachable
+ /// before running kubectl apply.
+ ///
+ /// The manifest resource builder.
+ ///
+ /// The total readiness budget shared across repeated kubectl cluster-info probes.
+ /// Values are normalized to whole seconds and must fall within the supported timeout range.
+ ///
+ /// A reference to the .
+ ///
+ /// Use this when the cluster control plane can take longer than the default 60 seconds
+ /// to begin serving the Kubernetes API, such as on slower developer machines or busy CI hosts.
+ ///
+ [AspireExport]
+ public static IResourceBuilder WithClusterReadyTimeout(
+ this IResourceBuilder builder,
+ TimeSpan timeout)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Resource.ClusterReadyTimeout = KubectlTimeouts.Normalize(timeout, nameof(timeout));
+ return builder;
+ }
+
+ ///
+ /// Sets the maximum time to wait for kubectl apply to complete.
+ ///
+ /// The manifest resource builder.
+ /// The apply timeout.
+ /// A reference to the .
+ [AspireExport]
+ public static IResourceBuilder WithApplyTimeout(
+ this IResourceBuilder builder,
+ TimeSpan timeout)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Resource.ApplyTimeout = KubectlTimeouts.Normalize(timeout, nameof(timeout));
+ return builder;
+ }
+
+ ///
+ /// Sets the maximum time to wait for applied CRDs to reach the Established condition.
+ ///
+ /// The manifest resource builder.
+ /// The CRD wait timeout.
+ /// A reference to the .
+ [AspireExport]
+ public static IResourceBuilder WithCrdWaitTimeout(
+ this IResourceBuilder builder,
+ TimeSpan timeout)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Resource.CrdWaitTimeout = KubectlTimeouts.Normalize(timeout, nameof(timeout));
+ return builder;
+ }
+
+ ///
+ /// Sets whether CRD wait failures fail the manifest resource or are logged as best-effort warnings.
+ ///
+ /// The manifest resource builder.
+ /// The CRD wait behavior.
+ /// A reference to the .
+ [AspireExport]
+ public static IResourceBuilder WithCrdWaitBehavior(
+ this IResourceBuilder builder,
+ CrdWaitBehavior behavior)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Resource.CrdWaitBehavior = behavior;
+ return builder;
+ }
+
+}
+
+#pragma warning restore ASPIREATS001
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs
new file mode 100644
index 000000000..d661b7357
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs
@@ -0,0 +1,516 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.Logging;
+using System.ComponentModel;
+using System.Diagnostics;
+
+namespace CommunityToolkit.Aspire.Hosting.Kind;
+
+///
+/// Manages Kubernetes manifest applies to a Kind cluster by orchestrating kubectl CLI calls.
+///
+internal sealed class KubectlManager(
+ IProcessRunner processRunner,
+ Func? delayAsync = null,
+ TimeSpan? clusterInfoMaxWait = null,
+ TimeSpan? clusterInfoProbeTimeout = null)
+{
+ private const string KubectlNotFoundMessage = "kubectl CLI not found. Install it from https://kubernetes.io/docs/tasks/tools/";
+ private static readonly TimeSpan ClusterInfoMaxWait = TimeSpan.FromSeconds(60);
+ private static readonly TimeSpan ClusterInfoProbeTimeout = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan ClusterInfoInitialDelay = TimeSpan.FromSeconds(1);
+ private static readonly TimeSpan ClusterInfoMaxDelay = TimeSpan.FromSeconds(10);
+
+ private readonly Func _delayAsync = delayAsync ?? Task.Delay;
+ private readonly TimeSpan _clusterInfoMaxWait = clusterInfoMaxWait ?? ClusterInfoMaxWait;
+ private readonly TimeSpan _clusterInfoProbeTimeout = clusterInfoProbeTimeout ?? ClusterInfoProbeTimeout;
+
+ internal TimeSpan ClusterInfoMaxWaitForTesting => _clusterInfoMaxWait;
+
+ ///
+ /// Waits for the cluster API to answer, then applies the manifest via kubectl apply.
+ ///
+ public async Task ApplyAsync(K8sManifestResource resource, ILogger logger, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+
+ await WaitForClusterInfoAsync(resource, logger, cancellationToken).ConfigureAwait(false);
+
+ var args = CreateApplyArguments(resource);
+
+ if (resource.Recursive && resource.IsKustomize)
+ {
+ logger.LogWarning(
+ "Ignoring recursive apply for Kustomize manifest '{ManifestPath}' because kubectl apply -k does not support --recursive.",
+ resource.ManifestPath);
+ }
+ else if (resource.Recursive && resource.InlineContent is not null)
+ {
+ logger.LogWarning(
+ "Ignoring recursive apply for inline manifest '{ManifestPath}' because kubectl apply -f - does not support --recursive.",
+ resource.ManifestPath);
+ }
+
+ logger.LogInformation(
+ "Applying manifest '{ManifestPath}' to cluster '{ClusterName}'...",
+ resource.ManifestPath, resource.Parent.Name);
+
+ ProcessResult result;
+ var applyTimeout = KubectlTimeouts.Normalize(resource.ApplyTimeout, nameof(resource.ApplyTimeout));
+ using (var applyCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
+ {
+ applyCts.CancelAfter(applyTimeout);
+
+ try
+ {
+ result = await RunKubectlAsync(
+ logger,
+ args,
+ standardInput: resource.InlineContent,
+ cancellationToken: applyCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ throw new TimeoutException(
+ $"Timed out applying manifest '{resource.ManifestPath}' to cluster '{resource.Parent.Name}' after {applyTimeout}.");
+ }
+ }
+
+ if (result.ExitCode != 0)
+ {
+ throw new InvalidOperationException(
+ $"Failed to apply manifest '{resource.ManifestPath}' to cluster '{resource.Parent.Name}': {FormatFailureOutput(result)}");
+ }
+
+ var crdNames = GetAppliedCrdNames(result.Output);
+ if (crdNames.Count > 0)
+ {
+ await WaitForCrdsAsync(crdNames, resource, logger, cancellationToken).ConfigureAwait(false);
+ }
+
+ logger.LogInformation(
+ "Manifest '{ManifestPath}' applied successfully.", resource.ManifestPath);
+ }
+
+ ///
+ /// Waits for applied CRDs to reach the Kubernetes Established condition.
+ ///
+ internal async Task WaitForCrdsAsync(
+ IEnumerable crdNames,
+ K8sManifestResource resource,
+ ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(crdNames);
+ ArgumentNullException.ThrowIfNull(resource);
+
+ var crds = crdNames.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ if (crds.Length == 0)
+ {
+ return;
+ }
+
+ var args = CreateWaitArguments(crds, resource.Parent.KubeconfigPath, resource.CrdWaitTimeout);
+
+ logger.LogInformation(
+ "Waiting for {CrdCount} custom resource definition(s) to become Established...",
+ crds.Length);
+
+ var result = await RunKubectlAsync(
+ logger,
+ args,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (result.ExitCode != 0)
+ {
+ var message = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
+ if (resource.CrdWaitBehavior == CrdWaitBehavior.BestEffort)
+ {
+ logger.LogWarning(
+ "Timed out or failed while waiting for custom resource definition(s) to become Established: {Error}",
+ message);
+ return;
+ }
+
+ throw new InvalidOperationException(
+ $"Timed out or failed while waiting for custom resource definition(s) to become Established: {message}");
+ }
+ }
+
+ internal Task WaitForCrdsAsync(
+ IEnumerable crdNames,
+ string kubeconfigPath,
+ TimeSpan timeout,
+ ILogger logger,
+ CancellationToken cancellationToken) =>
+ WaitForCrdsCoreAsync(crdNames, kubeconfigPath, timeout, logger, cancellationToken, bestEffort: false);
+
+ internal async Task> GetCustomResourceDefinitionsAsync(
+ string kubeconfigPath,
+ ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath);
+
+ var result = await RunKubectlAsync(
+ logger,
+ CreateGetCrdsArguments(kubeconfigPath),
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (result.ExitCode != 0)
+ {
+ throw new InvalidOperationException(
+ "Failed to query custom resource definitions: " +
+ (string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error));
+ }
+
+ return ParseResourceNames(result.Output);
+ }
+
+ ///
+ /// Creates the kubectl apply argument list for a manifest resource.
+ ///
+ internal static IReadOnlyList CreateApplyArguments(K8sManifestResource resource)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+ var isKustomize = resource.InlineContent is null &&
+ Directory.Exists(resource.ManifestPath) &&
+ IsKustomizeDirectory(resource.ManifestPath);
+ resource.IsKustomize = isKustomize;
+
+ List arguments =
+ [
+ "apply",
+ ];
+
+ if (resource.InlineContent is not null)
+ {
+ arguments.Add("-f");
+ arguments.Add("-");
+ }
+ else if (isKustomize)
+ {
+ arguments.Add("-k");
+ arguments.Add(resource.ManifestPath);
+ }
+ else
+ {
+ arguments.Add("-f");
+ arguments.Add(resource.ManifestPath);
+ }
+
+ arguments.Add($"--kubeconfig={resource.Parent.KubeconfigPath}");
+
+ if (!string.IsNullOrEmpty(resource.Namespace))
+ {
+ arguments.Add("--namespace");
+ arguments.Add(resource.Namespace);
+ }
+
+ if (resource.Recursive && !isKustomize && resource.InlineContent is null)
+ {
+ arguments.Add("--recursive");
+ }
+
+ if (resource.ServerSide)
+ {
+ arguments.Add("--server-side");
+
+ if (resource.ForceConflicts)
+ {
+ arguments.Add("--force-conflicts");
+ }
+ }
+
+ if (!string.IsNullOrEmpty(resource.FieldManager))
+ {
+ arguments.Add("--field-manager");
+ arguments.Add(resource.FieldManager);
+ }
+
+ return arguments;
+ }
+
+ ///
+ /// Creates the kubectl wait argument list for applied CRDs.
+ ///
+ internal static IReadOnlyList CreateWaitArguments(
+ IEnumerable crdNames,
+ string kubeconfigPath,
+ TimeSpan timeout)
+ {
+ ArgumentNullException.ThrowIfNull(crdNames);
+ ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath);
+
+ List arguments =
+ [
+ "wait",
+ "--for=condition=Established",
+ ];
+
+ arguments.AddRange(crdNames);
+ arguments.Add($"--timeout={KubectlTimeouts.ToSeconds(timeout, nameof(timeout))}s");
+ arguments.Add($"--kubeconfig={kubeconfigPath}");
+
+ return arguments;
+ }
+
+ ///
+ /// Creates the kubectl cluster-info argument list for an API reachability probe.
+ ///
+ internal static IReadOnlyList CreateClusterInfoArguments(string kubeconfigPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath);
+
+ return
+ [
+ "cluster-info",
+ $"--kubeconfig={kubeconfigPath}",
+ ];
+ }
+
+ internal static IReadOnlyList CreateGetCrdsArguments(string kubeconfigPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath);
+
+ return
+ [
+ "get",
+ "crd",
+ "-o",
+ "name",
+ $"--kubeconfig={kubeconfigPath}",
+ ];
+ }
+
+ ///
+ /// Returns whether the directory contains a Kustomize marker file recognized by kubectl apply -k.
+ ///
+ internal static bool IsKustomizeDirectory(string directory)
+ {
+ ArgumentNullException.ThrowIfNull(directory);
+
+ if (!Directory.Exists(directory))
+ {
+ return false;
+ }
+
+ return Directory.EnumerateFiles(directory, "*", SearchOption.TopDirectoryOnly)
+ .Select(Path.GetFileName)
+ .Any(fileName =>
+ string.Equals(fileName, "kustomization.yaml", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(fileName, "kustomization.yml", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(fileName, "kustomization", StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// Waits until the cluster API server is reachable through kubectl cluster-info.
+ ///
+ internal async Task WaitForClusterInfoAsync(
+ K8sManifestResource resource,
+ ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+
+ var args = CreateClusterInfoArguments(resource.Parent.KubeconfigPath);
+ var nextDelay = ClusterInfoInitialDelay;
+ var stopwatch = Stopwatch.StartNew();
+
+ while (true)
+ {
+ if (stopwatch.Elapsed >= _clusterInfoMaxWait)
+ {
+ throw new InvalidOperationException(
+ $"Timed out waiting for cluster '{resource.Parent.Name}' API to become reachable.");
+ }
+
+ var remaining = _clusterInfoMaxWait - stopwatch.Elapsed;
+ var probeTimeout = Min(remaining, _clusterInfoProbeTimeout);
+ ProcessResult result;
+ using (var probeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
+ {
+ probeCts.CancelAfter(probeTimeout);
+
+ try
+ {
+ result = await RunKubectlAsync(
+ logger,
+ args,
+ cancellationToken: probeCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ if (stopwatch.Elapsed >= _clusterInfoMaxWait)
+ {
+ throw new InvalidOperationException(
+ $"Timed out waiting for cluster '{resource.Parent.Name}' API to become reachable.");
+ }
+
+ result = new ProcessResult(1, "", "kubectl cluster-info probe timed out.");
+ }
+ }
+
+ if (result.ExitCode == 0)
+ {
+ return;
+ }
+
+ if (stopwatch.Elapsed >= _clusterInfoMaxWait)
+ {
+ throw new InvalidOperationException(
+ $"Timed out waiting for cluster '{resource.Parent.Name}' API to become reachable: " +
+ (string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error));
+ }
+
+ remaining = _clusterInfoMaxWait - stopwatch.Elapsed;
+ var delay = Min(nextDelay, remaining);
+ if (delay <= TimeSpan.Zero)
+ {
+ throw new InvalidOperationException(
+ $"Timed out waiting for cluster '{resource.Parent.Name}' API to become reachable: " +
+ (string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error));
+ }
+
+ logger.LogWarning(
+ "Cluster '{ClusterName}' API is not reachable yet; retrying kubectl cluster-info in {DelaySeconds:n1}s. Last error: {Error}",
+ resource.Parent.Name,
+ delay.TotalSeconds,
+ string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error);
+
+ await _delayAsync(delay, cancellationToken).ConfigureAwait(false);
+ nextDelay = TimeSpan.FromSeconds(Math.Min(nextDelay.TotalSeconds * 2, ClusterInfoMaxDelay.TotalSeconds));
+ }
+ }
+
+ private static TimeSpan Min(TimeSpan left, TimeSpan right)
+ {
+ return left <= right ? left : right;
+ }
+
+ private async Task RunKubectlAsync(
+ ILogger logger,
+ IReadOnlyList arguments,
+ string? standardInput = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ return await processRunner.RunAsync(
+ logger,
+ "kubectl",
+ arguments,
+ standardInput: standardInput,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ }
+ catch (Win32Exception ex)
+ {
+ throw new InvalidOperationException(KubectlNotFoundMessage, ex);
+ }
+ }
+
+ private static string FormatFailureOutput(ProcessResult result)
+ {
+ var messages = new[]
+ {
+ result.Error?.Trim(),
+ result.Output?.Trim(),
+ };
+
+ return string.Join(
+ Environment.NewLine,
+ messages.Where(static message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal));
+ }
+
+ ///
+ /// Extracts CRD resource names from kubectl apply output.
+ ///
+ private static IReadOnlyList GetAppliedCrdNames(string output)
+ {
+ if (string.IsNullOrWhiteSpace(output))
+ {
+ return [];
+ }
+
+ var crds = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ using var reader = new StringReader(output);
+ string? line;
+ while ((line = reader.ReadLine()) is not null)
+ {
+ if (!line.StartsWith("customresourcedefinition.", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var resourceName = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
+ if (!string.IsNullOrWhiteSpace(resourceName))
+ {
+ crds.Add(resourceName);
+ }
+ }
+
+ return [.. crds];
+ }
+
+ private async Task WaitForCrdsCoreAsync(
+ IEnumerable crdNames,
+ string kubeconfigPath,
+ TimeSpan timeout,
+ ILogger logger,
+ CancellationToken cancellationToken,
+ bool bestEffort)
+ {
+ ArgumentNullException.ThrowIfNull(crdNames);
+ ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath);
+
+ var crds = crdNames.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ if (crds.Length == 0)
+ {
+ return;
+ }
+
+ var args = CreateWaitArguments(crds, kubeconfigPath, timeout);
+
+ logger.LogInformation(
+ "Waiting for {CrdCount} custom resource definition(s) to become Established...",
+ crds.Length);
+
+ var result = await RunKubectlAsync(
+ logger,
+ args,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (result.ExitCode == 0)
+ {
+ return;
+ }
+
+ var message = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
+ if (bestEffort)
+ {
+ logger.LogWarning(
+ "Timed out or failed while waiting for custom resource definition(s) to become Established: {Error}",
+ message);
+ return;
+ }
+
+ throw new InvalidOperationException(
+ $"Timed out or failed while waiting for custom resource definition(s) to become Established: {message}");
+ }
+
+ private static IReadOnlySet ParseResourceNames(string output)
+ {
+ if (string.IsNullOrWhiteSpace(output))
+ {
+ return new HashSet(StringComparer.OrdinalIgnoreCase);
+ }
+
+ return output
+ .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs
new file mode 100644
index 000000000..38229e64e
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs
@@ -0,0 +1,29 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace CommunityToolkit.Aspire.Hosting.Kind;
+
+internal static class KubectlTimeouts
+{
+ internal static readonly TimeSpan MaximumTimeout = TimeSpan.FromHours(1);
+
+ internal static TimeSpan Normalize(TimeSpan timeout, string parameterName)
+ {
+ if (timeout <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(parameterName, timeout, "Timeout must be greater than zero.");
+ }
+
+ if (timeout > MaximumTimeout)
+ {
+ throw new ArgumentOutOfRangeException(parameterName, timeout, $"Timeout must be less than or equal to {MaximumTimeout}.");
+ }
+
+ return TimeSpan.FromSeconds(Math.Ceiling(timeout.TotalSeconds));
+ }
+
+ internal static int ToSeconds(TimeSpan timeout, string parameterName)
+ {
+ return (int)Normalize(timeout, parameterName).TotalSeconds;
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md
index 1cb74338b..26caddee1 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md
@@ -6,6 +6,7 @@ An [Aspire](https://learn.microsoft.com/dotnet/aspire) hosting integration that
- **Docker or Podman** - Kind runs Kubernetes nodes as containers. Install [Docker](https://docs.docker.com/get-docker/) or [Podman](https://podman.io/docs/installation).
- **Kind CLI** - The `kind` command must be available on your `PATH`. Install from [kind.sigs.k8s.io](https://kind.sigs.k8s.io/docs/user/quick-start/#installation).
+- **kubectl CLI** - Required for `AddManifest` / `AddManifestFromContent`. Install from [kubernetes.io](https://kubernetes.io/docs/tasks/tools/).
- **Helm CLI** - Required for deploy scenarios and Helm chart resources. Install from [helm.sh](https://helm.sh/docs/intro/install/).
## Getting started
@@ -26,7 +27,7 @@ var cluster = builder.AddKindCluster("mycluster");
builder.Build().Run();
```
-This creates a Kind cluster named **mycluster** that is provisioned when the AppHost starts and deleted when it shuts down.
+This creates a Kind cluster named **mycluster** that is provisioned when the AppHost starts and is cleaned up on graceful shutdown, Ctrl+C, or other process-exit signals on a best-effort basis.
## Scenario 1: Kind cluster as a managed dependency (F5 mode)
@@ -52,9 +53,27 @@ var cluster = builder.AddKindCluster("mycluster")
.WithKubernetesVersion("v1.32.2");
```
+#### WithNodeImage
+
+Use `WithNodeImage` when you need to pin the full Kind node image name instead of composing one from a Kubernetes version. This is useful with private mirrors or pre-approved node images.
+
+```csharp
+var cluster = builder.AddKindCluster("mycluster")
+ .WithNodeImage("registry.example.com/kindest/node:v1.32.2");
+```
+
+#### WithNodeMount
+
+Use `WithNodeMount` to project host content into every Kind node. This is useful for local charts, registries, or other host-side assets that must be visible from inside the cluster nodes. Relative host paths are resolved against the AppHost project directory. The sample AppHost shows this as a cluster-configuration example; it does not include a workload that reads the mounted path from inside the cluster.
+
+```csharp
+var cluster = builder.AddKindCluster("mycluster")
+ .WithNodeMount(@"C:\dev\charts", "/var/local/charts", readOnly: true);
+```
+
#### Cluster lifetime
-By default the cluster is deleted when the AppHost shuts down (`ClusterLifetime.Session`). To keep the cluster across AppHost restarts, use `ClusterLifetime.Persistent`:
+By default the cluster is deleted on graceful shutdown or other process-exit signals (`ClusterLifetime.Session`) on a best-effort basis. To keep the cluster across AppHost restarts, use `ClusterLifetime.Persistent`:
```csharp
var cluster = builder.AddKindCluster("mycluster")
@@ -63,7 +82,7 @@ var cluster = builder.AddKindCluster("mycluster")
| Value | Behavior |
|---|---|
-| `ClusterLifetime.Session` | Cluster is deleted on AppHost shutdown (default). |
+| `ClusterLifetime.Session` | Cluster is deleted on graceful shutdown or process exit on a best-effort basis (default). |
| `ClusterLifetime.Persistent` | Cluster survives AppHost restarts and is reused on next startup. |
## Networking model
@@ -150,9 +169,103 @@ var cluster = builder.AddKindCluster("mycluster")
var redis = cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
.WithChartVersion("20.0.0")
.WithHelmValue("replica.replicaCount", "0")
+ .WithHelmStringValue("auth.password", "000123")
+ .WithCrdWaitRetry()
.WithNamespace("cache");
```
+#### WithHelmStringValue
+
+Use `WithHelmStringValue` when a value looks numeric or boolean but must remain a string in the rendered chart. Kind emits `--set-string` for these values instead of `--set`.
+
+```csharp
+var redis = cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
+ .WithHelmStringValue("auth.password", "000123");
+```
+
+#### WithCrdWaitRetry
+
+Use `WithCrdWaitRetry` for charts that install CRDs and then immediately reference them. Kind retries the Helm install, waits for newly-created CRDs to reach `Established`, then re-runs Helm.
+
+```csharp
+var certManager = cluster.AddHelmChart("cert-manager", "jetstack/cert-manager")
+ .WithCrdWaitRetry(maxAttempts: 3, backoff: TimeSpan.FromSeconds(5));
+```
+
+### Applying raw manifests to the cluster
+
+Use `AddManifest` to apply a Kubernetes manifest (file, directory, or Kustomize overlay) to the cluster after it becomes healthy. This runs `kubectl apply -f --kubeconfig ` against the cluster kubeconfig and is the natural equivalent of `AddHelmChart` for scenarios where a chart would be overkill. Relative paths are resolved against the AppHost project directory.
+
+`AddManifest` supports a single file, a directory, or a Kustomize overlay. URL fetch is not supported today β use a local file. For URL support, `curl` the file down as a build step and reference the local path.
+
+If the path is a directory containing a Kustomize marker file such as `kustomization.yaml`, `kustomization.yml`, `Kustomization`, or `Kustomization.yml`, Kind automatically switches to `kubectl apply -k ` at apply time. `WithRecursive()` is ignored for Kustomize overlays because `kubectl apply -k` does not support `--recursive`.
+
+```csharp
+var cluster = builder.AddKindCluster("mycluster");
+
+// Single file
+cluster.AddManifest("crds", "./manifests/crds.yaml");
+
+// Directory, recursive, into a namespace
+cluster.AddManifest("platform", "./manifests/platform")
+ .WithRecursive()
+ .WithNamespace("platform");
+
+// Server-side apply with a stable field manager (useful for CRDs and controllers
+// that reconcile large objects)
+cluster.AddManifest("operator", "./manifests/argocd/install.yaml")
+ .WithServerSideApply(forceConflicts: true)
+ .WithFieldManager("aspire-apphost");
+
+// Inline content, applied via kubectl apply -f -
+cluster.AddManifestFromContent("demo-ns", """
+ apiVersion: v1
+ kind: Namespace
+ metadata:
+ name: aspire-demo
+ """);
+```
+
+Downstream resources can wait on the manifest resource before starting so they only see the cluster after the manifests have been applied:
+
+```csharp
+var crds = cluster.AddManifest("crds", "./manifests/crds.yaml");
+
+var operatorContainer = builder.AddContainer("my-operator", "my-org/operator")
+ .WithReference(cluster)
+ .WaitFor(crds);
+```
+
+Manifests persist with the cluster - deleted with session clusters, retained with persistent clusters.
+
+When `kubectl apply` reports custom resource definitions, Kind waits up to 5 minutes for those CRDs to reach the `Established` condition before marking the manifest resource running. The default behavior is fail-fast: a CRD wait timeout fails the manifest resource. Use `.WithCrdWaitTimeout(...)` to adjust the timeout, or `.WithCrdWaitBehavior(CrdWaitBehavior.BestEffort)` to log a warning and continue.
+
+#### WithClusterReadyTimeout
+
+If the Kubernetes API needs longer to become reachable before `kubectl apply`, use `WithClusterReadyTimeout` to extend the `kubectl cluster-info` readiness budget for that manifest resource.
+
+```csharp
+cluster.AddManifest("platform", "./manifests/platform")
+ .WithClusterReadyTimeout(TimeSpan.FromMinutes(2));
+```
+
+#### Namespace behavior
+
+`.WithNamespace(ns)` passes `--namespace ` to `kubectl apply`, but it does **not** create the namespace. This differs from `AddHelmChart`, which passes `--create-namespace`.
+
+If a manifest declares its own `metadata.namespace` that conflicts with `--namespace`, `kubectl` rejects the apply. Prefer one of these patterns:
+
+- Create the namespace with a separate `AddManifest("ns", "namespace.yaml")` and `.WaitFor()` it before applying namespaced manifests.
+- Omit `.WithNamespace()` and let each manifest declare its own namespace.
+
+#### WithServerSideApply
+
+> **Warning:** `forceConflicts: true` overrides field ownership held by other controllers. Use sparingly β you're telling `kubectl` to overwrite whatever another controller (for example, Helm) had set for those fields.
+
+#### Security scope
+
+Manifests apply with the cluster kubeconfig, which for a local Kind cluster is typically cluster-admin. `AddManifest` can create cluster-scoped resources such as `ClusterRole`, `ClusterRoleBinding`, and CRDs. Only apply manifests you trust.
+
### Full F5 example
```csharp
@@ -235,9 +348,28 @@ builder.AddContainer("my-container", "my-image")
| `WithReference(kind)` | Injects `KUBECONFIG` and `K8S_CLUSTER_NAME` into another resource |
| `WithKindNetwork()` | Connects a container to the Kind container network |
| `AddHelmChart(name, chartRef)` | Deploys a Helm chart to the Kind cluster during F5 |
+| `AddManifest(name, manifestPath)` | Applies a Kubernetes manifest (file or directory) via `kubectl apply` after the cluster is healthy |
+| `AddManifestFromContent(name, content)` | Applies inline Kubernetes manifest content via `kubectl apply -f -` after the cluster is healthy |
+| `WithRecursive()` | For `AddManifest`: recurse into subdirectories when applying |
+| `WithServerSideApply(bool forceConflicts = false)` | For `AddManifest`: use server-side apply, optionally with `--force-conflicts` |
+| `WithFieldManager(string)` | For `AddManifest`: set the `kubectl apply --field-manager` identifier |
+| `WithApplyTimeout(TimeSpan)` | For `AddManifest`: set the maximum time for `kubectl apply` |
+| `WithCrdWaitTimeout(TimeSpan)` | For `AddManifest`: set the CRD `Established` wait timeout |
+| `WithCrdWaitBehavior(CrdWaitBehavior)` | For `AddManifest`: choose fail-fast or best-effort CRD wait behavior |
| `WithKind()` | Configures a `KubernetesEnvironmentResource` to deploy to a local Kind cluster (scenario 2) |
+
+## Security & scope notes
+
+- Relative paths are resolved against the AppHost directory but are not sandboxed; do not apply untrusted manifests.
+- `AddManifestFromContent(string)` holds stdin content in memory. For very large manifests, use `AddManifest(file)` instead.
+- `AddManifest` runs with whatever authority the cluster kubeconfig has β cluster-admin on a default Kind cluster.
+
## Additional information
- [Kind documentation](https://kind.sigs.k8s.io/)
- [.NET Aspire documentation](https://learn.microsoft.com/dotnet/aspire)
- [Aspire Community Toolkit](https://github.com/CommunityToolkit/Aspire)
+- [`kubectl apply` documentation](https://kubernetes.io/docs/reference/kubectl/generated/kubectl_apply/)
+- [Kubernetes server-side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/)
+- [Kustomize documentation](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/)
+- [CRD Established condition](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/)
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/api/CommunityToolkit.Aspire.Hosting.Kind.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/api/CommunityToolkit.Aspire.Hosting.Kind.cs
new file mode 100644
index 000000000..976e33c41
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/api/CommunityToolkit.Aspire.Hosting.Kind.cs
@@ -0,0 +1,280 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+namespace Aspire.Hosting
+{
+ public enum ClusterLifetime
+ {
+ Session = 0,
+ Persistent = 1
+ }
+
+ public enum CrdWaitBehavior
+ {
+ Fail = 0,
+ BestEffort = 1
+ }
+
+ public static partial class KindClusterResourceBuilderExtensions
+ {
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder AddKindCluster(this IDistributedApplicationBuilder builder, string name) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithClusterLifetime(this ApplicationModel.IResourceBuilder builder, ClusterLifetime lifetime)
+ where T : ApplicationModel.IKindResource { throw null; }
+
+ [AspireExportIgnore(Reason = "Action is not ATS-compatible. Configure Kind config through ATS-supported APIs.")]
+ public static ApplicationModel.IResourceBuilder WithKindConfig(this ApplicationModel.IResourceBuilder builder, System.Action configure) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithKubernetesVersion(this ApplicationModel.IResourceBuilder builder, string version)
+ where T : ApplicationModel.IKindResource { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithNodeImage(this ApplicationModel.IResourceBuilder builder, string image)
+ where T : ApplicationModel.IKindResource { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithNodeMount(this ApplicationModel.IResourceBuilder builder, string hostPath, string containerPath, bool readOnly = false)
+ where T : ApplicationModel.IKindResource { throw null; }
+
+ [AspireExport("withKindClusterReference")]
+ public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder kind)
+ where T : ApplicationModel.IResourceWithEnvironment { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithWorkerNodes(this ApplicationModel.IResourceBuilder builder, int count)
+ where T : ApplicationModel.IKindResource { throw null; }
+ }
+
+ public static partial class KindContainerExtensions
+ {
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithKindNetwork(this ApplicationModel.IResourceBuilder builder) { throw null; }
+
+ [AspireExport("withKindContainerReference")]
+ public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder kind) { throw null; }
+ }
+
+ public static partial class KindHelmChartResourceBuilderExtensions
+ {
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder AddHelmChart(this ApplicationModel.IResourceBuilder builder, string name, string chartRef) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithChartVersion(this ApplicationModel.IResourceBuilder builder, string version) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithCrdWaitRetry(this ApplicationModel.IResourceBuilder builder, int maxAttempts = 3, System.TimeSpan? backoff = null) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithHelmStringValue(this ApplicationModel.IResourceBuilder builder, string key, string value) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithHelmValue(this ApplicationModel.IResourceBuilder builder, string key, string value) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithHelmValuesFile(this ApplicationModel.IResourceBuilder builder, string path) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithNamespace(this ApplicationModel.IResourceBuilder builder, string @namespace)
+ where T : ApplicationModel.KindDeployedResource { throw null; }
+ }
+
+ public static partial class KindManifestResourceBuilderExtensions
+ {
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder AddManifest(this ApplicationModel.IResourceBuilder builder, string name, string manifestPath) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder AddManifestFromContent(this ApplicationModel.IResourceBuilder builder, string name, string content) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithApplyTimeout(this ApplicationModel.IResourceBuilder builder, System.TimeSpan timeout) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithClusterReadyTimeout(this ApplicationModel.IResourceBuilder builder, System.TimeSpan timeout) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithCrdWaitBehavior(this ApplicationModel.IResourceBuilder builder, CrdWaitBehavior behavior) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithCrdWaitTimeout(this ApplicationModel.IResourceBuilder builder, System.TimeSpan timeout) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithFieldManager(this ApplicationModel.IResourceBuilder builder, string fieldManager) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithRecursive(this ApplicationModel.IResourceBuilder builder) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithServerSideApply(this ApplicationModel.IResourceBuilder builder, bool forceConflicts = false) { throw null; }
+ }
+
+ public static partial class KubernetesEnvironmentKindExtensions
+ {
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithKind(this ApplicationModel.IResourceBuilder builder) { throw null; }
+ }
+}
+
+namespace Aspire.Hosting.ApplicationModel
+{
+ public partial interface IKindResource : IResource
+ {
+ string KubeconfigPath { get; }
+ }
+
+ [AspireExport(ExposeProperties = true)]
+ public partial class K8sManifestResource : KindDeployedResource
+ {
+ public K8sManifestResource(string name, string manifestPath, KindClusterResource parent) : base(default!, default!) { }
+
+ public System.TimeSpan ApplyTimeout { get { throw null; } set { } }
+
+ public System.TimeSpan ClusterReadyTimeout { get { throw null; } set { } }
+
+ public CrdWaitBehavior CrdWaitBehavior { get { throw null; } set { } }
+
+ public System.TimeSpan CrdWaitTimeout { get { throw null; } set { } }
+
+ public string? FieldManager { get { throw null; } set { } }
+
+ public bool ForceConflicts { get { throw null; } set { } }
+
+ public string? InlineContent { get { throw null; } set { } }
+
+ public bool IsKustomize { get { throw null; } set { } }
+
+ public string ManifestPath { get { throw null; } }
+
+ public bool Recursive { get { throw null; } set { } }
+
+ public bool ServerSide { get { throw null; } set { } }
+ }
+
+ public sealed partial class KindClusterResource : Resource, IKindResource, IResource, IResourceWithWaitSupport
+ {
+ public KindClusterResource(string name) : base(default!) { }
+
+ public string ContainerKubeconfigPath { get { throw null; } }
+
+ public string KubeconfigPath { get { throw null; } }
+ }
+
+ public sealed partial class KindConfigModel
+ {
+ public string ApiVersion { get { throw null; } set { } }
+
+ public System.Collections.Generic.IList? ContainerdConfigPatches { get { throw null; } set { } }
+
+ public System.Collections.Generic.IDictionary? FeatureGates { get { throw null; } set { } }
+
+ public string Kind { get { throw null; } set { } }
+
+ public System.Collections.Generic.IList? KubeadmConfigPatches { get { throw null; } set { } }
+
+ public KindNetworkingModel? Networking { get { throw null; } set { } }
+
+ public System.Collections.Generic.IList Nodes { get { throw null; } }
+
+ public System.Collections.Generic.IDictionary? RuntimeConfig { get { throw null; } set { } }
+ }
+
+ public abstract partial class KindDeployedResource : Resource, IResourceWithParent, IResourceWithParent, IResource, IResourceWithWaitSupport
+ {
+ protected KindDeployedResource(string name, KindClusterResource parent) : base(default!) { }
+
+ public string? Namespace { get { throw null; } set { } }
+
+ public KindClusterResource Parent { get { throw null; } }
+ }
+
+ public sealed partial class KindEnvironmentResource : Resource, IKindResource, IResource, IResourceWithParent, IResourceWithParent
+ {
+ public KindEnvironmentResource(string name, Kubernetes.KubernetesEnvironmentResource parent) : base(default!) { }
+
+ public string KubeconfigPath { get { throw null; } }
+
+ public Kubernetes.KubernetesEnvironmentResource Parent { get { throw null; } }
+ }
+
+ public partial class KindHelmChartResource : KindDeployedResource
+ {
+ public KindHelmChartResource(string name, string chartRef, KindClusterResource parent) : base(default!, default!) { }
+
+ public string ChartRef { get { throw null; } }
+
+ public string ReleaseName { get { throw null; } }
+
+ public System.Collections.Generic.Dictionary StringValues { get { throw null; } }
+
+ public System.Collections.Generic.Dictionary Values { get { throw null; } }
+
+ public System.Collections.Generic.List ValuesFiles { get { throw null; } }
+
+ public string? Version { get { throw null; } set { } }
+ }
+
+ public sealed partial class KindMountModel
+ {
+ public string? ContainerPath { get { throw null; } set { } }
+
+ public string? HostPath { get { throw null; } set { } }
+
+ public string? Propagation { get { throw null; } set { } }
+
+ public bool? ReadOnly { get { throw null; } set { } }
+
+ public bool? SelinuxRelabel { get { throw null; } set { } }
+ }
+
+ public sealed partial class KindNetworkingModel
+ {
+ public string? ApiServerAddress { get { throw null; } set { } }
+
+ public int? ApiServerPort { get { throw null; } set { } }
+
+ public bool? DisableDefaultCNI { get { throw null; } set { } }
+
+ public string? IpFamily { get { throw null; } set { } }
+
+ public string? KubeProxyMode { get { throw null; } set { } }
+
+ public string? PodSubnet { get { throw null; } set { } }
+
+ public string? ServiceSubnet { get { throw null; } set { } }
+ }
+
+ public sealed partial class KindNodeModel
+ {
+ public System.Collections.Generic.IList? ExtraMounts { get { throw null; } set { } }
+
+ public System.Collections.Generic.IList? ExtraPortMappings { get { throw null; } set { } }
+
+ public string? Image { get { throw null; } set { } }
+
+ public System.Collections.Generic.IList? KubeadmConfigPatches { get { throw null; } set { } }
+
+ public System.Collections.Generic.IDictionary? Labels { get { throw null; } set { } }
+
+ public string Role { get { throw null; } set { } }
+ }
+
+ public sealed partial class KindPortMappingModel
+ {
+ public int? ContainerPort { get { throw null; } set { } }
+
+ public int? HostPort { get { throw null; } set { } }
+
+ public string? ListenAddress { get { throw null; } set { } }
+
+ public string? Protocol { get { throw null; } set { } }
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs
index 05db84cd2..052394bb3 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs
@@ -1,12 +1,15 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-using System.Runtime.InteropServices;
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Eventing;
using Aspire.Hosting.Lifecycle;
using CommunityToolkit.Aspire.Testing;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
+using System.Runtime.InteropServices;
namespace CommunityToolkit.Aspire.Hosting.Kind.Tests;
@@ -50,6 +53,85 @@ public async Task WithKubernetesVersionSetsVersion()
}
}
+ [Fact]
+ public async Task WithNodeImageSetsImageOnAllNodes()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddKindCluster("test-cluster")
+ .WithWorkerNodes(2)
+ .WithNodeImage("myacr.azurecr.io/kindest/node:v1.32.2");
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ var configPath = await KindConfigGenerator.GenerateConfigAsync(resource, CancellationToken.None);
+ try
+ {
+ var yaml = await File.ReadAllTextAsync(configPath);
+ Assert.Equal(3, yaml.Split("image: myacr.azurecr.io/kindest/node:v1.32.2").Length - 1);
+ }
+ finally
+ {
+ File.Delete(configPath);
+ }
+ }
+
+ [Fact]
+ public async Task WithNodeMountAddsMountOnAllNodes()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var hostPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "host-data"));
+
+ builder.AddKindCluster("test-cluster")
+ .WithWorkerNodes(1)
+ .WithNodeMount(hostPath, "/container-data", readOnly: true);
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ var configPath = await KindConfigGenerator.GenerateConfigAsync(resource, CancellationToken.None);
+ try
+ {
+ var yaml = await File.ReadAllTextAsync(configPath);
+ Assert.Equal(2, yaml.Split($"hostPath: {hostPath}").Length - 1);
+ Assert.Equal(2, yaml.Split("containerPath: /container-data").Length - 1);
+ Assert.Equal(2, yaml.Split("readOnly: true").Length - 1);
+ }
+ finally
+ {
+ File.Delete(configPath);
+ }
+ }
+
+ [Fact]
+ public async Task WithNodeMountResolvesRelativeHostPathFromAppHostDirectory()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var relativeHostPath = Path.Combine("mounts", "charts");
+ var expectedHostPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, relativeHostPath));
+
+ builder.AddKindCluster("test-cluster")
+ .WithNodeMount(relativeHostPath, "/container-data");
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ var configPath = await KindConfigGenerator.GenerateConfigAsync(resource, CancellationToken.None);
+ try
+ {
+ var yaml = await File.ReadAllTextAsync(configPath);
+ Assert.Contains($"hostPath: {expectedHostPath}", yaml);
+ }
+ finally
+ {
+ File.Delete(configPath);
+ }
+ }
+
[Fact]
public async Task WithWorkerNodesSetsCount()
{
@@ -125,6 +207,51 @@ public void AddKindClusterThrowsOnNullName()
Assert.Throws(() => builder.AddKindCluster(null!));
}
+ [Fact]
+ public void WithNodeImageRejectsNull()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() => cluster.WithNodeImage(null!));
+ }
+
+ [Fact]
+ public void WithNodeMountRejectsNullHostPath()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() => cluster.WithNodeMount(null!, "/container-data"));
+ }
+
+ [Fact]
+ public void WithNodeMountRejectsNullContainerPath()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() => cluster.WithNodeMount(@"C:\host-data", null!));
+ }
+
+ [Fact]
+ public void WithNodeMountRejectsEmptyHostPath()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() => cluster.WithNodeMount("", "/container-data"));
+ }
+
+ [Fact]
+ public void WithNodeMountRejectsRelativeContainerPath()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() => cluster.WithNodeMount(@"C:\host-data", "container-data"));
+ }
+
[Fact]
public async Task GeneratedConfigContainsImageFromKindContainerImageTags()
{
@@ -229,6 +356,93 @@ public void AddKindClusterRegistersLifecycleHook()
Assert.NotNull(descriptor);
}
+ [Fact]
+ public async Task KindClusterLifecycleHook_CleansUpOnApplicationStopping()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ builder.AddKindCluster("test-cluster");
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var loggerService = app.Services.GetRequiredService();
+ var processRunner = new FakeProcessRunner();
+ var hostLifetime = new TestHostApplicationLifetime();
+ var hook = new KindClusterLifecycleHook(
+ model,
+ loggerService,
+ processRunner,
+ new TestKindContainerRuntimeResolver(),
+ hostLifetime);
+
+ await hook.SubscribeAsync(new NoOpEventing(), null!);
+ hostLifetime.StopApplication();
+
+ await WaitForConditionAsync(() =>
+ processRunner.Commands.Any(command => command.FileName == "kind" && command.Arguments.Contains("delete cluster --name=test-cluster", StringComparison.Ordinal)));
+
+ Assert.Single(
+ processRunner.Commands,
+ command => command.FileName == "kind" && command.Arguments.Contains("delete cluster --name=test-cluster", StringComparison.Ordinal));
+
+ await hook.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task KindClusterLifecycleHook_DoesNotDoubleDeleteWhenStoppingThenDisposed()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ builder.AddKindCluster("test-cluster");
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var loggerService = app.Services.GetRequiredService();
+ var processRunner = new FakeProcessRunner();
+ var hostLifetime = new TestHostApplicationLifetime();
+ var hook = new KindClusterLifecycleHook(
+ model,
+ loggerService,
+ processRunner,
+ new TestKindContainerRuntimeResolver(),
+ hostLifetime);
+
+ await hook.SubscribeAsync(new NoOpEventing(), null!);
+ hostLifetime.StopApplication();
+ await WaitForConditionAsync(() =>
+ processRunner.Commands.Any(command => command.FileName == "kind" && command.Arguments.Contains("delete cluster --name=test-cluster", StringComparison.Ordinal)));
+
+ await hook.DisposeAsync();
+
+ Assert.Single(
+ processRunner.Commands,
+ command => command.FileName == "kind" && command.Arguments.Contains("delete cluster --name=test-cluster", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task KindClusterLifecycleHook_DoesNotDeletePersistentClustersOnApplicationStopping()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ builder.AddKindCluster("persistent-cluster")
+ .WithClusterLifetime(ClusterLifetime.Persistent);
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var loggerService = app.Services.GetRequiredService();
+ var processRunner = new FakeProcessRunner();
+ var hostLifetime = new TestHostApplicationLifetime();
+ var hook = new KindClusterLifecycleHook(
+ model,
+ loggerService,
+ processRunner,
+ new TestKindContainerRuntimeResolver(),
+ hostLifetime);
+
+ await hook.SubscribeAsync(new NoOpEventing(), null!);
+ hostLifetime.StopApplication();
+ await hook.DisposeAsync();
+
+ Assert.DoesNotContain(processRunner.Commands, command => command.FileName == "kind" && command.Arguments.Contains("delete cluster", StringComparison.Ordinal));
+ }
+
// ββ KindConfigGenerator tests ββββββββββββββββββββββββββββββββββββββββ
[Fact]
@@ -533,6 +747,38 @@ public async Task DefaultProcessRunner_InvalidCommand_NonZeroExitCode()
Assert.NotEqual(0, result.ExitCode);
}
+ [Fact]
+ public async Task DefaultProcessRunner_RedactsKubeconfigPathInDebugLog()
+ {
+ var runner = new DefaultProcessRunner();
+ var logger = new CapturingLogger();
+ const string kubeconfigPath = "C:\\Users\\tamirdresher\\.kube\\kind-config.yaml";
+
+ _ = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? await runner.RunAsync(logger, "cmd", ["/c", "echo", "hello", $"--kubeconfig={kubeconfigPath}"])
+ : await runner.RunAsync(logger, "sh", ["-c", "echo hello", $"--kubeconfig={kubeconfigPath}"]);
+
+ var executingMessage = Assert.Single(logger.Messages, message => message.StartsWith("Executing:", StringComparison.Ordinal));
+ Assert.DoesNotContain(kubeconfigPath, executingMessage, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("--kubeconfig=[REDACTED]", executingMessage, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task DefaultProcessRunner_RedactsSpaceSeparatedKubeconfigPathInDebugLog()
+ {
+ var runner = new DefaultProcessRunner();
+ var logger = new CapturingLogger();
+ const string kubeconfigPath = "C:\\Users\\tamirdresher\\.kube\\kind-config.yaml";
+
+ _ = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? await runner.RunAsync(logger, "cmd", ["/c", "echo", "hello", "--kubeconfig", kubeconfigPath])
+ : await runner.RunAsync(logger, "sh", ["-c", "echo hello", "--kubeconfig", kubeconfigPath]);
+
+ var executingMessage = Assert.Single(logger.Messages, message => message.StartsWith("Executing:", StringComparison.Ordinal));
+ Assert.DoesNotContain(kubeconfigPath, executingMessage, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("--kubeconfig [REDACTED]", executingMessage, StringComparison.Ordinal);
+ }
+
// ββ Edge-case tests ββββββββββββββββββββββββββββββββββββββββββββββββββ
[Fact]
@@ -553,6 +799,7 @@ public async Task WithWorkerNodes_Zero_IsValid()
Assert.Contains("- role: control-plane", yaml);
Assert.DoesNotContain("- role: worker", yaml);
}
+
finally
{
File.Delete(configPath);
@@ -570,4 +817,85 @@ public void KindClusterResource_Constructor_SetsPathsCorrectly()
}
private sealed class TestResource(string name) : Resource(name), IResourceWithEnvironment;
-}
+
+ private sealed class TestKindContainerRuntimeResolver : IKindContainerRuntimeResolver
+ {
+ public Task ResolveAsync(CancellationToken cancellationToken) =>
+ Task.FromResult(new KindContainerRuntime("docker"));
+ }
+
+ private sealed class TestHostApplicationLifetime : IHostApplicationLifetime
+ {
+ private readonly CancellationTokenSource _applicationStarted = new();
+ private readonly CancellationTokenSource _applicationStopping = new();
+ private readonly CancellationTokenSource _applicationStopped = new();
+
+ public CancellationToken ApplicationStarted => _applicationStarted.Token;
+
+ public CancellationToken ApplicationStopping => _applicationStopping.Token;
+
+ public CancellationToken ApplicationStopped => _applicationStopped.Token;
+
+ public void StopApplication()
+ {
+ if (!_applicationStopping.IsCancellationRequested)
+ {
+ _applicationStopping.Cancel();
+ }
+ }
+ }
+
+ private sealed class NoOpEventing : IDistributedApplicationEventing
+ {
+ public DistributedApplicationEventSubscription Subscribe(Func callback)
+ where T : IDistributedApplicationEvent => null!;
+
+ public DistributedApplicationEventSubscription Subscribe(IResource resource, Func callback)
+ where T : IDistributedApplicationResourceEvent => null!;
+
+ public void Unsubscribe(DistributedApplicationEventSubscription subscription)
+ {
+ }
+
+ public Task PublishAsync(T @event, CancellationToken cancellationToken = default)
+ where T : IDistributedApplicationEvent => Task.CompletedTask;
+
+ public Task PublishAsync(T @event, EventDispatchBehavior dispatchBehavior, CancellationToken cancellationToken = default)
+ where T : IDistributedApplicationEvent => Task.CompletedTask;
+ }
+
+ private static async Task WaitForConditionAsync(Func condition)
+ {
+ for (var attempt = 0; attempt < 50; attempt++)
+ {
+ if (condition())
+ {
+ return;
+ }
+
+ await Task.Delay(20);
+ }
+
+ throw new Xunit.Sdk.XunitException("Timed out waiting for asynchronous condition.");
+ }
+
+ private sealed class CapturingLogger : ILogger
+ {
+ public List Messages { get; } = [];
+
+ public IDisposable? BeginScope(TState state)
+ where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception? exception,
+ Func formatter)
+ {
+ Messages.Add(formatter(state, exception));
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs
index cc7f02d20..136a1521b 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs
@@ -19,31 +19,44 @@ internal sealed class FakeProcessRunner : IProcessRunner
public ProcessResult NextResult { get; set; } = new(0, "", "");
public Queue Results { get; } = new();
public Dictionary ResultsByFileName { get; } = [];
+ public TimeSpan Delay { get; set; }
+ public Queue Delays { get; } = new();
- public Task RunAsync(
+ public async Task RunAsync(
ILogger logger,
string fileName,
IReadOnlyList arguments,
string? workingDirectory = null,
IReadOnlyDictionary? environmentVariables = null,
+ string? standardInput = null,
CancellationToken cancellationToken = default)
{
+ ProcessResult result;
+ TimeSpan delay;
lock (_lock)
{
- Commands.Add(new(fileName, string.Join(" ", arguments), workingDirectory, environmentVariables));
+ Commands.Add(new(fileName, string.Join(" ", arguments), workingDirectory, environmentVariables, standardInput));
- return Task.FromResult(
- ResultsByFileName.TryGetValue(fileName, out var result)
- ? result
+ result = ResultsByFileName.TryGetValue(fileName, out var fileResult)
+ ? fileResult
: Results.Count > 0
? Results.Dequeue()
- : NextResult);
+ : NextResult;
+ delay = Delays.Count > 0 ? Delays.Dequeue() : Delay;
}
+
+ if (delay > TimeSpan.Zero)
+ {
+ await Task.Delay(delay, cancellationToken);
+ }
+
+ return result;
}
internal sealed record ExecutedCommand(
string FileName,
string Arguments,
string? WorkingDirectory,
- IReadOnlyDictionary? EnvironmentVariables);
+ IReadOnlyDictionary? EnvironmentVariables,
+ string? StandardInput);
}
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs
index ed3a3e16f..40c9003d0 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs
@@ -3,6 +3,7 @@
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.Logging;
namespace CommunityToolkit.Aspire.Hosting.Kind.Tests;
@@ -84,6 +85,132 @@ public void WithHelmValueAddsValue()
Assert.Equal("false", resource.Values["auth.enabled"]);
}
+ [Fact]
+ public void WithHelmStringValueAddsStringValue()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
+ .WithHelmStringValue("auth.password", "000123")
+ .WithHelmStringValue("feature.flag", "false");
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal(2, resource.StringValues.Count);
+ Assert.Equal("000123", resource.StringValues["auth.password"]);
+ Assert.Equal("false", resource.StringValues["feature.flag"]);
+ }
+
+ [Fact]
+ public void WithHelmValueLastWriteWinsForDuplicateKey()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
+ .WithHelmValue("replica.replicaCount", "1")
+ .WithHelmValue("replica.replicaCount", "2");
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal("2", resource.Values["replica.replicaCount"]);
+ }
+
+ [Fact]
+ public void WithHelmValueAndStringValueUseLastWriteWinsAcrossModes()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
+ .WithHelmValue("auth.password", "123")
+ .WithHelmStringValue("auth.password", "000123");
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.False(resource.Values.ContainsKey("auth.password"));
+ Assert.Equal("000123", resource.StringValues["auth.password"]);
+ }
+
+ [Fact]
+ public void WithHelmStringValueAndValueUseLastWriteWinsAcrossModes()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
+ .WithHelmStringValue("auth.password", "000123")
+ .WithHelmValue("auth.password", "123");
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.False(resource.StringValues.ContainsKey("auth.password"));
+ Assert.Equal("123", resource.Values["auth.password"]);
+ }
+
+ [Fact]
+ public void WithCrdWaitRetrySetsRetryConfiguration()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
+ .WithCrdWaitRetry(maxAttempts: 3, backoff: TimeSpan.FromSeconds(7));
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal(3, resource.CrdWaitRetryMaxAttempts);
+ Assert.Equal(TimeSpan.FromSeconds(7), resource.CrdWaitRetryBackoff);
+ }
+
+ [Fact]
+ public void WithCrdWaitRetryUsesDefaultBackoff()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
+ .WithCrdWaitRetry();
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal(3, resource.CrdWaitRetryMaxAttempts);
+ Assert.Equal(TimeSpan.FromSeconds(5), resource.CrdWaitRetryBackoff);
+ }
+
+ [Fact]
+ public void WithCrdWaitRetryRejectsLessThanTwoAttempts()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddHelmChart("redis", "chart/ref").WithCrdWaitRetry(1, TimeSpan.FromSeconds(5)));
+ }
+
+ [Fact]
+ public void WithCrdWaitRetryRejectsInvalidBackoff()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddHelmChart("redis", "chart/ref").WithCrdWaitRetry(3, TimeSpan.Zero));
+ }
+
[Fact]
public void WithHelmValuesFileAddsPath()
{
@@ -144,6 +271,7 @@ public void ValuesAndValuesFilesStartEmpty()
var resource = new KindHelmChartResource("redis", "chart/ref", cluster);
Assert.Empty(resource.Values);
+ Assert.Empty(resource.StringValues);
Assert.Empty(resource.ValuesFiles);
}
@@ -173,6 +301,7 @@ public void FluentApiChainingWorks()
cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
.WithChartVersion("20.0.0")
.WithHelmValue("replica.replicaCount", "2")
+ .WithHelmStringValue("auth.password", "000123")
.WithHelmValuesFile("./values.yaml")
.WithNamespace("cache");
@@ -182,6 +311,7 @@ public void FluentApiChainingWorks()
var resource = Assert.Single(appModel.Resources.OfType());
Assert.Equal("20.0.0", resource.Version);
Assert.Equal("2", resource.Values["replica.replicaCount"]);
+ Assert.Equal("000123", resource.StringValues["auth.password"]);
Assert.Single(resource.ValuesFiles);
Assert.Equal("cache", resource.Namespace);
}
@@ -197,6 +327,7 @@ public void CreateInstallArgumentsPreservesArgumentBoundaries()
};
resource.Values["annotations.description"] = "My \"Redis\" App";
+ resource.StringValues["auth.password"] = "000123";
resource.ValuesFiles.Add(@"C:\temp path\values file.yaml");
var arguments = HelmManager.CreateInstallArguments(resource);
@@ -215,12 +346,90 @@ public void CreateInstallArgumentsPreservesArgumentBoundaries()
"--create-namespace",
"--set",
"annotations.description=My \"Redis\" App",
+ "--set-string",
+ "auth.password=000123",
"-f",
@"C:\temp path\values file.yaml",
],
arguments);
}
+ [Fact]
+ public async Task InstallAsync_RetriesAfterWaitingForNewCrds()
+ {
+ var cluster = new KindClusterResource("cluster");
+ var resource = new KindHelmChartResource("redis", "chart/ref", cluster)
+ {
+ CrdWaitRetryMaxAttempts = 3,
+ CrdWaitRetryBackoff = TimeSpan.FromSeconds(2),
+ };
+ var processRunner = new FakeProcessRunner();
+ processRunner.Results.Enqueue(new(0, "", "")); // kubectl get crd baseline
+ processRunner.Results.Enqueue(new(1, "", "no matches for kind \"Widget\" in version \"widgets.example.com/v1\"; ensure CRDs are installed first"));
+ processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", "")); // kubectl get crd after failure
+ processRunner.Results.Enqueue(new(0, "", "")); // kubectl wait
+ processRunner.Results.Enqueue(new(0, "release installed", "")); // retry succeeds
+ var delays = new List();
+ var manager = new HelmManager(
+ processRunner,
+ (delay, _) =>
+ {
+ delays.Add(delay);
+ return Task.CompletedTask;
+ });
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+
+ await manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None);
+
+ Assert.Equal(5, processRunner.Commands.Count);
+ Assert.Equal("kubectl", processRunner.Commands[0].FileName);
+ Assert.Contains("get crd -o name", processRunner.Commands[0].Arguments);
+ Assert.Equal("helm", processRunner.Commands[1].FileName);
+ Assert.Equal("kubectl", processRunner.Commands[2].FileName);
+ Assert.Contains("get crd -o name", processRunner.Commands[2].Arguments);
+ Assert.Equal("kubectl", processRunner.Commands[3].FileName);
+ Assert.Contains("wait --for=condition=Established customresourcedefinition.apiextensions.k8s.io/widgets.example.com", processRunner.Commands[3].Arguments);
+ Assert.Equal("helm", processRunner.Commands[4].FileName);
+ Assert.Equal([TimeSpan.FromSeconds(2)], delays);
+ }
+
+ [Fact]
+ public async Task InstallAsync_DoesNotRetryWithoutNewCrds()
+ {
+ var cluster = new KindClusterResource("cluster");
+ var resource = new KindHelmChartResource("redis", "chart/ref", cluster)
+ {
+ CrdWaitRetryMaxAttempts = 3,
+ CrdWaitRetryBackoff = TimeSpan.FromSeconds(2),
+ };
+ var processRunner = new FakeProcessRunner();
+ processRunner.Results.Enqueue(new(0, "", "")); // kubectl get crd baseline
+ processRunner.Results.Enqueue(new(1, "", "no matches for kind \"Widget\" in version \"widgets.example.com/v1\"; ensure CRDs are installed first"));
+ processRunner.Results.Enqueue(new(0, "", "")); // kubectl get crd after failure
+ var manager = new HelmManager(processRunner, static (_, _) => Task.CompletedTask);
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+
+ var ex = await Assert.ThrowsAsync(
+ () => manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None));
+
+ Assert.Contains("Failed to install Helm chart", ex.Message);
+ Assert.Equal(3, processRunner.Commands.Count);
+ }
+
+ [Fact]
+ public void ComputeRetryBackoffDoublesPerFailure()
+ {
+ Assert.Equal(TimeSpan.FromSeconds(5), HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 1));
+ Assert.Equal(TimeSpan.FromSeconds(10), HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 2));
+ Assert.Equal(TimeSpan.FromSeconds(20), HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 3));
+ }
+
+ [Fact]
+ public void ComputeRetryBackoffSaturatesInsteadOfOverflowing()
+ {
+ Assert.Equal(TimeSpan.MaxValue, HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 64));
+ }
+
// ββ Null-check tests βββββββββββββββββββββββββββββββββββββββββββββββββ
[Fact]
@@ -282,6 +491,17 @@ public void WithHelmValueShouldThrowWhenBuilderIsNull()
Assert.Equal(nameof(builder), exception.ParamName);
}
+ [Fact]
+ public void WithHelmStringValueShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.WithHelmStringValue("key", "value");
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
[Fact]
public void WithHelmValuesFileShouldThrowWhenBuilderIsNull()
{
@@ -293,6 +513,17 @@ public void WithHelmValuesFileShouldThrowWhenBuilderIsNull()
Assert.Equal(nameof(builder), exception.ParamName);
}
+ [Fact]
+ public void WithCrdWaitRetryShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.WithCrdWaitRetry();
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
[Fact]
public void WithNamespaceShouldThrowWhenBuilderIsNull()
{
@@ -364,4 +595,4 @@ public void AddHelmChartRegistersUniqueHealthCheckPerResource()
Assert.NotEmpty(healthCheckAnnotations);
}
}
-}
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs
new file mode 100644
index 000000000..44cacd107
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs
@@ -0,0 +1,1073 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.Logging;
+using System.ComponentModel;
+
+namespace CommunityToolkit.Aspire.Hosting.Kind.Tests;
+
+public class KindManifestTests
+{
+ [Fact]
+ public void AddManifestCreatesResource()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", "./manifests/crds.yaml");
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal("crds", resource.Name);
+ Assert.True(Path.IsPathRooted(resource.ManifestPath));
+ Assert.EndsWith(Path.Combine("manifests", "crds.yaml"), resource.ManifestPath, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void AddManifestSetsParent()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", "./manifests/crds.yaml");
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var manifestResource = Assert.Single(appModel.Resources.OfType());
+ var clusterResource = Assert.Single(appModel.Resources.OfType());
+ Assert.Same(clusterResource, manifestResource.Parent);
+ }
+
+ [Fact]
+ public void AddManifestFromContentCreatesResource()
+ {
+ const string content = "apiVersion: v1\nkind: Namespace\nmetadata:\n name: aspire-demo";
+ var builder = DistributedApplication.CreateBuilder();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifestFromContent("demo-ns", content);
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal("demo-ns", resource.Name);
+ Assert.Equal(K8sManifestResource.InlineManifestPath, resource.ManifestPath);
+ Assert.Equal(content, resource.InlineContent);
+ Assert.False(resource.IsKustomize);
+ }
+
+ [Fact]
+ public void AddManifestThrowsOnNullBuilder()
+ {
+ IResourceBuilder builder = null!;
+ Assert.Throws(() => builder.AddManifest("crds", "./crds.yaml"));
+ }
+
+ [Fact]
+ public void AddManifestThrowsOnNullName()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var cluster = builder.AddKindCluster("test-cluster");
+ Assert.Throws(() => cluster.AddManifest(null!, "./crds.yaml"));
+ }
+
+ [Fact]
+ public void AddManifestThrowsOnNullManifestPath()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() => cluster.AddManifest("crds", null!));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void AddManifestThrowsOnWhitespaceManifestPath(string manifestPath)
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() => cluster.AddManifest("crds", manifestPath));
+ }
+
+ [Fact]
+ public void AddManifestFromContentThrowsOnNullBuilder()
+ {
+ IResourceBuilder builder = null!;
+ Assert.Throws(() => builder.AddManifestFromContent("crds", "apiVersion: v1"));
+ }
+
+ [Fact]
+ public void AddManifestFromContentThrowsOnNullName()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var cluster = builder.AddKindCluster("test-cluster");
+ Assert.Throws(() => cluster.AddManifestFromContent(null!, "apiVersion: v1"));
+ }
+
+ [Fact]
+ public void AddManifestFromContentThrowsOnNullContent()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var cluster = builder.AddKindCluster("test-cluster");
+ Assert.Throws(() => cluster.AddManifestFromContent("crds", null!));
+ }
+
+ [Fact]
+ public void AddManifestUsesAppHostRelativePath()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var relativePath = Path.Combine("manifests", "crds.yaml");
+ var expected = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, relativePath));
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", relativePath);
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal(expected, resource.ManifestPath);
+ }
+
+ [Fact]
+ public void AddManifestUsesAbsolutePathAsIs()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var absolutePath = Path.Combine(AppContext.BaseDirectory, "manifests", "crds.yaml");
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", absolutePath);
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal(absolutePath, resource.ManifestPath);
+ }
+
+ [Fact]
+ public void AddManifestDetectsKustomizationYaml()
+ {
+ var directory = CreateTestDirectory();
+
+ try
+ {
+ File.WriteAllText(Path.Combine(directory, "kustomization.yaml"), "resources: []");
+ var resource = AddManifestAndGetResource(directory);
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.True(resource.IsKustomize);
+ Assert.Contains("-k", args);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AddManifestDetectsKustomizationYml()
+ {
+ var directory = CreateTestDirectory();
+
+ try
+ {
+ File.WriteAllText(Path.Combine(directory, "kustomization.yml"), "resources: []");
+ var resource = AddManifestAndGetResource(directory);
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.True(resource.IsKustomize);
+ Assert.Contains("-k", args);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AddManifestOnDirectoryWithoutKustomization_IsNotKustomize()
+ {
+ var directory = CreateTestDirectory();
+
+ try
+ {
+ File.WriteAllText(Path.Combine(directory, "manifest.yaml"), "apiVersion: v1");
+ var resource = AddManifestAndGetResource(directory);
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.False(resource.IsKustomize);
+ Assert.Contains("-f", args);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void WithRecursiveSetsRecursive()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("all", "./manifests")
+ .WithRecursive();
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService