diff --git a/Directory.Packages.props b/Directory.Packages.props
index 74e3b4e47..d8bf8eb20 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -54,6 +54,7 @@
+
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/CommunityToolkit.Aspire.Hosting.Kind.csproj b/src/CommunityToolkit.Aspire.Hosting.Kind/CommunityToolkit.Aspire.Hosting.Kind.csproj
index 31757c868..6bd638d8a 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/CommunityToolkit.Aspire.Hosting.Kind.csproj
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/CommunityToolkit.Aspire.Hosting.Kind.csproj
@@ -9,6 +9,7 @@
+
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..142d9cc8c 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs
@@ -20,6 +20,7 @@ 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));
@@ -29,6 +30,7 @@ public async Task RunAsync(
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();
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs
index 950127c61..df7731003 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs
@@ -3,38 +3,145 @@
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Logging;
+using Polly;
+using Polly.Retry;
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 readonly Func _delayAsync = delayAsync ?? Task.Delay;
+ private readonly KubectlManager _kubectlManager = kubectlManager ?? new KubectlManager(processRunner, delayAsync);
+
///
/// Installs or upgrades the Helm release.
+ /// Callers should pass the Helm resource's scoped logger from .
///
- public async Task InstallAsync(KindHelmChartResource resource, ILogger logger, CancellationToken cancellationToken)
+ public async Task InstallAsync(KindHelmChartResource resource, ILogger resourceLogger, CancellationToken cancellationToken)
{
var args = CreateInstallArguments(resource);
+ var maxAttempts = resource.CrdWaitRetryMaxAttempts;
+ if (maxAttempts <= 1)
+ {
+ resourceLogger.LogInformation(
+ "Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}' (attempt 1/1)...",
+ resource.ChartRef,
+ resource.ReleaseName,
+ resource.Parent.Name);
+
+ var result = await processRunner.RunAsync(
+ resourceLogger,
+ "helm",
+ args,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (result.ExitCode != 0)
+ {
+ throw new InvalidOperationException(
+ $"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {FormatFailureOutput(result)}");
+ }
+
+ resourceLogger.LogInformation(
+ "Helm release '{ReleaseName}' installed successfully.", resource.ReleaseName);
+ return;
+ }
+
+ IReadOnlySet knownCrds = maxAttempts > 1
+ ? await TryGetCustomResourceDefinitionsAsync(resource, resourceLogger, cancellationToken).ConfigureAwait(false)
+ : new HashSet(StringComparer.OrdinalIgnoreCase);
+ var attempt = 0;
+ var pipeline = new ResiliencePipelineBuilder()
+ .AddRetry(new RetryStrategyOptions
+ {
+ MaxRetryAttempts = Math.Max(0, maxAttempts - 1),
+ Delay = TimeSpan.Zero,
+ UseJitter = false,
+ ShouldHandle = new PredicateBuilder()
+ .HandleResult(static result => result.ShouldRetry),
+ OnRetry = async arguments =>
+ {
+ var retryResult = arguments.Outcome.Result!;
+ if (retryResult.NewCrds.Length > 0)
+ {
+ resourceLogger.LogWarning(
+ "Helm release '{ReleaseName}' failed and discovered {CrdCount} new CRD(s). Waiting for them to become Established before retrying.",
+ resource.ReleaseName,
+ retryResult.NewCrds.Length);
+
+ await _kubectlManager.WaitForCrdsAsync(
+ retryResult.NewCrds,
+ resource.Parent.KubeconfigPath,
+ resource.CrdWaitRetryTimeout,
+ resourceLogger,
+ arguments.Context.CancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ resourceLogger.LogWarning(
+ "Helm release '{ReleaseName}' failed. Retrying because {MethodName} is enabled.",
+ resource.ReleaseName,
+ "WithCrdWaitRetry");
+ }
+
+ knownCrds = retryResult.DiscoveredCrds;
+ var backoff = ComputeRetryBackoff(resource.CrdWaitRetryBackoff, arguments.AttemptNumber + 1);
+ resourceLogger.LogInformation(
+ "Retrying Helm release '{ReleaseName}' in {DelaySeconds:n1}s.",
+ resource.ReleaseName,
+ backoff.TotalSeconds);
+ await _delayAsync(backoff, arguments.Context.CancellationToken).ConfigureAwait(false);
+ }
+ })
+ .Build();
+
+ var finalResult = await pipeline.ExecuteAsync(async token =>
+ {
+ attempt++;
+ resourceLogger.LogInformation(
+ "Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}' (attempt {Attempt}/{MaxAttempts})...",
+ resource.ChartRef,
+ resource.ReleaseName,
+ resource.Parent.Name,
+ attempt,
+ maxAttempts);
+
+ var result = await processRunner.RunAsync(
+ resourceLogger,
+ "helm",
+ args,
+ cancellationToken: token).ConfigureAwait(false);
+
+ if (result.ExitCode == 0)
+ {
+ return HelmInstallAttemptResult.Success(result);
+ }
- logger.LogInformation(
- "Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}'...",
- resource.ChartRef, resource.ReleaseName, resource.Parent.Name);
+ if (attempt >= maxAttempts)
+ {
+ return HelmInstallAttemptResult.Fail(result);
+ }
- var result = await processRunner.RunAsync(
- logger,
- "helm",
- args,
- cancellationToken: cancellationToken).ConfigureAwait(false);
+ var discoveredCrds = await TryGetCustomResourceDefinitionsAsync(resource, resourceLogger, token).ConfigureAwait(false);
+ var newCrds = discoveredCrds
+ .Except(knownCrds, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
- if (result.ExitCode != 0)
+ return HelmInstallAttemptResult.Retry(result, discoveredCrds, newCrds);
+ }, cancellationToken).ConfigureAwait(false);
+
+ if (finalResult.Result.ExitCode != 0)
{
throw new InvalidOperationException(
- $"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {result.Error}");
+ $"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {FormatFailureOutput(finalResult.Result)}");
}
- logger.LogInformation(
+ resourceLogger.LogInformation(
"Helm release '{ReleaseName}' installed successfully.", resource.ReleaseName);
}
@@ -70,6 +177,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 +191,58 @@ 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 string FormatFailureOutput(ProcessResult result)
+ {
+ return string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
+ }
+
+ private async Task> TryGetCustomResourceDefinitionsAsync(
+ KindHelmChartResource resource,
+ ILogger resourceLogger,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await _kubectlManager.GetCustomResourceDefinitionsAsync(
+ resource.Parent.KubeconfigPath,
+ resourceLogger,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ resourceLogger.LogDebug(
+ ex,
+ "Unable to snapshot CRDs for Helm release '{ReleaseName}'.",
+ resource.ReleaseName);
+ return new HashSet(StringComparer.OrdinalIgnoreCase);
+ }
+ }
+
+ private sealed record HelmInstallAttemptResult(ProcessResult Result, bool ShouldRetry, IReadOnlySet DiscoveredCrds, string[] NewCrds)
+ {
+ public static HelmInstallAttemptResult Success(ProcessResult result) =>
+ new(result, ShouldRetry: false, new HashSet(StringComparer.OrdinalIgnoreCase), []);
+
+ public static HelmInstallAttemptResult Fail(ProcessResult result) =>
+ new(result, ShouldRetry: false, new HashSet(StringComparer.OrdinalIgnoreCase), []);
+
+ public static HelmInstallAttemptResult Retry(ProcessResult result, IReadOnlySet discoveredCrds, string[] newCrds) =>
+ new(result, ShouldRetry: true, discoveredCrds, newCrds);
+ }
}
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/K8sManifestAnnotations.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestAnnotations.cs
new file mode 100644
index 000000000..5bf2d19ac
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestAnnotations.cs
@@ -0,0 +1,83 @@
+// 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 Aspire.Hosting;
+
+namespace CommunityToolkit.Aspire.Hosting.Kind;
+
+internal sealed class K8sManifestApplyOptionsAnnotation : IResourceAnnotation
+{
+ public bool IsKustomize { get; set; }
+
+ public bool Recursive { get; set; }
+
+ public bool ServerSide { get; set; }
+
+ public bool ForceConflicts { get; set; }
+
+ public string? FieldManager { get; set; }
+
+ public TimeSpan ApplyTimeout { get; set; } = KubectlTimeouts.DefaultApplyTimeout;
+}
+
+internal sealed class K8sManifestCrdWaitPolicy
+{
+ public TimeSpan Timeout { get; set; } = KubectlTimeouts.DefaultCrdWaitTimeout;
+
+ public CrdWaitBehavior FailureBehavior { get; set; } = CrdWaitBehavior.Fail;
+}
+
+internal sealed class K8sManifestWaitPolicyAnnotation : IResourceAnnotation
+{
+ public TimeSpan ClusterReadyTimeout { get; set; } = TimeSpan.FromSeconds(60);
+
+ public K8sManifestCrdWaitPolicy Crd { get; } = new();
+}
+
+internal static class K8sManifestAnnotations
+{
+ public static K8sManifestApplyOptionsAnnotation GetApplyOptions(K8sManifestResource resource)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+ return resource.TryGetLastAnnotation(out var annotation)
+ ? annotation
+ : new K8sManifestApplyOptionsAnnotation();
+ }
+
+ public static K8sManifestApplyOptionsAnnotation GetOrCreateApplyOptions(K8sManifestResource resource)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+
+ if (resource.TryGetLastAnnotation(out var annotation))
+ {
+ return annotation;
+ }
+
+ annotation = new K8sManifestApplyOptionsAnnotation();
+ resource.Annotations.Add(annotation);
+ return annotation;
+ }
+
+ public static K8sManifestWaitPolicyAnnotation GetWaitPolicy(K8sManifestResource resource)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+ return resource.TryGetLastAnnotation(out var annotation)
+ ? annotation
+ : new K8sManifestWaitPolicyAnnotation();
+ }
+
+ public static K8sManifestWaitPolicyAnnotation GetOrCreateWaitPolicy(K8sManifestResource resource)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+
+ if (resource.TryGetLastAnnotation(out var annotation))
+ {
+ return annotation;
+ }
+
+ annotation = new K8sManifestWaitPolicyAnnotation();
+ resource.Annotations.Add(annotation);
+ return annotation;
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs
new file mode 100644
index 000000000..607bddd15
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs
@@ -0,0 +1,127 @@
+// 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 an absolute file path, an absolute 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 => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetApplyOptions(this).IsKustomize;
+ set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateApplyOptions(this).IsKustomize = value;
+ }
+
+ ///
+ /// 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 => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetApplyOptions(this).Recursive;
+ set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateApplyOptions(this).Recursive = value;
+ }
+
+ ///
+ /// 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 => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetApplyOptions(this).ServerSide;
+ set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateApplyOptions(this).ServerSide = value;
+ }
+
+ ///
+ /// 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 => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetApplyOptions(this).ForceConflicts;
+ set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateApplyOptions(this).ForceConflicts = value;
+ }
+
+ ///
+ /// 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 => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetApplyOptions(this).FieldManager;
+ set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateApplyOptions(this).FieldManager = value;
+ }
+
+ ///
+ /// Gets or sets the maximum time to wait for kubectl apply to complete.
+ ///
+ public TimeSpan ApplyTimeout
+ {
+ get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetApplyOptions(this).ApplyTimeout;
+ set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateApplyOptions(this).ApplyTimeout = value;
+ }
+
+ ///
+ /// Gets or sets the maximum time to wait for the Kubernetes API to become reachable
+ /// before running kubectl apply.
+ ///
+ public TimeSpan ClusterReadyTimeout
+ {
+ get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetWaitPolicy(this).ClusterReadyTimeout;
+ set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateWaitPolicy(this).ClusterReadyTimeout = value;
+ }
+
+ ///
+ /// Gets or sets the maximum time to wait for applied CRDs to reach the Established condition.
+ ///
+ public TimeSpan CrdWaitTimeout
+ {
+ get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetWaitPolicy(this).Crd.Timeout;
+ set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateWaitPolicy(this).Crd.Timeout = value;
+ }
+
+ ///
+ /// Gets or sets how CRD wait failures are handled.
+ ///
+ public CrdWaitBehavior CrdWaitBehavior
+ {
+ get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetWaitPolicy(this).Crd.FailureBehavior;
+ set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateWaitPolicy(this).Crd.FailureBehavior = value;
+ }
+}
+
+#pragma warning restore ASPIREATS001
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs
index 81121fa68..9f5e3cc7d 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs
@@ -5,12 +5,12 @@
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Lifecycle;
+using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
-
namespace CommunityToolkit.Aspire.Hosting.Kind;
///
-/// Handles cleanup of Kind clusters on application shutdown.
+/// Handles cleanup of Kind clusters during graceful host shutdown and disposal.
/// Clusters with lifetime are deleted;
/// clusters with lifetime are left running.
///
@@ -18,18 +18,48 @@ internal sealed class KindClusterLifecycleHook(
DistributedApplicationModel appModel,
ResourceLoggerService loggerService,
IProcessRunner processRunner,
- IKindContainerRuntimeResolver containerRuntimeResolver) : IDistributedApplicationEventingSubscriber, IAsyncDisposable
+ IKindContainerRuntimeResolver containerRuntimeResolver,
+ IHostApplicationLifetime hostApplicationLifetime) : IDistributedApplicationEventingSubscriber, IAsyncDisposable
{
+ private readonly object _cleanupLock = new();
+ private Task? _cleanupTask;
+ private CancellationTokenRegistration _applicationStoppingRegistration;
+
///
public Task SubscribeAsync(
IDistributedApplicationEventing eventing,
DistributedApplicationExecutionContext executionContext,
CancellationToken cancellationToken = default)
{
+ ArgumentNullException.ThrowIfNull(eventing);
+
+ _applicationStoppingRegistration = hostApplicationLifetime.ApplicationStopping.Register(() => _ = EnsureCleanupStarted());
return Task.CompletedTask;
}
+
///
public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ await EnsureCleanupStarted().ConfigureAwait(false);
+ }
+ finally
+ {
+ _applicationStoppingRegistration.Dispose();
+ }
+ }
+
+ private Task EnsureCleanupStarted()
+ {
+ lock (_cleanupLock)
+ {
+ _cleanupTask ??= CleanupClustersAsync();
+ return _cleanupTask;
+ }
+ }
+
+ private async Task CleanupClustersAsync()
{
var clusters = appModel.Resources.OfType();
@@ -58,4 +88,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..807fb9a79 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
@@ -133,10 +133,82 @@ public static IResourceBuilder WithKubernetesVersion(
ArgumentException.ThrowIfNullOrEmpty(version);
var annotation = GetOrCreateNodeImageAnnotation(builder.Resource);
+ ThrowIfNodeImageConfigurationConflicts(annotation, configuredValueName: nameof(annotation.Version), conflictingValueName: nameof(annotation.Image));
annotation.Version = version;
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);
+ ThrowIfNodeImageConfigurationConflicts(annotation, configuredValueName: nameof(annotation.Image), conflictingValueName: nameof(annotation.Version));
+ 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 +230,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 +283,36 @@ 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;
+ }
+
+ private static void ThrowIfNodeImageConfigurationConflicts(
+ KindNodeImageAnnotation annotation,
+ string configuredValueName,
+ string conflictingValueName)
+ {
+ ArgumentNullException.ThrowIfNull(annotation);
+
+ var hasConflictingValue = configuredValueName == nameof(annotation.Version)
+ ? !string.IsNullOrEmpty(annotation.Image)
+ : !string.IsNullOrEmpty(annotation.Version);
+
+ if (hasConflictingValue)
+ {
+ throw new InvalidOperationException(
+ $"Kind node image configuration cannot set both {configuredValueName} and {conflictingValueName} on the same cluster. Use either {nameof(WithKubernetesVersion)} or {nameof(WithNodeImage)}.");
+ }
+ }
+
///
/// Verifies that the Kind CLI is installed and available on PATH.
///
@@ -270,4 +372,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..af718bbb6 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using CommunityToolkit.Aspire.Hosting.Kind;
+
namespace Aspire.Hosting.ApplicationModel;
///
@@ -41,4 +43,15 @@ 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 CrdWaitRetryTimeout { get; set; } = KubectlTimeouts.DefaultCrdWaitTimeout;
+
+ internal TimeSpan CrdWaitRetryBackoff { get; set; } = KubectlTimeouts.DefaultCrdWaitRetryBackoff;
}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs
index 027fc121d..fc1c82b52 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,75 @@ 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.
+ ///
+ ///
+ /// The timeout used while waiting for newly discovered CRDs to become Established
+ /// between retry attempts. When , Kind uses a 5 minute timeout.
+ ///
+ /// 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,
+ TimeSpan? crdWaitTimeout = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentOutOfRangeException.ThrowIfLessThan(maxAttempts, 2);
+
+ builder.Resource.CrdWaitRetryMaxAttempts = maxAttempts;
+ builder.Resource.CrdWaitRetryBackoff = KubectlTimeouts.Normalize(backoff ?? KubectlTimeouts.DefaultCrdWaitRetryBackoff, nameof(backoff));
+ builder.Resource.CrdWaitRetryTimeout = KubectlTimeouts.Normalize(crdWaitTimeout ?? KubectlTimeouts.DefaultCrdWaitTimeout, nameof(crdWaitTimeout));
+ return builder;
+ }
+
///
/// Adds a values file (maps to helm install -f path).
///
@@ -174,6 +239,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 +261,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..79f141041
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs
@@ -0,0 +1,292 @@
+// 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 path to a Kubernetes manifest file, a directory of manifest files,
+ /// or a Kustomize overlay directory. Relative paths are not supported.
+ /// 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);
+ if (!Path.IsPathRooted(manifestPath))
+ {
+ throw new ArgumentException(
+ "Manifest path must be an absolute path. Pass a rooted file or directory path so published AppHosts do not depend on the original AppHost project location.",
+ nameof(manifestPath));
+ }
+
+ var resource = new K8sManifestResource(name, Path.GetFullPath(manifestPath), 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);
+ var applyOptions = K8sManifestAnnotations.GetApplyOptions(resource);
+
+ await notifications.PublishUpdateAsync(resource,
+ state => state with
+ {
+ State = KnownResourceStates.Running,
+ Properties = [
+ new("ManifestPath", resource.ManifestPath),
+ new("Namespace", resource.Namespace ?? "(default)"),
+ new("ServerSide", applyOptions.ServerSide.ToString()),
+ new("Mode", applyOptions.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: K8sManifestAnnotations.GetWaitPolicy(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);
+
+ K8sManifestAnnotations.GetOrCreateApplyOptions(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);
+
+ var applyOptions = K8sManifestAnnotations.GetOrCreateApplyOptions(builder.Resource);
+ applyOptions.ServerSide = true;
+ applyOptions.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);
+
+ K8sManifestAnnotations.GetOrCreateApplyOptions(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);
+
+ K8sManifestAnnotations.GetOrCreateWaitPolicy(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);
+
+ K8sManifestAnnotations.GetOrCreateApplyOptions(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);
+
+ K8sManifestAnnotations.GetOrCreateWaitPolicy(builder.Resource).Crd.Timeout = 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);
+
+ K8sManifestAnnotations.GetOrCreateWaitPolicy(builder.Resource).Crd.FailureBehavior = 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..797f8faca
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs
@@ -0,0 +1,540 @@
+// 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 Polly;
+using Polly.Retry;
+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.
+ /// Callers should pass the manifest resource's scoped logger from .
+ ///
+ public async Task ApplyAsync(K8sManifestResource resource, ILogger resourceLogger, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+ var applyOptions = K8sManifestAnnotations.GetApplyOptions(resource);
+
+ await WaitForClusterInfoAsync(resource, resourceLogger, cancellationToken).ConfigureAwait(false);
+ ValidateRecursiveManifestTarget(resource);
+ LogConfigurationWarnings(resource, resourceLogger, applyOptions);
+
+ var args = CreateApplyArguments(resource);
+
+ resourceLogger.LogInformation(
+ "Applying manifest '{ManifestPath}' to cluster '{ClusterName}'...",
+ resource.ManifestPath, resource.Parent.Name);
+
+ ProcessResult result;
+ var applyTimeout = KubectlTimeouts.Normalize(applyOptions.ApplyTimeout, nameof(resource.ApplyTimeout));
+ using (var applyCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
+ {
+ applyCts.CancelAfter(applyTimeout);
+
+ try
+ {
+ result = await RunKubectlAsync(
+ resourceLogger,
+ 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, resourceLogger, cancellationToken).ConfigureAwait(false);
+ }
+
+ resourceLogger.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 resourceLogger,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(crdNames);
+ ArgumentNullException.ThrowIfNull(resource);
+
+ var crds = crdNames.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ if (crds.Length == 0)
+ {
+ return;
+ }
+
+ var waitPolicy = K8sManifestAnnotations.GetWaitPolicy(resource);
+ var args = CreateWaitArguments(crds, resource.Parent.KubeconfigPath, waitPolicy.Crd.Timeout);
+
+ resourceLogger.LogInformation(
+ "Waiting for {CrdCount} custom resource definition(s) to become Established...",
+ crds.Length);
+
+ var result = await RunKubectlAsync(
+ resourceLogger,
+ args,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (result.ExitCode != 0)
+ {
+ var message = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
+ if (waitPolicy.Crd.FailureBehavior == CrdWaitBehavior.BestEffort)
+ {
+ resourceLogger.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 resourceLogger,
+ CancellationToken cancellationToken) =>
+ WaitForCrdsCoreAsync(crdNames, kubeconfigPath, timeout, resourceLogger, cancellationToken, bestEffort: false);
+
+ internal async Task> GetCustomResourceDefinitionsAsync(
+ string kubeconfigPath,
+ ILogger resourceLogger,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath);
+
+ var result = await RunKubectlAsync(
+ resourceLogger,
+ 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 applyOptions = K8sManifestAnnotations.GetApplyOptions(resource);
+ var isKustomize = resource.InlineContent is null &&
+ Directory.Exists(resource.ManifestPath) &&
+ IsKustomizeDirectory(resource.ManifestPath);
+ K8sManifestAnnotations.GetOrCreateApplyOptions(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 (applyOptions.Recursive && !isKustomize && resource.InlineContent is null)
+ {
+ arguments.Add("--recursive");
+ }
+
+ if (applyOptions.ServerSide)
+ {
+ arguments.Add("--server-side");
+
+ if (applyOptions.ForceConflicts)
+ {
+ arguments.Add("--force-conflicts");
+ }
+ }
+
+ if (!string.IsNullOrEmpty(applyOptions.FieldManager))
+ {
+ arguments.Add("--field-manager");
+ arguments.Add(applyOptions.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 resourceLogger,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+
+ var args = CreateClusterInfoArguments(resource.Parent.KubeconfigPath);
+ string? lastFailureMessage = null;
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(_clusterInfoMaxWait);
+
+ var pipeline = new ResiliencePipelineBuilder()
+ .AddRetry(new RetryStrategyOptions
+ {
+ MaxRetryAttempts = int.MaxValue,
+ Delay = TimeSpan.Zero,
+ UseJitter = false,
+ ShouldHandle = new PredicateBuilder()
+ .HandleResult(static result => result.ExitCode != 0),
+ OnRetry = async arguments =>
+ {
+ var delay = ComputeClusterInfoRetryDelay(arguments.AttemptNumber + 1);
+ var result = arguments.Outcome.Result!;
+ var error = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
+ resourceLogger.LogWarning(
+ "Cluster '{ClusterName}' API is not reachable yet; retrying kubectl cluster-info in {DelaySeconds:n1}s. Last error: {Error}",
+ resource.Parent.Name,
+ delay.TotalSeconds,
+ error);
+ await _delayAsync(delay, arguments.Context.CancellationToken).ConfigureAwait(false);
+ }
+ })
+ .Build();
+
+ try
+ {
+ await pipeline.ExecuteAsync(async token =>
+ {
+ using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(token);
+ probeCts.CancelAfter(_clusterInfoProbeTimeout);
+
+ try
+ {
+ var result = await RunKubectlAsync(
+ resourceLogger,
+ args,
+ cancellationToken: probeCts.Token).ConfigureAwait(false);
+ lastFailureMessage = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
+ return result;
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && !timeoutCts.IsCancellationRequested)
+ {
+ lastFailureMessage = "kubectl cluster-info probe timed out.";
+ return new ProcessResult(1, "", lastFailureMessage);
+ }
+ }, timeoutCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ throw new InvalidOperationException(
+ $"Timed out waiting for cluster '{resource.Parent.Name}' API to become reachable" +
+ (string.IsNullOrWhiteSpace(lastFailureMessage) ? "." : $": {lastFailureMessage}"));
+ }
+ }
+
+ private static TimeSpan Min(TimeSpan left, TimeSpan right)
+ {
+ return left <= right ? left : right;
+ }
+
+ private static TimeSpan ComputeClusterInfoRetryDelay(int failureCount)
+ {
+ var delay = TimeSpan.FromSeconds(ClusterInfoInitialDelay.TotalSeconds * Math.Pow(2, failureCount - 1));
+ return Min(delay, ClusterInfoMaxDelay);
+ }
+
+ private async Task RunKubectlAsync(
+ ILogger resourceLogger,
+ IReadOnlyList arguments,
+ string? standardInput = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ return await processRunner.RunAsync(
+ resourceLogger,
+ "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));
+ }
+
+ private static void ValidateRecursiveManifestTarget(K8sManifestResource resource)
+ {
+ var applyOptions = K8sManifestAnnotations.GetApplyOptions(resource);
+ if (!applyOptions.Recursive || resource.InlineContent is not null || applyOptions.IsKustomize)
+ {
+ return;
+ }
+
+ if (!Directory.Exists(resource.ManifestPath))
+ {
+ throw new InvalidOperationException(
+ $"Manifest '{resource.ManifestPath}' must be an existing directory when {nameof(KindManifestResourceBuilderExtensions.WithRecursive)} is used.");
+ }
+ }
+
+ private static void LogConfigurationWarnings(
+ K8sManifestResource resource,
+ ILogger resourceLogger,
+ K8sManifestApplyOptionsAnnotation applyOptions)
+ {
+ if (applyOptions.Recursive && applyOptions.IsKustomize)
+ {
+ resourceLogger.LogWarning(
+ "Ignoring recursive apply for Kustomize manifest '{ManifestPath}' because kubectl apply -k does not support --recursive.",
+ resource.ManifestPath);
+ }
+ else if (applyOptions.Recursive && resource.InlineContent is not null)
+ {
+ resourceLogger.LogWarning(
+ "Ignoring recursive apply for inline manifest '{ManifestPath}' because kubectl apply -f - does not support --recursive.",
+ resource.ManifestPath);
+ }
+ }
+
+ ///
+ /// 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 resourceLogger,
+ 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);
+
+ resourceLogger.LogInformation(
+ "Waiting for {CrdCount} custom resource definition(s) to become Established...",
+ crds.Length);
+
+ var result = await RunKubectlAsync(
+ resourceLogger,
+ args,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (result.ExitCode == 0)
+ {
+ return;
+ }
+
+ var message = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
+ if (bestEffort)
+ {
+ resourceLogger.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..cee6dda79
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs
@@ -0,0 +1,32 @@
+// 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 DefaultApplyTimeout = TimeSpan.FromMinutes(5);
+ internal static readonly TimeSpan DefaultCrdWaitTimeout = TimeSpan.FromMinutes(5);
+ internal static readonly TimeSpan DefaultCrdWaitRetryBackoff = TimeSpan.FromSeconds(5);
+ 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..5bb79a3fb 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 during graceful host shutdown (via `ApplicationStopping`) or when the lifecycle hook is disposed, 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 during graceful host shutdown (`ClusterLifetime.Session`) or when the lifecycle hook is disposed, 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 during graceful host shutdown or lifecycle-hook disposal on a best-effort basis (default). |
| `ClusterLifetime.Persistent` | Cluster survives AppHost restarts and is reused on next startup. |
## Networking model
@@ -150,9 +169,109 @@ 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` as an explicit opt-in retry policy for Helm installs. Without it, Kind makes a single Helm attempt and surfaces the original failure immediately. With it, Kind retries failed installs up to the configured attempt count, waits for any newly observed CRDs to reach `Established` between attempts, and then re-runs Helm after the configured backoff.
+
+Prefer `.WaitFor(...)` ordering or packaging tightly coupled CRDs and dependents into a single chart when you can; `WithCrdWaitRetry` is a fallback for charts that still need bounded retry behavior.
+
+```csharp
+var certManager = cluster.AddHelmChart("cert-manager", "jetstack/cert-manager")
+ .WithCrdWaitRetry(
+ maxAttempts: 3,
+ backoff: TimeSpan.FromSeconds(5),
+ crdWaitTimeout: TimeSpan.FromMinutes(2));
+```
+
+### 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. Manifest paths must be absolute so published AppHosts do not depend on the original 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");
+var manifestsRoot = Path.Combine(builder.AppHostDirectory, "manifests");
+
+// Single file
+cluster.AddManifest("crds", Path.Combine(manifestsRoot, "crds.yaml"));
+
+// Directory, recursive, into a namespace
+cluster.AddManifest("platform", Path.Combine(manifestsRoot, "platform"))
+ .WithRecursive()
+ .WithNamespace("platform");
+
+// Server-side apply with a stable field manager (useful for CRDs and controllers
+// that reconcile large objects)
+cluster.AddManifest("operator", Path.Combine(manifestsRoot, "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", Path.Combine(manifestsRoot, "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", Path.Combine(builder.AppHostDirectory, "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", Path.Combine(builder.AppHostDirectory, "manifests", "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 +354,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
+
+- Absolute manifest paths 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/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs
index 05db84cd2..841fc7f32 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs
@@ -1,12 +1,16 @@
// 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.Utils;
+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;
@@ -15,7 +19,7 @@ public class AddKindClusterTests
[Fact]
public void AddKindClusterCreatesResource()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
builder.AddKindCluster("test-cluster");
@@ -29,7 +33,7 @@ public void AddKindClusterCreatesResource()
[Fact]
public async Task WithKubernetesVersionSetsVersion()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
builder.AddKindCluster("test-cluster")
.WithKubernetesVersion("v1.32.2");
@@ -50,10 +54,108 @@ public async Task WithKubernetesVersionSetsVersion()
}
}
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void WithKubernetesVersionAndNodeImageConflict_Throws(bool setVersionFirst)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ if (setVersionFirst)
+ {
+ cluster.WithKubernetesVersion("v1.32.2");
+ Assert.Throws(() => cluster.WithNodeImage("kindest/node:v1.32.2"));
+ return;
+ }
+
+ cluster.WithNodeImage("kindest/node:v1.32.2");
+ Assert.Throws(() => cluster.WithKubernetesVersion("v1.32.2"));
+ }
+
+ [Fact]
+ public async Task WithNodeImageSetsImageOnAllNodes()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ 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()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ 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()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ 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()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
builder.AddKindCluster("test-cluster")
.WithWorkerNodes(3);
@@ -78,7 +180,7 @@ public async Task WithWorkerNodesSetsCount()
[Fact]
public async Task DefaultsAreCorrect()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
builder.AddKindCluster("test-cluster");
@@ -103,7 +205,7 @@ public async Task DefaultsAreCorrect()
[Fact]
public void WithWorkerNodesRejectsNegative()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var resourceBuilder = builder.AddKindCluster("test-cluster");
Assert.Throws(() => resourceBuilder.WithWorkerNodes(-1));
@@ -120,11 +222,56 @@ public void AddKindClusterThrowsOnNullBuilder()
[Fact]
public void AddKindClusterThrowsOnNullName()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
Assert.Throws(() => builder.AddKindCluster(null!));
}
+ [Fact]
+ public void WithNodeImageRejectsNull()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() => cluster.WithNodeImage(null!));
+ }
+
+ [Fact]
+ public void WithNodeMountRejectsNullHostPath()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() => cluster.WithNodeMount(null!, "/container-data"));
+ }
+
+ [Fact]
+ public void WithNodeMountRejectsNullContainerPath()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() => cluster.WithNodeMount(@"C:\host-data", null!));
+ }
+
+ [Fact]
+ public void WithNodeMountRejectsEmptyHostPath()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() => cluster.WithNodeMount("", "/container-data"));
+ }
+
+ [Fact]
+ public void WithNodeMountRejectsRelativeContainerPath()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() => cluster.WithNodeMount(@"C:\host-data", "container-data"));
+ }
+
[Fact]
public async Task GeneratedConfigContainsImageFromKindContainerImageTags()
{
@@ -154,7 +301,7 @@ public async Task GeneratedConfigContainsImageFromKindContainerImageTags()
[Fact]
public void WithReferenceInjectsEnvironmentAnnotation()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var kind = builder.AddKindCluster("test-cluster");
builder.AddResource(new TestResource("svc"))
@@ -170,7 +317,7 @@ public void WithReferenceInjectsEnvironmentAnnotation()
[Fact]
public async Task WithReference_NonContainer_SetsHostKubeconfigEnvironmentValue()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var kind = builder.AddKindCluster("test-cluster");
var service = builder.AddResource(new TestResource("svc"))
@@ -186,7 +333,7 @@ public async Task WithReference_NonContainer_SetsHostKubeconfigEnvironmentValue(
[Fact]
public void DefaultClusterLifetimeIsSession()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
builder.AddKindCluster("test-cluster");
@@ -201,7 +348,7 @@ public void DefaultClusterLifetimeIsSession()
[Fact]
public void WithClusterLifetimeSetsAnnotation()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
builder.AddKindCluster("test-cluster")
.WithClusterLifetime(ClusterLifetime.Persistent);
@@ -217,7 +364,7 @@ public void WithClusterLifetimeSetsAnnotation()
[Fact]
public void AddKindClusterRegistersLifecycleHook()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
builder.AddKindCluster("test-cluster");
@@ -229,6 +376,119 @@ public void AddKindClusterRegistersLifecycleHook()
Assert.NotNull(descriptor);
}
+ [Fact]
+ public async Task KindClusterLifecycleHook_CleansUpOnApplicationStopping()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ 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_CleansUpOnDisposeAsyncWithoutApplicationStopping()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ 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!);
+ 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_DoesNotDoubleDeleteWhenStoppingThenDisposed()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ 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()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ 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]
@@ -470,7 +730,7 @@ public async Task WithKubernetesVersionAndWorkerNodes_BothOrders_AllNodesHaveIma
var expectedImage = $"{"kindest/node"}:v1.31.0";
// Order 1: workers first, then version
- var builder1 = DistributedApplication.CreateBuilder();
+ using var builder1 = TestDistributedApplicationBuilder.Create();
builder1.AddKindCluster("order1")
.WithWorkerNodes(2)
.WithKubernetesVersion("v1.31.0");
@@ -488,7 +748,7 @@ public async Task WithKubernetesVersionAndWorkerNodes_BothOrders_AllNodesHaveIma
finally { File.Delete(path1); }
// Order 2: version first, then workers
- var builder2 = DistributedApplication.CreateBuilder();
+ using var builder2 = TestDistributedApplicationBuilder.Create();
builder2.AddKindCluster("order2")
.WithKubernetesVersion("v1.31.0")
.WithWorkerNodes(2);
@@ -533,12 +793,44 @@ public async Task DefaultProcessRunner_InvalidCommand_NonZeroExitCode()
Assert.NotEqual(0, result.ExitCode);
}
+ [Fact]
+ public async Task DefaultProcessRunner_LogsInlineKubeconfigPathInDebugLog()
+ {
+ 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.Contains(kubeconfigPath, executingMessage, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains($"--kubeconfig={kubeconfigPath}", executingMessage, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task DefaultProcessRunner_LogsSpaceSeparatedKubeconfigPathInDebugLog()
+ {
+ 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.Contains(kubeconfigPath, executingMessage, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains($"--kubeconfig {kubeconfigPath}", executingMessage, StringComparison.Ordinal);
+ }
+
// ── Edge-case tests ──────────────────────────────────────────────────
[Fact]
public async Task WithWorkerNodes_Zero_IsValid()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
builder.AddKindCluster("edge-zero")
.WithWorkerNodes(0);
@@ -553,6 +845,7 @@ public async Task WithWorkerNodes_Zero_IsValid()
Assert.Contains("- role: control-plane", yaml);
Assert.DoesNotContain("- role: worker", yaml);
}
+
finally
{
File.Delete(configPath);
@@ -570,4 +863,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..f28971e39 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs
@@ -19,31 +19,45 @@ 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 Func DelayAsync { get; set; } = Task.Delay;
- 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 DelayAsync(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/KindContainerExtensionsTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindContainerExtensionsTests.cs
index 0454a9bad..43f8c193c 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindContainerExtensionsTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindContainerExtensionsTests.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Hosting;
+using Aspire.Hosting.Utils;
using CommunityToolkit.Aspire.Testing;
namespace CommunityToolkit.Aspire.Hosting.Kind.Tests;
@@ -18,7 +19,7 @@ public async Task WithKindNetwork_UsesRuntimeConnectionStateForRestart()
processRunner.Results.Enqueue(new(0, "", ""));
processRunner.Results.Enqueue(new(0, "", ""));
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
builder.Services.AddSingleton(processRunner);
builder.Services.AddSingleton(
new KindContainerRuntimeResolver(new FakeContainerRuntimeResolver("Docker")));
@@ -70,7 +71,7 @@ await builder.Eventing.PublishAsync(
[Fact]
public void WithReference_Container_InjectsBindMountAnnotation()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var kind = builder.AddKindCluster("test-cluster");
var container = builder.AddContainer("test-container", "test-image")
@@ -90,7 +91,7 @@ public void WithReference_Container_InjectsBindMountAnnotation()
[Fact]
public void WithReference_Container_InjectsEnvironmentAnnotation()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var kind = builder.AddKindCluster("test-cluster");
builder.AddContainer("test-container", "test-image")
@@ -106,7 +107,7 @@ public void WithReference_Container_InjectsEnvironmentAnnotation()
[Fact]
public async Task WithReference_Container_SetsContainerKubeconfigEnvironmentValue()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var kind = builder.AddKindCluster("test-cluster");
var container = builder.AddContainer("test-container", "test-image")
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs
index ed3a3e16f..c94f86ced 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs
@@ -3,6 +3,8 @@
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.Logging;
namespace CommunityToolkit.Aspire.Hosting.Kind.Tests;
@@ -11,7 +13,7 @@ public class KindHelmChartTests
[Fact]
public void AddHelmChartCreatesResource()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test-cluster");
cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis");
@@ -27,7 +29,7 @@ public void AddHelmChartCreatesResource()
[Fact]
public void AddHelmChartSetsParent()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test-cluster");
cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis");
@@ -52,7 +54,7 @@ public void ReleaseNameDefaultsToResourceName()
[Fact]
public void WithChartVersionSetsVersion()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test-cluster");
cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
@@ -68,7 +70,7 @@ public void WithChartVersionSetsVersion()
[Fact]
public void WithHelmValueAddsValue()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test-cluster");
cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
@@ -84,10 +86,148 @@ public void WithHelmValueAddsValue()
Assert.Equal("false", resource.Values["auth.enabled"]);
}
+ [Fact]
+ public void WithHelmStringValueAddsStringValue()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ 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()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ 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()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ 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()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ 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()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
+ .WithCrdWaitRetry(maxAttempts: 3, backoff: TimeSpan.FromSeconds(7), crdWaitTimeout: TimeSpan.FromSeconds(42));
+
+ 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);
+ Assert.Equal(TimeSpan.FromSeconds(42), resource.CrdWaitRetryTimeout);
+ }
+
+ [Fact]
+ public void WithCrdWaitRetryUsesDefaultBackoff()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ 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(KubectlTimeouts.DefaultCrdWaitRetryBackoff, resource.CrdWaitRetryBackoff);
+ Assert.Equal(KubectlTimeouts.DefaultCrdWaitTimeout, resource.CrdWaitRetryTimeout);
+ }
+
+ [Fact]
+ public void WithCrdWaitRetryRejectsLessThanTwoAttempts()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddHelmChart("redis", "chart/ref").WithCrdWaitRetry(1, TimeSpan.FromSeconds(5)));
+ }
+
+ [Fact]
+ public void WithCrdWaitRetryRejectsInvalidBackoff()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddHelmChart("redis", "chart/ref").WithCrdWaitRetry(3, TimeSpan.Zero));
+ }
+
+ [Fact]
+ public void WithCrdWaitRetryRejectsInvalidCrdWaitTimeout()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddHelmChart("redis", "chart/ref").WithCrdWaitRetry(3, TimeSpan.FromSeconds(5), TimeSpan.Zero));
+ }
+
[Fact]
public void WithHelmValuesFileAddsPath()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test-cluster");
cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
@@ -106,7 +246,7 @@ public void WithHelmValuesFileAddsPath()
[Fact]
public void WithNamespaceSetsNamespace()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test-cluster");
cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
@@ -144,13 +284,14 @@ public void ValuesAndValuesFilesStartEmpty()
var resource = new KindHelmChartResource("redis", "chart/ref", cluster);
Assert.Empty(resource.Values);
+ Assert.Empty(resource.StringValues);
Assert.Empty(resource.ValuesFiles);
}
[Fact]
public void MultipleHelmChartsCanBeAddedToSameCluster()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test-cluster");
cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis");
@@ -167,12 +308,13 @@ public void MultipleHelmChartsCanBeAddedToSameCluster()
[Fact]
public void FluentApiChainingWorks()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test-cluster");
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 +324,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 +340,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 +359,140 @@ 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_UsesConfiguredCrdWaitTimeoutBetweenRetries()
+ {
+ var cluster = new KindClusterResource("cluster");
+ var resource = new KindHelmChartResource("redis", "chart/ref", cluster)
+ {
+ CrdWaitRetryMaxAttempts = 2,
+ CrdWaitRetryBackoff = TimeSpan.FromSeconds(2),
+ CrdWaitRetryTimeout = TimeSpan.FromSeconds(42),
+ };
+ var processRunner = new FakeProcessRunner();
+ processRunner.Results.Enqueue(new(0, "", ""));
+ 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", ""));
+ processRunner.Results.Enqueue(new(0, "", ""));
+ processRunner.Results.Enqueue(new(0, "release installed", ""));
+ var manager = new HelmManager(processRunner, static (_, _) => Task.CompletedTask);
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+
+ await manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None);
+
+ Assert.Contains("--timeout=42s", processRunner.Commands[3].Arguments);
+ }
+
+ [Fact]
+ public async Task InstallAsync_DoesNotRetryByDefault()
+ {
+ var cluster = new KindClusterResource("cluster");
+ var resource = new KindHelmChartResource("redis", "chart/ref", cluster);
+ var processRunner = new FakeProcessRunner();
+ processRunner.Results.Enqueue(new(1, "", "release failed"));
+ 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.Single(processRunner.Commands);
+ }
+
+ [Fact]
+ public async Task InstallAsync_RetriesExplicitlyConfiguredFailuresEvenWithoutNewCrds()
+ {
+ 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, "", ""));
+ processRunner.Results.Enqueue(new(1, "", "release failed"));
+ processRunner.Results.Enqueue(new(0, "", ""));
+ processRunner.Results.Enqueue(new(1, "", "release still failed"));
+ processRunner.Results.Enqueue(new(0, "", ""));
+ processRunner.Results.Enqueue(new(0, "release installed", ""));
+ 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(6, processRunner.Commands.Count);
+ Assert.Equal(3, processRunner.Commands.Count(command => command.FileName == "helm"));
+ Assert.Equal(3, processRunner.Commands.Count(command => command.FileName == "kubectl"));
+ Assert.Equal([TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4)], delays);
+ }
+
+ [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]
@@ -237,7 +509,7 @@ public void AddHelmChartShouldThrowWhenBuilderIsNull()
[Fact]
public void AddHelmChartShouldThrowWhenNameIsNull()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test-cluster");
string name = null!;
@@ -250,7 +522,7 @@ public void AddHelmChartShouldThrowWhenNameIsNull()
[Fact]
public void AddHelmChartShouldThrowWhenChartRefIsNull()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test-cluster");
string chartRef = null!;
@@ -282,6 +554,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 +576,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()
{
@@ -330,7 +624,7 @@ public void KindHelmChartResourceShouldThrowWhenChartRefIsNull()
[Fact]
public void AddHelmChartRegistersHealthCheck()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test-cluster");
cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis");
@@ -346,7 +640,7 @@ public void AddHelmChartRegistersHealthCheck()
[Fact]
public void AddHelmChartRegistersUniqueHealthCheckPerResource()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test-cluster");
cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis");
@@ -364,4 +658,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..e2b073c60
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs
@@ -0,0 +1,1110 @@
+// 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 Aspire.Hosting.Utils;
+using Microsoft.Extensions.Logging;
+using System.ComponentModel;
+
+namespace CommunityToolkit.Aspire.Hosting.Kind.Tests;
+
+public class KindManifestTests
+{
+ [Fact]
+ public void AddManifestCreatesResource()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ var absolutePath = Path.Combine(AppContext.BaseDirectory, "manifests", "crds.yaml");
+ cluster.AddManifest("crds", absolutePath);
+
+ 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.Equal(absolutePath, resource.ManifestPath);
+ }
+
+ [Fact]
+ public void AddManifestSetsParent()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ var absolutePath = Path.Combine(AppContext.BaseDirectory, "manifests", "crds.yaml");
+ cluster.AddManifest("crds", absolutePath);
+
+ 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";
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ 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", @"C:\manifests\crds.yaml"));
+ }
+
+ [Fact]
+ public void AddManifestThrowsOnNullName()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+ Assert.Throws(() => cluster.AddManifest(null!, @"C:\manifests\crds.yaml"));
+ }
+
+ [Fact]
+ public void AddManifestThrowsOnNullManifestPath()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() => cluster.AddManifest("crds", null!));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void AddManifestThrowsOnWhitespaceManifestPath(string manifestPath)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ 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()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+ Assert.Throws(() => cluster.AddManifestFromContent(null!, "apiVersion: v1"));
+ }
+
+ [Fact]
+ public void AddManifestFromContentThrowsOnNullContent()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+ Assert.Throws(() => cluster.AddManifestFromContent("crds", null!));
+ }
+
+ [Fact]
+ public void AddManifestRejectsRelativePath()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ var exception = Assert.Throws(() => cluster.AddManifest("crds", Path.Combine("manifests", "crds.yaml")));
+
+ Assert.Contains("absolute path", exception.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void AddManifestUsesAbsolutePathAsIs()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ 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()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("all", Path.Combine(AppContext.BaseDirectory, "manifests"))
+ .WithRecursive();
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.True(resource.Recursive);
+ }
+
+ [Fact]
+ public void WithServerSideApplySetsServerSide()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithServerSideApply();
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.True(resource.ServerSide);
+ Assert.False(resource.ForceConflicts);
+ }
+
+ [Fact]
+ public void WithServerSideApplyForceConflictsSetsBoth()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithServerSideApply(forceConflicts: true);
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.True(resource.ServerSide);
+ Assert.True(resource.ForceConflicts);
+ }
+
+ [Fact]
+ public void WithFieldManagerSetsFieldManager()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithFieldManager("my-tool");
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal("my-tool", resource.FieldManager);
+ }
+
+ [Fact]
+ public void WithApplyTimeoutSetsApplyTimeout()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithApplyTimeout(TimeSpan.FromSeconds(30));
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal(TimeSpan.FromSeconds(30), resource.ApplyTimeout);
+ }
+
+ [Fact]
+ public void WithClusterReadyTimeoutSetsClusterReadyTimeout()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithClusterReadyTimeout(TimeSpan.FromSeconds(90));
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal(TimeSpan.FromSeconds(90), resource.ClusterReadyTimeout);
+ }
+
+ [Fact]
+ public void WithClusterReadyTimeoutWiresValueIntoKubectlManagerCreation()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithClusterReadyTimeout(TimeSpan.FromSeconds(90));
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+ var resource = Assert.Single(appModel.Resources.OfType());
+
+ var manager = KindManifestResourceBuilderExtensions.CreateKubectlManager(new FakeProcessRunner(), resource);
+
+ Assert.Equal(TimeSpan.FromSeconds(90), manager.ClusterInfoMaxWaitForTesting);
+ }
+
+ [Fact]
+ public void WithClusterReadyTimeoutRejectsZero()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithClusterReadyTimeout(TimeSpan.Zero));
+ }
+
+ [Fact]
+ public void WithClusterReadyTimeoutRejectsNegative()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithClusterReadyTimeout(TimeSpan.FromSeconds(-1)));
+ }
+
+ [Fact]
+ public void WithClusterReadyTimeoutRoundsSubSecondUpToOneSecond()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithClusterReadyTimeout(TimeSpan.FromMilliseconds(500));
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal(TimeSpan.FromSeconds(1), resource.ClusterReadyTimeout);
+ }
+
+ [Fact]
+ public void WithClusterReadyTimeoutRejectsMoreThanOneHour()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithClusterReadyTimeout(TimeSpan.MaxValue));
+ }
+
+ [Fact]
+ public void WithApplyTimeoutRejectsZero()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithApplyTimeout(TimeSpan.Zero));
+ }
+
+ [Fact]
+ public void WithApplyTimeoutRejectsNegative()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithApplyTimeout(TimeSpan.FromSeconds(-1)));
+ }
+
+ [Fact]
+ public void WithApplyTimeoutRoundsSubSecondUpToOneSecond()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithApplyTimeout(TimeSpan.FromMilliseconds(500));
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal(TimeSpan.FromSeconds(1), resource.ApplyTimeout);
+ }
+
+ [Fact]
+ public void WithApplyTimeoutRejectsMoreThanOneHour()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithApplyTimeout(TimeSpan.MaxValue));
+ }
+
+ [Fact]
+ public void WithCrdWaitTimeoutSetsCrdWaitTimeout()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithCrdWaitTimeout(TimeSpan.FromSeconds(45));
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal(TimeSpan.FromSeconds(45), resource.CrdWaitTimeout);
+ }
+
+ [Fact]
+ public void WithCrdWaitTimeoutRejectsZero()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithCrdWaitTimeout(TimeSpan.Zero));
+ }
+
+ [Fact]
+ public void WithCrdWaitTimeoutRejectsNegative()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithCrdWaitTimeout(TimeSpan.FromSeconds(-1)));
+ }
+
+ [Fact]
+ public void WithCrdWaitTimeoutRoundsSubSecondUpToOneSecond()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithCrdWaitTimeout(TimeSpan.FromMilliseconds(500));
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal(TimeSpan.FromSeconds(1), resource.CrdWaitTimeout);
+ }
+
+ [Fact]
+ public void WithCrdWaitTimeoutRejectsMoreThanOneHour()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+
+ Assert.Throws(() =>
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithCrdWaitTimeout(TimeSpan.MaxValue));
+ }
+
+ [Fact]
+ public void WithCrdWaitBehaviorSetsCrdWaitBehavior()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithCrdWaitBehavior(CrdWaitBehavior.BestEffort);
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal(CrdWaitBehavior.BestEffort, resource.CrdWaitBehavior);
+ }
+
+ [Fact]
+ public void DefaultRecursiveIsFalse()
+ {
+ var cluster = new KindClusterResource("cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster);
+
+ Assert.False(resource.Recursive);
+ }
+
+ [Fact]
+ public void DefaultServerSideIsFalse()
+ {
+ var cluster = new KindClusterResource("cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster);
+
+ Assert.False(resource.ServerSide);
+ Assert.False(resource.ForceConflicts);
+ }
+
+ [Fact]
+ public void DefaultFieldManagerIsNull()
+ {
+ var cluster = new KindClusterResource("cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster);
+
+ Assert.Null(resource.FieldManager);
+ }
+
+ [Fact]
+ public void DefaultApplyTimeoutIsFiveMinutes()
+ {
+ var cluster = new KindClusterResource("cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster);
+
+ Assert.Equal(TimeSpan.FromMinutes(5), resource.ApplyTimeout);
+ }
+
+ [Fact]
+ public void DefaultClusterReadyTimeoutIsSixtySeconds()
+ {
+ var cluster = new KindClusterResource("cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster);
+
+ Assert.Equal(TimeSpan.FromSeconds(60), resource.ClusterReadyTimeout);
+ }
+
+ [Fact]
+ public void DefaultCrdWaitSettingsFailAfterFiveMinutes()
+ {
+ var cluster = new KindClusterResource("cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster);
+
+ Assert.Equal(TimeSpan.FromMinutes(5), resource.CrdWaitTimeout);
+ Assert.Equal(CrdWaitBehavior.Fail, resource.CrdWaitBehavior);
+ }
+
+ [Fact]
+ public void DefaultNamespaceIsNull()
+ {
+ var cluster = new KindClusterResource("cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster);
+
+ Assert.Null(resource.Namespace);
+ }
+
+ [Fact]
+ public void ManifestResourceIsIResourceWithParent()
+ {
+ var cluster = new KindClusterResource("cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster);
+
+ Assert.IsAssignableFrom>(resource);
+ Assert.Same(cluster, ((IResourceWithParent)resource).Parent);
+ }
+
+ // ── KubectlManager argument-shape tests (no CLI invocation) ──────────────────
+
+ [Fact]
+ public void CreateApplyArguments_MinimalManifest_ContainsApplyAndKubeconfig()
+ {
+ var cluster = new KindClusterResource("test-cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster);
+
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.Equal("apply", args[0]);
+ Assert.Contains("-f", args);
+ Assert.Contains("./crds.yaml", args);
+ Assert.Contains(args, a => a.StartsWith("--kubeconfig=", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void CreateApplyArguments_WithNamespace_IncludesNamespaceFlag()
+ {
+ var cluster = new KindClusterResource("test-cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster)
+ {
+ Namespace = "kube-system",
+ };
+
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.Contains("--namespace", args);
+ Assert.Contains("kube-system", args);
+ }
+
+ [Fact]
+ public void CreateApplyArguments_WithRecursive_IncludesRecursiveFlag()
+ {
+ var cluster = new KindClusterResource("test-cluster");
+ var resource = new K8sManifestResource("all", "./manifests", cluster)
+ {
+ Recursive = true,
+ };
+
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.Contains("--recursive", args);
+ }
+
+ [Fact]
+ public void CreateApplyArguments_KustomizeMode_Uses_MinusK()
+ {
+ var cluster = new KindClusterResource("test-cluster");
+ var directory = CreateTestDirectory();
+
+ try
+ {
+ File.WriteAllText(Path.Combine(directory, "kustomization.yaml"), "resources: []");
+ var resource = new K8sManifestResource("kustom", directory, cluster);
+
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.Contains("-k", args);
+ Assert.Contains(directory, args);
+ Assert.DoesNotContain("-f", args);
+ Assert.True(resource.IsKustomize);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Theory]
+ [InlineData("Kustomization")]
+ [InlineData("Kustomization.yml")]
+ [InlineData("KUSTOMIZATION.YAML")]
+ public void CreateApplyArguments_KustomizeMode_DetectsCaseInsensitiveVariants(string fileName)
+ {
+ var cluster = new KindClusterResource("test-cluster");
+ var directory = CreateTestDirectory();
+
+ try
+ {
+ File.WriteAllText(Path.Combine(directory, fileName), "resources: []");
+ var resource = new K8sManifestResource("kustom", directory, cluster);
+
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.Contains("-k", args);
+ Assert.True(resource.IsKustomize);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void CreateApplyArguments_DetectsKustomizeAtApplyTime()
+ {
+ var directory = CreateTestDirectory();
+
+ try
+ {
+ var resource = AddManifestAndGetResource(directory);
+ Assert.False(resource.IsKustomize);
+
+ File.WriteAllText(Path.Combine(directory, "kustomization.yaml"), "resources: []");
+
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.Contains("-k", args);
+ Assert.True(resource.IsKustomize);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void CreateApplyArguments_InlineContent_Uses_MinusStdinDash()
+ {
+ var cluster = new KindClusterResource("test-cluster");
+ var resource = new K8sManifestResource("inline", K8sManifestResource.InlineManifestPath, cluster)
+ {
+ InlineContent = "apiVersion: v1",
+ };
+
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.Contains("-f", args);
+ Assert.Contains("-", args);
+ Assert.DoesNotContain(K8sManifestResource.InlineManifestPath, args);
+ }
+
+ [Fact]
+ public void CreateApplyArguments_InlineContent_SkipsKustomizeDetection()
+ {
+ var cluster = new KindClusterResource("test-cluster");
+ var resource = new K8sManifestResource("inline", K8sManifestResource.InlineManifestPath, cluster)
+ {
+ InlineContent = "apiVersion: v1",
+ IsKustomize = true,
+ };
+
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.Contains("-f", args);
+ Assert.Contains("-", args);
+ Assert.DoesNotContain("-k", args);
+ }
+
+ [Fact]
+ public void WithRecursive_OnKustomize_Warns_And_Ignores()
+ {
+ var cluster = new KindClusterResource("test-cluster");
+ var directory = CreateTestDirectory();
+
+ try
+ {
+ File.WriteAllText(Path.Combine(directory, "kustomization.yaml"), "resources: []");
+ var resource = new K8sManifestResource("kustom", directory, cluster)
+ {
+ Recursive = true,
+ };
+
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.DoesNotContain("--recursive", args);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void CreateApplyArguments_WithServerSide_IncludesServerSideFlag()
+ {
+ var cluster = new KindClusterResource("test-cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster)
+ {
+ ServerSide = true,
+ };
+
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.Contains("--server-side", args);
+ Assert.DoesNotContain("--force-conflicts", args);
+ }
+
+ [Fact]
+ public void CreateApplyArguments_ServerSideWithForceConflicts_IncludesBoth()
+ {
+ var cluster = new KindClusterResource("test-cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster)
+ {
+ ServerSide = true,
+ ForceConflicts = true,
+ };
+
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.Contains("--server-side", args);
+ Assert.Contains("--force-conflicts", args);
+ }
+
+ [Fact]
+ public void CreateApplyArguments_ForceConflictsWithoutServerSide_OmitsForceConflicts()
+ {
+ // --force-conflicts only means anything with --server-side; without server-side we
+ // should not emit it, otherwise kubectl rejects the command.
+ var cluster = new KindClusterResource("test-cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster)
+ {
+ ServerSide = false,
+ ForceConflicts = true,
+ };
+
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.DoesNotContain("--force-conflicts", args);
+ }
+
+ [Fact]
+ public void CreateApplyArguments_WithFieldManager_IncludesFieldManagerFlag()
+ {
+ var cluster = new KindClusterResource("test-cluster");
+ var resource = new K8sManifestResource("crds", "./crds.yaml", cluster)
+ {
+ FieldManager = "my-tool",
+ };
+
+ var args = KubectlManager.CreateApplyArguments(resource);
+
+ Assert.Contains("--field-manager", args);
+ Assert.Contains("my-tool", args);
+ }
+
+ [Fact]
+ public void CreateWaitArguments_ProducesExpectedShape()
+ {
+ var args = KubectlManager.CreateWaitArguments(
+ ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com", "customresourcedefinition.apiextensions.k8s.io/gadgets.example.com"],
+ "C:\\kube\\config.yaml",
+ TimeSpan.FromMinutes(5));
+
+ Assert.Equal("wait", args[0]);
+ Assert.Equal("--for=condition=Established", args[1]);
+ Assert.Contains("customresourcedefinition.apiextensions.k8s.io/widgets.example.com", args);
+ Assert.Contains("customresourcedefinition.apiextensions.k8s.io/gadgets.example.com", args);
+ Assert.Contains("--timeout=300s", args);
+ Assert.Contains("--kubeconfig=C:\\kube\\config.yaml", args);
+ }
+
+ [Fact]
+ public void CreateWaitArguments_RoundsSubSecondTimeoutUpToOneSecond()
+ {
+ var args = KubectlManager.CreateWaitArguments(
+ ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"],
+ "C:\\kube\\config.yaml",
+ TimeSpan.FromMilliseconds(500));
+
+ Assert.Contains("--timeout=1s", args);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void CreateKubectlArguments_RejectWhitespaceKubeconfigPath(string kubeconfigPath)
+ {
+ Assert.Throws(() =>
+ KubectlManager.CreateWaitArguments(
+ ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"],
+ kubeconfigPath,
+ TimeSpan.FromSeconds(1)));
+ Assert.Throws(() => KubectlManager.CreateClusterInfoArguments(kubeconfigPath));
+ Assert.Throws(() => KubectlManager.CreateGetCrdsArguments(kubeconfigPath));
+ }
+
+ [Fact]
+ public async Task ApplyAsync_WaitsForAppliedCrdsBestEffort()
+ {
+ var processRunner = new FakeProcessRunner();
+ processRunner.Results.Enqueue(new(0, "cluster is running", ""));
+ processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com created", ""));
+ processRunner.Results.Enqueue(new(1, "", "timed out waiting for the condition"));
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new KubectlManager(processRunner);
+ var resource = new K8sManifestResource("crds", "./crds.yaml", new KindClusterResource("test-cluster"))
+ {
+ CrdWaitBehavior = CrdWaitBehavior.BestEffort,
+ };
+
+ await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None);
+
+ Assert.Equal(3, processRunner.Commands.Count);
+ Assert.Contains("cluster-info", processRunner.Commands[0].Arguments);
+ Assert.Contains("apply -f ./crds.yaml", processRunner.Commands[1].Arguments);
+ Assert.Contains("wait --for=condition=Established customresourcedefinition.apiextensions.k8s.io/widgets.example.com", processRunner.Commands[2].Arguments);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_FailsWhenCrdWaitFailsByDefault()
+ {
+ var processRunner = new FakeProcessRunner();
+ processRunner.Results.Enqueue(new(0, "cluster is running", ""));
+ processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com created", ""));
+ processRunner.Results.Enqueue(new(1, "", "timed out waiting for the condition"));
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new KubectlManager(processRunner);
+ var resource = new K8sManifestResource("crds", "./crds.yaml", new KindClusterResource("test-cluster"));
+
+ var ex = await Assert.ThrowsAsync(
+ () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None));
+
+ Assert.Contains("Established", ex.Message);
+ Assert.Equal(3, processRunner.Commands.Count);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_UsesConfiguredCrdWaitTimeout()
+ {
+ var processRunner = new FakeProcessRunner();
+ processRunner.Results.Enqueue(new(0, "cluster is running", ""));
+ processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com created", ""));
+ processRunner.Results.Enqueue(new(0, "", ""));
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new KubectlManager(processRunner);
+ var resource = new K8sManifestResource("crds", "./crds.yaml", new KindClusterResource("test-cluster"))
+ {
+ CrdWaitTimeout = TimeSpan.FromSeconds(42),
+ };
+
+ await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None);
+
+ Assert.Contains("--timeout=42s", processRunner.Commands[2].Arguments);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_RetriesClusterInfoBeforeApply()
+ {
+ var processRunner = new FakeProcessRunner();
+ processRunner.Results.Enqueue(new(1, "", "not ready"));
+ processRunner.Results.Enqueue(new(1, "", "still not ready"));
+ processRunner.Results.Enqueue(new(0, "cluster is running", ""));
+ processRunner.Results.Enqueue(new(0, "namespace/default unchanged", ""));
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new KubectlManager(processRunner, static (_, _) => Task.CompletedTask);
+ var resource = new K8sManifestResource("manifest", "./manifest.yaml", new KindClusterResource("test-cluster"));
+
+ await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None);
+
+ Assert.Equal(4, processRunner.Commands.Count);
+ Assert.All(processRunner.Commands.Take(3), command => Assert.Contains("cluster-info", command.Arguments));
+ Assert.Contains("apply -f ./manifest.yaml", processRunner.Commands[3].Arguments);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_ClusterInfoSlowFailuresRespectWallClockBudget()
+ {
+ var processRunner = new FakeProcessRunner
+ {
+ NextResult = new(1, "", "not ready"),
+ Delay = TimeSpan.FromMilliseconds(40),
+ };
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new KubectlManager(
+ processRunner,
+ static (_, _) => Task.CompletedTask,
+ clusterInfoMaxWait: TimeSpan.FromMilliseconds(100),
+ clusterInfoProbeTimeout: TimeSpan.FromSeconds(1));
+ var resource = new K8sManifestResource("manifest", "./manifest.yaml", new KindClusterResource("test-cluster"));
+ var started = DateTimeOffset.UtcNow;
+
+ var ex = await Assert.ThrowsAsync(
+ () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None));
+
+ var elapsed = DateTimeOffset.UtcNow - started;
+ Assert.Contains("Timed out waiting for cluster", ex.Message);
+ Assert.True(elapsed < TimeSpan.FromSeconds(1), $"Elapsed {elapsed} exceeded tolerance.");
+ Assert.All(processRunner.Commands, command => Assert.Contains("cluster-info", command.Arguments));
+ }
+
+ [Fact]
+ public async Task ApplyAsync_CancelsApplyAfterConfiguredTimeout()
+ {
+ var processRunner = new FakeProcessRunner
+ {
+ DelayAsync = static (_, cancellationToken) =>
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken));
+ return tcs.Task;
+ }
+ };
+ processRunner.Results.Enqueue(new(0, "cluster is running", ""));
+ processRunner.Results.Enqueue(new(0, "namespace/default unchanged", ""));
+ processRunner.Delays.Enqueue(TimeSpan.Zero);
+ processRunner.Delays.Enqueue(TimeSpan.FromSeconds(5));
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new KubectlManager(processRunner);
+ var resource = new K8sManifestResource("manifest", "./manifest.yaml", new KindClusterResource("test-cluster"))
+ {
+ ApplyTimeout = TimeSpan.FromMilliseconds(10),
+ };
+
+ await Assert.ThrowsAsync(
+ () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ApplyAsync_WithRecursiveFilePath_ThrowsBeforeKubectlApply()
+ {
+ var directory = CreateTestDirectory();
+
+ try
+ {
+ var manifestPath = Path.Combine(directory, "manifest.yaml");
+ await File.WriteAllTextAsync(manifestPath, "apiVersion: v1");
+
+ var processRunner = new FakeProcessRunner();
+ processRunner.Results.Enqueue(new(0, "cluster is running", ""));
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new KubectlManager(processRunner);
+ var resource = new K8sManifestResource("manifest", manifestPath, new KindClusterResource("test-cluster"))
+ {
+ Recursive = true,
+ };
+
+ var exception = await Assert.ThrowsAsync(
+ () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None));
+
+ Assert.Contains("existing directory", exception.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Single(processRunner.Commands);
+ Assert.Contains("cluster-info", processRunner.Commands[0].Arguments);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task ApplyAsync_IncludesStdoutWhenApplyFailsWithoutStderr()
+ {
+ var processRunner = new FakeProcessRunner();
+ processRunner.Results.Enqueue(new(0, "cluster is running", ""));
+ processRunner.Results.Enqueue(new(1, "resource mapping not found", ""));
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new KubectlManager(processRunner);
+ var resource = new K8sManifestResource("manifest", "./manifest.yaml", new KindClusterResource("test-cluster"));
+
+ var ex = await Assert.ThrowsAsync(
+ () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None));
+
+ Assert.Contains("resource mapping not found", ex.Message);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_ThrowsHelpfulErrorWhenKubectlIsMissing()
+ {
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new KubectlManager(new ThrowingProcessRunner(new Win32Exception("kubectl not found")));
+ var resource = new K8sManifestResource("manifest", "./manifest.yaml", new KindClusterResource("test-cluster"));
+
+ var ex = await Assert.ThrowsAsync(
+ () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None));
+
+ Assert.Contains("kubectl CLI not found", ex.Message);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_InlineContent_PassesStandardInput()
+ {
+ const string content = "apiVersion: v1\nkind: Namespace";
+ var processRunner = new FakeProcessRunner();
+ processRunner.Results.Enqueue(new(0, "cluster is running", ""));
+ processRunner.Results.Enqueue(new(0, "", ""));
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new KubectlManager(processRunner);
+ var resource = new K8sManifestResource("inline", K8sManifestResource.InlineManifestPath, new KindClusterResource("test-cluster"))
+ {
+ InlineContent = content,
+ };
+
+ await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None);
+
+ var command = processRunner.Commands.Last();
+ Assert.Contains("apply -f -", command.Arguments);
+ Assert.Equal(content, command.StandardInput);
+ }
+
+ [Fact]
+ public void CreateApplyArguments_ThrowsOnNullResource()
+ {
+ Assert.Throws(() => KubectlManager.CreateApplyArguments(null!));
+ }
+
+ private static K8sManifestResource AddManifestAndGetResource(string path)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+ cluster.AddManifest("kustom", path);
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ return Assert.Single(appModel.Resources.OfType());
+ }
+
+ private static string CreateTestDirectory()
+ {
+ var directory = Path.Combine(AppContext.BaseDirectory, "kind-manifest-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ return directory;
+ }
+
+ private sealed class ThrowingProcessRunner(Exception exception) : IProcessRunner
+ {
+ public Task RunAsync(
+ ILogger logger,
+ string fileName,
+ IReadOnlyList arguments,
+ string? workingDirectory = null,
+ IReadOnlyDictionary? environmentVariables = null,
+ string? standardInput = null,
+ CancellationToken cancellationToken = default) => Task.FromException(exception);
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPublicApiTests.cs
index b66fe1147..f93ea0612 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPublicApiTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPublicApiTests.cs
@@ -3,6 +3,7 @@
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Utils;
using CommunityToolkit.Aspire.Hosting.Kind;
namespace CommunityToolkit.Aspire.Hosting.Kind.Tests;
@@ -23,7 +24,7 @@ public void AddKindClusterShouldThrowWhenBuilderIsNull()
[Fact]
public void AddKindClusterShouldThrowWhenNameIsNull()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
string name = null!;
var action = () => builder.AddKindCluster(name);
@@ -54,6 +55,28 @@ public void WithWorkerNodesShouldThrowWhenBuilderIsNull()
Assert.Equal(nameof(builder), exception.ParamName);
}
+ [Fact]
+ public void WithNodeImageShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.WithNodeImage("kindest/node:v1.32.2");
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithNodeMountShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.WithNodeMount(@"C:\host", "/container");
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
[Fact]
public void WithClusterLifetimeShouldThrowWhenBuilderIsNull()
{
@@ -102,7 +125,7 @@ public void WithKindConfigShouldThrowWhenBuilderIsNull()
[Fact]
public void WithKindConfigShouldThrowWhenConfigureIsNull()
{
- var builder = DistributedApplication.CreateBuilder();
+ using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test");
Action configure = null!;
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs
index 1f9c626d3..2c7857ab6 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs
@@ -45,20 +45,27 @@ public void WithKindParentLinksToKubernetesEnvironment()
public void FluentMethodsWorkAfterWithKind()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var hostPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "kind-data"));
builder.AddKubernetesEnvironment("k8s")
.WithKind()
- .WithKubernetesVersion("v1.32.2")
- .WithWorkerNodes(2);
+ .WithWorkerNodes(2)
+ .WithNodeImage("myacr.azurecr.io/kindest/node:v1.32.2")
+ .WithNodeMount(hostPath, "/kind-data", readOnly: true);
using var app = builder.Build();
var model = app.Services.GetRequiredService();
var kindEnv = Assert.Single(model.Resources.OfType());
Assert.True(kindEnv.TryGetLastAnnotation