From 5c80dc4b586693f62caa43fb36bbe2ecb02d3d8d Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 25 Jul 2026 22:47:00 +0300 Subject: [PATCH 01/16] feat(Kind): add AddManifest resource for kubectl apply after cluster ready Adds Kubernetes manifest resources for Kind clusters, aligned with the existing K3s manifest pattern while keeping Kind's host-side kubectl execution model. Public API surface: cluster.AddManifest(name, manifestPath) .WithNamespace(ns) .WithRecursive() .WithServerSideApply(forceConflicts: false) .WithFieldManager("aspire-apphost") .WithApplyTimeout(TimeSpan.FromMinutes(5)) .WithCrdWaitTimeout(TimeSpan.FromMinutes(5)) .WithCrdWaitBehavior(CrdWaitBehavior.Fail) cluster.AddManifestFromContent(name, content) .WithNamespace(ns) .WithServerSideApply(forceConflicts: false) .WithFieldManager("aspire-apphost") Key behavior: - K8sManifestResource extends KindDeployedResource(name, parent) and is exported for Aspire tooling. - AddManifest resolves relative paths against AppHostDirectory; Kustomize detection happens at apply time and supports case-insensitive marker variants. - AddManifestFromContent applies inline YAML via kubectl apply -f - using stdin, avoiding temporary files. - KubectlManager probes kubectl cluster-info before apply, bounds apply with ApplyTimeout, and fails CRD waits by default with an opt-in best-effort mode. - WithRecursive is honored for file/directory applies and ignored with a warning for Kustomize or stdin applies. - Manifest resources rely on Running/FailedToStart state rather than a default empty-selector workload health check. Downstream resources can WaitFor the manifest resource so they only start once kubectl apply succeeds: var crds = cluster.AddManifest("crds", "./manifests/crds.yaml"); builder.AddContainer("my-operator", "my-org/operator") .WithReference(cluster) .WaitFor(crds); Manifests persist with the cluster - deleted with session clusters, retained with persistent clusters. Tests cover resource registration, parent wiring, null guards, AppHost-relative path resolution, absolute-path preservation, Kustomize detection, stdin apply argument shape and input piping, recursive ignore behavior, cluster-info retry, apply timeout cancellation, CRD wait argument shape, and default/best-effort CRD wait behavior. Closes: none (net new) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c8c8109-2ae6-407b-8740-8c3018abe8f1 --- ...Toolkit.Aspire.Hosting.Kind.AppHost.csproj | 4 + .../Program.cs | 26 + .../manifests/extra-config.yaml | 16 + .../CrdWaitBehavior.cs | 26 + .../DefaultProcessRunner.cs | 42 +- .../IProcessRunner.cs | 1 + .../K8sManifestResource.cs | 85 ++ .../KindHelmChartResourceBuilderExtensions.cs | 4 + .../KindManifestResourceBuilderExtensions.cs | 252 +++++ .../KubectlManager.cs | 379 ++++++++ .../KubectlTimeouts.cs | 29 + .../README.md | 85 ++ .../AddKindClusterTests.cs | 54 ++ .../FakeProcessRunner.cs | 27 +- .../KindManifestTests.cs | 913 ++++++++++++++++++ 15 files changed, 1935 insertions(+), 8 deletions(-) create mode 100644 examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/manifests/extra-config.yaml create mode 100644 src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs 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..9e95c22e2 100644 --- a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs +++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs @@ -19,11 +19,37 @@ .WithHelmValue("master.service.nodePorts.redis", "30379") .WithNamespace("cache"); +// Apply raw Kubernetes YAML to the Kind cluster and target the namespace declared by the sample manifest. +var manifestResource = cluster.AddManifest("extra-config", "manifests/extra-config.yaml") + .WithNamespace("aspire-demo"); + +// Demonstrate recursive directory apply for manifest folders. +cluster.AddManifest("recursive-config", "manifests") + .WithRecursive(); + +// Demonstrate server-side apply with conflict forcing and a stable field manager. +cluster.AddManifest("ssa-config", "manifests/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", "manifests/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"); diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/manifests/extra-config.yaml b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/manifests/extra-config.yaml new file mode 100644 index 000000000..dafb34631 --- /dev/null +++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/manifests/extra-config.yaml @@ -0,0 +1,16 @@ +# This file demonstrates a multi-document manifest: +# 1. A Namespace for grouping demo resources. +# 2. A ConfigMap scoped to that namespace. +apiVersion: v1 +kind: Namespace +metadata: + name: aspire-demo +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: extra-config + namespace: aspire-demo +data: + greeting: hello-kind + purpose: aspire-demo diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs new file mode 100644 index 000000000..b3c1a54e1 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREATS001 // AspireExport APIs are experimental + +[assembly: Aspire.Hosting.AspireExport(typeof(Aspire.Hosting.CrdWaitBehavior))] + +namespace Aspire.Hosting; + +/// +/// Specifies how Kind manifest resources handle CRD Established-condition wait failures. +/// +public enum CrdWaitBehavior +{ + /// + /// Fail the manifest resource when waiting for applied CRDs fails or times out. + /// + Fail, + + /// + /// Log a warning and continue when waiting for applied CRDs fails or times out. + /// + BestEffort, +} + +#pragma warning restore ASPIREATS001 diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs index af1093ae1..84ebe8465 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs @@ -20,15 +20,17 @@ public async Task RunAsync( IReadOnlyList arguments, string? workingDirectory = null, IReadOnlyDictionary? environmentVariables = null, + string? standardInput = null, CancellationToken cancellationToken = default) { - logger.LogDebug("Executing: {FileName} {Arguments}", fileName, string.Join(' ', arguments)); + logger.LogDebug("Executing: {FileName} {Arguments}", fileName, RedactSensitiveArgs(arguments)); var psi = new ProcessStartInfo { FileName = fileName, RedirectStandardOutput = true, RedirectStandardError = true, + RedirectStandardInput = standardInput is not null, UseShellExecute = false, CreateNoWindow = true }; @@ -79,6 +81,12 @@ public async Task RunAsync( try { + if (standardInput is not null) + { + await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken).ConfigureAwait(false); + process.StandardInput.Close(); + } + // WaitForExitAsync observes process termination; WaitForExit() then lets async stdout/stderr event handlers finish draining redirected output. await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); process.WaitForExit(); @@ -99,4 +107,36 @@ public async Task RunAsync( return new ProcessResult(process.ExitCode, stdout.ToString().TrimEnd(), stderr.ToString().TrimEnd()); } + + internal static string RedactSensitiveArgs(IReadOnlyList arguments) + { + ArgumentNullException.ThrowIfNull(arguments); + + var redacted = new string[arguments.Count]; + var redactNext = false; + + for (var i = 0; i < arguments.Count; i++) + { + var argument = arguments[i]; + + if (redactNext) + { + redacted[i] = "[REDACTED]"; + redactNext = false; + } + else if (string.Equals(argument, "--kubeconfig", StringComparison.OrdinalIgnoreCase)) + { + redacted[i] = argument; + redactNext = true; + } + else + { + redacted[i] = argument.StartsWith("--kubeconfig=", StringComparison.OrdinalIgnoreCase) + ? "--kubeconfig=[REDACTED]" + : argument; + } + } + + return string.Join(' ', redacted); + } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/IProcessRunner.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/IProcessRunner.cs index 165cdf03d..eba7808e8 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/IProcessRunner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/IProcessRunner.cs @@ -25,5 +25,6 @@ Task RunAsync( IReadOnlyList arguments, string? workingDirectory = null, IReadOnlyDictionary? environmentVariables = null, + string? standardInput = null, CancellationToken cancellationToken = default); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs new file mode 100644 index 000000000..8d4b20f48 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; + +#pragma warning disable ASPIREATS001 // AspireExport APIs are experimental + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A Kubernetes manifest applied to a Kind cluster via kubectl apply. +/// +/// The name of the resource. +/// +/// Absolute path to a Kubernetes manifest file or directory of manifest files, +/// or <inline> for manifests provided via standard input. +/// +/// The parent Kind cluster resource. +[AspireExport(ExposeProperties = true)] +public class K8sManifestResource(string name, string manifestPath, KindClusterResource parent) + : KindDeployedResource(name, parent) +{ + internal const string InlineManifestPath = ""; + + /// + /// Gets the manifest path passed to kubectl apply. + /// Accepts a file path, a directory path, or <inline> for standard input. + /// + public string ManifestPath { get; } = manifestPath ?? throw new ArgumentNullException(nameof(manifestPath)); + + /// + /// Gets or sets whether this resource represents a Kustomize overlay directory. + /// + public bool IsKustomize { get; set; } + + /// + /// Gets or sets inline manifest content applied with kubectl apply -f -. + /// + public string? InlineContent { get; set; } + + /// + /// Gets or sets whether to recursively apply manifests in subdirectories + /// (maps to kubectl apply --recursive). + /// Only meaningful when is a directory. + /// + public bool Recursive { get; set; } + + /// + /// Gets or sets whether to apply the manifest server-side + /// (maps to kubectl apply --server-side). + /// Server-side apply is required for large CRDs that exceed the client-side annotation size limit. + /// + public bool ServerSide { get; set; } + + /// + /// Gets or sets whether to force conflicts on server-side apply + /// (maps to kubectl apply --server-side --force-conflicts). + /// Only meaningful when is . + /// + public bool ForceConflicts { get; set; } + + /// + /// Gets the field manager name used with server-side apply + /// (maps to kubectl apply --field-manager). + /// When , kubectl uses its default (kubectl). + /// + public string? FieldManager { get; set; } + + /// + /// Gets or sets the maximum time to wait for kubectl apply to complete. + /// + public TimeSpan ApplyTimeout { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the maximum time to wait for applied CRDs to reach the Established condition. + /// + public TimeSpan CrdWaitTimeout { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets how CRD wait failures are handled. + /// + public CrdWaitBehavior CrdWaitBehavior { get; set; } = CrdWaitBehavior.Fail; +} + +#pragma warning restore ASPIREATS001 diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs index 027fc121d..cfb479ea9 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs @@ -174,6 +174,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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs new file mode 100644 index 000000000..bf21d131a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs @@ -0,0 +1,252 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Kind; +using Microsoft.Extensions.DependencyInjection; + +#pragma warning disable ASPIREATS001 // AspireExport APIs are experimental + +namespace Aspire.Hosting; + +/// +/// Extension methods for adding Kubernetes manifest resources to Kind clusters. +/// +public static class KindManifestResourceBuilderExtensions +{ + /// + /// Adds a Kubernetes manifest to be applied to the Kind cluster via kubectl apply. + /// + /// The Kind cluster resource builder. + /// The name of the manifest resource. + /// + /// Absolute or relative path to a Kubernetes manifest file, a directory of manifest files, + /// or a Kustomize overlay directory. Relative paths are resolved against the AppHost project directory. + /// URL fetch is not supported; reference a local file or directory. + /// + /// A reference to the . + /// + /// The manifest is applied after the parent Kind cluster reaches the + /// state. Downstream resources that call + /// WaitFor on the manifest resource only start once kubectl apply succeeds. + /// Requires kubectl on PATH. + /// + [AspireExport] + public static IResourceBuilder AddManifest( + this IResourceBuilder builder, + [ResourceName] string name, + string manifestPath) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(manifestPath); + + var absolutePath = System.IO.Path.IsPathRooted(manifestPath) + ? manifestPath + : System.IO.Path.GetFullPath( + System.IO.Path.Combine(builder.ApplicationBuilder.AppHostDirectory, manifestPath)); + + var resource = new K8sManifestResource(name, absolutePath, builder.Resource); + + return AddManifestResource(builder, resource); + } + + /// + /// Adds Kubernetes manifest content to be applied to the Kind cluster via kubectl apply -f -. + /// + /// The Kind cluster resource builder. + /// The name of the manifest resource. + /// The Kubernetes manifest YAML content. + /// A reference to the . + [AspireExport] + public static IResourceBuilder AddManifestFromContent( + this IResourceBuilder builder, + [ResourceName] string name, + string content) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(content); + + var resource = new K8sManifestResource(name, K8sManifestResource.InlineManifestPath, builder.Resource) + { + InlineContent = content, + }; + + return AddManifestResource(builder, resource); + } + + private static IResourceBuilder AddManifestResource( + IResourceBuilder builder, + K8sManifestResource resource) + { + var resourceBuilder = builder.ApplicationBuilder + .AddResource(resource) + .ExcludeFromManifest() + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "K8s Manifest", + State = KnownResourceStates.NotStarted, + Properties = [ + new("ManifestPath", resource.ManifestPath), + new("Mode", "apply"), + ] + }); + + resourceBuilder.OnInitializeResource(async (resource, e, ct) => + { + var notifications = e.Notifications; + var loggerService = e.Services.GetRequiredService(); + var logger = loggerService.GetLogger(resource); + + // Wait for the parent Kind cluster to be running before applying the manifest. + await notifications.WaitForResourceAsync(resource.Parent.Name, KnownResourceStates.Running, ct); + + await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, e.Services), ct); + + await notifications.PublishUpdateAsync(resource, + state => state with { State = KnownResourceStates.Starting }); + + try + { + var processRunner = e.Services.GetRequiredService(); + var kubectlManager = new KubectlManager(processRunner); + await kubectlManager.ApplyAsync(resource, logger, ct); + + await notifications.PublishUpdateAsync(resource, + state => state with + { + State = KnownResourceStates.Running, + Properties = [ + new("ManifestPath", resource.ManifestPath), + new("Namespace", resource.Namespace ?? "(default)"), + new("ServerSide", resource.ServerSide.ToString()), + new("Mode", resource.IsKustomize ? "kustomize" : "apply"), + ] + }); + } + catch (Exception) + { + await notifications.PublishUpdateAsync(resource, + state => state with { State = KnownResourceStates.FailedToStart }); + throw; + } + }); + + return resourceBuilder; + } + + /// + /// Recursively applies manifests from subdirectories (maps to kubectl apply --recursive). + /// Only meaningful when the manifest path is a directory. + /// + /// The manifest resource builder. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithRecursive( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Resource.Recursive = true; + return builder; + } + + /// + /// Applies the manifest server-side (maps to kubectl apply --server-side). + /// Required for large CRDs that exceed the client-side annotation size limit. + /// + /// The manifest resource builder. + /// + /// When , also passes --force-conflicts to override field ownership + /// held by another field manager (e.g., a controller). Defaults to . + /// + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithServerSideApply( + this IResourceBuilder builder, + bool forceConflicts = false) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Resource.ServerSide = true; + builder.Resource.ForceConflicts = forceConflicts; + return builder; + } + + /// + /// Sets the field manager name passed to kubectl apply --field-manager. + /// + /// + /// The flag is passed whenever this method is used, but it is primarily meaningful with + /// server-side apply because Kubernetes records managed fields for server-side operations. + /// + /// The manifest resource builder. + /// The field manager name. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithFieldManager( + this IResourceBuilder builder, + string fieldManager) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(fieldManager); + + builder.Resource.FieldManager = fieldManager; + return builder; + } + + /// + /// Sets the maximum time to wait for kubectl apply to complete. + /// + /// The manifest resource builder. + /// The apply timeout. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithApplyTimeout( + this IResourceBuilder builder, + TimeSpan timeout) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Resource.ApplyTimeout = KubectlTimeouts.Normalize(timeout, nameof(timeout)); + return builder; + } + + /// + /// Sets the maximum time to wait for applied CRDs to reach the Established condition. + /// + /// The manifest resource builder. + /// The CRD wait timeout. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithCrdWaitTimeout( + this IResourceBuilder builder, + TimeSpan timeout) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Resource.CrdWaitTimeout = KubectlTimeouts.Normalize(timeout, nameof(timeout)); + return builder; + } + + /// + /// Sets whether CRD wait failures fail the manifest resource or are logged as best-effort warnings. + /// + /// The manifest resource builder. + /// The CRD wait behavior. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithCrdWaitBehavior( + this IResourceBuilder builder, + CrdWaitBehavior behavior) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Resource.CrdWaitBehavior = behavior; + return builder; + } + +} + +#pragma warning restore ASPIREATS001 diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs new file mode 100644 index 000000000..b64704e5a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs @@ -0,0 +1,379 @@ +// 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; +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +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 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; + + /// + /// Waits for the cluster API to answer, then applies the manifest via kubectl apply. + /// + public async Task ApplyAsync(K8sManifestResource resource, ILogger logger, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + await WaitForClusterInfoAsync(resource, logger, cancellationToken).ConfigureAwait(false); + + var args = CreateApplyArguments(resource); + + if (resource.Recursive && resource.IsKustomize) + { + logger.LogWarning( + "Ignoring recursive apply for Kustomize manifest '{ManifestPath}' because kubectl apply -k does not support --recursive.", + resource.ManifestPath); + } + else if (resource.Recursive && resource.InlineContent is not null) + { + logger.LogWarning( + "Ignoring recursive apply for inline manifest '{ManifestPath}' because kubectl apply -f - does not support --recursive.", + resource.ManifestPath); + } + + logger.LogInformation( + "Applying manifest '{ManifestPath}' to cluster '{ClusterName}'...", + resource.ManifestPath, resource.Parent.Name); + + ProcessResult result; + var applyTimeout = KubectlTimeouts.Normalize(resource.ApplyTimeout, nameof(resource.ApplyTimeout)); + using (var applyCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + applyCts.CancelAfter(applyTimeout); + + try + { + result = await processRunner.RunAsync( + logger, + "kubectl", + 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}': {result.Error}"); + } + + var crdNames = GetAppliedCrdNames(result.Output); + if (crdNames.Count > 0) + { + await WaitForCrdsAsync(crdNames, resource, logger, cancellationToken).ConfigureAwait(false); + } + + logger.LogInformation( + "Manifest '{ManifestPath}' applied successfully.", resource.ManifestPath); + } + + /// + /// Waits for applied CRDs to reach the Kubernetes Established condition. + /// + internal async Task WaitForCrdsAsync( + IEnumerable crdNames, + K8sManifestResource resource, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(crdNames); + ArgumentNullException.ThrowIfNull(resource); + + var crds = crdNames.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (crds.Length == 0) + { + return; + } + + var args = CreateWaitArguments(crds, resource.Parent.KubeconfigPath, resource.CrdWaitTimeout); + + logger.LogInformation( + "Waiting for {CrdCount} custom resource definition(s) to become Established...", + crds.Length); + + var result = await processRunner.RunAsync( + logger, + "kubectl", + args, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.ExitCode != 0) + { + var message = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error; + if (resource.CrdWaitBehavior == CrdWaitBehavior.BestEffort) + { + logger.LogWarning( + "Timed out or failed while waiting for custom resource definition(s) to become Established: {Error}", + message); + return; + } + + throw new InvalidOperationException( + $"Timed out or failed while waiting for custom resource definition(s) to become Established: {message}"); + } + } + + /// + /// Creates the kubectl apply argument list for a manifest resource. + /// + internal static IReadOnlyList CreateApplyArguments(K8sManifestResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + var isKustomize = resource.InlineContent is null && + Directory.Exists(resource.ManifestPath) && + IsKustomizeDirectory(resource.ManifestPath); + resource.IsKustomize = isKustomize; + + List arguments = + [ + "apply", + ]; + + if (resource.InlineContent is not null) + { + arguments.Add("-f"); + arguments.Add("-"); + } + else if (isKustomize) + { + arguments.Add("-k"); + arguments.Add(resource.ManifestPath); + } + else + { + arguments.Add("-f"); + arguments.Add(resource.ManifestPath); + } + + arguments.Add($"--kubeconfig={resource.Parent.KubeconfigPath}"); + + if (!string.IsNullOrEmpty(resource.Namespace)) + { + arguments.Add("--namespace"); + arguments.Add(resource.Namespace); + } + + if (resource.Recursive && !isKustomize && resource.InlineContent is null) + { + arguments.Add("--recursive"); + } + + if (resource.ServerSide) + { + arguments.Add("--server-side"); + + if (resource.ForceConflicts) + { + arguments.Add("--force-conflicts"); + } + } + + if (!string.IsNullOrEmpty(resource.FieldManager)) + { + arguments.Add("--field-manager"); + arguments.Add(resource.FieldManager); + } + + return arguments; + } + + /// + /// Creates the kubectl wait argument list for applied CRDs. + /// + internal static IReadOnlyList CreateWaitArguments( + IEnumerable crdNames, + string kubeconfigPath, + TimeSpan timeout) + { + ArgumentNullException.ThrowIfNull(crdNames); + ArgumentNullException.ThrowIfNull(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) + { + ArgumentNullException.ThrowIfNull(kubeconfigPath); + + return + [ + "cluster-info", + $"--kubeconfig={kubeconfigPath}", + ]; + } + + /// + /// Returns whether the directory contains a Kustomize marker file recognized by kubectl apply -k. + /// + internal static bool IsKustomizeDirectory(string directory) + { + ArgumentNullException.ThrowIfNull(directory); + + if (!Directory.Exists(directory)) + { + return false; + } + + return Directory.EnumerateFiles(directory, "*", SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .Any(fileName => + string.Equals(fileName, "kustomization.yaml", StringComparison.OrdinalIgnoreCase) || + string.Equals(fileName, "kustomization.yml", StringComparison.OrdinalIgnoreCase) || + string.Equals(fileName, "kustomization", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Waits until the cluster API server is reachable through kubectl cluster-info. + /// + internal async Task WaitForClusterInfoAsync( + K8sManifestResource resource, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + var args = CreateClusterInfoArguments(resource.Parent.KubeconfigPath); + var nextDelay = ClusterInfoInitialDelay; + var stopwatch = Stopwatch.StartNew(); + + while (true) + { + if (stopwatch.Elapsed >= _clusterInfoMaxWait) + { + throw new InvalidOperationException( + $"Timed out waiting for cluster '{resource.Parent.Name}' API to become reachable."); + } + + var remaining = _clusterInfoMaxWait - stopwatch.Elapsed; + var probeTimeout = Min(remaining, _clusterInfoProbeTimeout); + ProcessResult result; + using (var probeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + probeCts.CancelAfter(probeTimeout); + + try + { + result = await processRunner.RunAsync( + logger, + "kubectl", + args, + cancellationToken: probeCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + if (stopwatch.Elapsed >= _clusterInfoMaxWait) + { + throw new InvalidOperationException( + $"Timed out waiting for cluster '{resource.Parent.Name}' API to become reachable."); + } + + result = new ProcessResult(1, "", "kubectl cluster-info probe timed out."); + } + } + + if (result.ExitCode == 0) + { + return; + } + + if (stopwatch.Elapsed >= _clusterInfoMaxWait) + { + throw new InvalidOperationException( + $"Timed out waiting for cluster '{resource.Parent.Name}' API to become reachable: " + + (string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error)); + } + + remaining = _clusterInfoMaxWait - stopwatch.Elapsed; + var delay = Min(nextDelay, remaining); + if (delay <= TimeSpan.Zero) + { + throw new InvalidOperationException( + $"Timed out waiting for cluster '{resource.Parent.Name}' API to become reachable: " + + (string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error)); + } + + logger.LogWarning( + "Cluster '{ClusterName}' API is not reachable yet; retrying kubectl cluster-info in {DelaySeconds:n1}s. Last error: {Error}", + resource.Parent.Name, + delay.TotalSeconds, + string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error); + + await _delayAsync(delay, cancellationToken).ConfigureAwait(false); + nextDelay = TimeSpan.FromSeconds(Math.Min(nextDelay.TotalSeconds * 2, ClusterInfoMaxDelay.TotalSeconds)); + } + } + + private static TimeSpan Min(TimeSpan left, TimeSpan right) + { + return left <= right ? left : right; + } + + /// + /// 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]; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs new file mode 100644 index 000000000..38229e64e --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace CommunityToolkit.Aspire.Hosting.Kind; + +internal static class KubectlTimeouts +{ + internal static readonly TimeSpan MaximumTimeout = TimeSpan.FromHours(1); + + internal static TimeSpan Normalize(TimeSpan timeout, string parameterName) + { + if (timeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(parameterName, timeout, "Timeout must be greater than zero."); + } + + if (timeout > MaximumTimeout) + { + throw new ArgumentOutOfRangeException(parameterName, timeout, $"Timeout must be less than or equal to {MaximumTimeout}."); + } + + return TimeSpan.FromSeconds(Math.Ceiling(timeout.TotalSeconds)); + } + + internal static int ToSeconds(TimeSpan timeout, string parameterName) + { + return (int)Normalize(timeout, parameterName).TotalSeconds; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md index 1cb74338b..fac1f7328 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 @@ -153,6 +154,71 @@ var redis = cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicha .WithNamespace("cache"); ``` +### Applying raw manifests to the cluster + +Use `AddManifest` to apply a Kubernetes manifest (file, directory, or Kustomize overlay) to the cluster after it becomes healthy. This runs `kubectl apply -f --kubeconfig ` against the cluster kubeconfig and is the natural equivalent of `AddHelmChart` for scenarios where a chart would be overkill. Relative paths are resolved against the AppHost project directory. + +`AddManifest` supports a single file, a directory, or a Kustomize overlay. URL fetch is not supported today — use a local file. For URL support, `curl` the file down as a build step and reference the local path. + +If the path is a directory containing a Kustomize marker file such as `kustomization.yaml`, `kustomization.yml`, `Kustomization`, or `Kustomization.yml`, Kind automatically switches to `kubectl apply -k ` at apply time. `WithRecursive()` is ignored for Kustomize overlays because `kubectl apply -k` does not support `--recursive`. + +```csharp +var cluster = builder.AddKindCluster("mycluster"); + +// Single file +cluster.AddManifest("crds", "./manifests/crds.yaml"); + +// Directory, recursive, into a namespace +cluster.AddManifest("platform", "./manifests/platform") + .WithRecursive() + .WithNamespace("platform"); + +// Server-side apply with a stable field manager (useful for CRDs and controllers +// that reconcile large objects) +cluster.AddManifest("operator", "./manifests/argocd/install.yaml") + .WithServerSideApply(forceConflicts: true) + .WithFieldManager("aspire-apphost"); + +// Inline content, applied via kubectl apply -f - +cluster.AddManifestFromContent("demo-ns", """ + apiVersion: v1 + kind: Namespace + metadata: + name: aspire-demo + """); +``` + +Downstream resources can wait on the manifest resource before starting so they only see the cluster after the manifests have been applied: + +```csharp +var crds = cluster.AddManifest("crds", "./manifests/crds.yaml"); + +var operatorContainer = builder.AddContainer("my-operator", "my-org/operator") + .WithReference(cluster) + .WaitFor(crds); +``` + +Manifests persist with the cluster - deleted with session clusters, retained with persistent clusters. + +When `kubectl apply` reports custom resource definitions, Kind waits up to 5 minutes for those CRDs to reach the `Established` condition before marking the manifest resource running. The default behavior is fail-fast: a CRD wait timeout fails the manifest resource. Use `.WithCrdWaitTimeout(...)` to adjust the timeout, or `.WithCrdWaitBehavior(CrdWaitBehavior.BestEffort)` to log a warning and continue. + +#### Namespace behavior + +`.WithNamespace(ns)` passes `--namespace ` to `kubectl apply`, but it does **not** create the namespace. This differs from `AddHelmChart`, which passes `--create-namespace`. + +If a manifest declares its own `metadata.namespace` that conflicts with `--namespace`, `kubectl` rejects the apply. Prefer one of these patterns: + +- Create the namespace with a separate `AddManifest("ns", "namespace.yaml")` and `.WaitFor()` it before applying namespaced manifests. +- Omit `.WithNamespace()` and let each manifest declare its own namespace. + +#### WithServerSideApply + +> **Warning:** `forceConflicts: true` overrides field ownership held by other controllers. Use sparingly — you're telling `kubectl` to overwrite whatever another controller (for example, Helm) had set for those fields. + +#### Security scope + +Manifests apply with the cluster kubeconfig, which for a local Kind cluster is typically cluster-admin. `AddManifest` can create cluster-scoped resources such as `ClusterRole`, `ClusterRoleBinding`, and CRDs. Only apply manifests you trust. + ### Full F5 example ```csharp @@ -235,9 +301,28 @@ builder.AddContainer("my-container", "my-image") | `WithReference(kind)` | Injects `KUBECONFIG` and `K8S_CLUSTER_NAME` into another resource | | `WithKindNetwork()` | Connects a container to the Kind container network | | `AddHelmChart(name, chartRef)` | Deploys a Helm chart to the Kind cluster during F5 | +| `AddManifest(name, manifestPath)` | Applies a Kubernetes manifest (file or directory) via `kubectl apply` after the cluster is healthy | +| `AddManifestFromContent(name, content)` | Applies inline Kubernetes manifest content via `kubectl apply -f -` after the cluster is healthy | +| `WithRecursive()` | For `AddManifest`: recurse into subdirectories when applying | +| `WithServerSideApply(bool forceConflicts = false)` | For `AddManifest`: use server-side apply, optionally with `--force-conflicts` | +| `WithFieldManager(string)` | For `AddManifest`: set the `kubectl apply --field-manager` identifier | +| `WithApplyTimeout(TimeSpan)` | For `AddManifest`: set the maximum time for `kubectl apply` | +| `WithCrdWaitTimeout(TimeSpan)` | For `AddManifest`: set the CRD `Established` wait timeout | +| `WithCrdWaitBehavior(CrdWaitBehavior)` | For `AddManifest`: choose fail-fast or best-effort CRD wait behavior | | `WithKind()` | Configures a `KubernetesEnvironmentResource` to deploy to a local Kind cluster (scenario 2) | + +## Security & scope notes + +- Relative paths are resolved against the AppHost directory but are not sandboxed; do not apply untrusted manifests. +- `AddManifestFromContent(string)` holds stdin content in memory. For very large manifests, use `AddManifest(file)` instead. +- `AddManifest` runs with whatever authority the cluster kubeconfig has — cluster-admin on a default Kind cluster. + ## Additional information - [Kind documentation](https://kind.sigs.k8s.io/) - [.NET Aspire documentation](https://learn.microsoft.com/dotnet/aspire) - [Aspire Community Toolkit](https://github.com/CommunityToolkit/Aspire) +- [`kubectl apply` documentation](https://kubernetes.io/docs/reference/kubectl/generated/kubectl_apply/) +- [Kubernetes server-side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/) +- [Kustomize documentation](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/) +- [CRD Established condition](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs index 05db84cd2..eddbb2c91 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs @@ -6,6 +6,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Lifecycle; using CommunityToolkit.Aspire.Testing; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace CommunityToolkit.Aspire.Hosting.Kind.Tests; @@ -533,6 +534,38 @@ public async Task DefaultProcessRunner_InvalidCommand_NonZeroExitCode() Assert.NotEqual(0, result.ExitCode); } + [Fact] + public async Task DefaultProcessRunner_RedactsKubeconfigPathInDebugLog() + { + var runner = new DefaultProcessRunner(); + var logger = new CapturingLogger(); + const string kubeconfigPath = "C:\\Users\\tamirdresher\\.kube\\kind-config.yaml"; + + _ = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? await runner.RunAsync(logger, "cmd", ["/c", "echo", "hello", $"--kubeconfig={kubeconfigPath}"]) + : await runner.RunAsync(logger, "sh", ["-c", "echo hello", $"--kubeconfig={kubeconfigPath}"]); + + var executingMessage = Assert.Single(logger.Messages, message => message.StartsWith("Executing:", StringComparison.Ordinal)); + Assert.DoesNotContain(kubeconfigPath, executingMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains("--kubeconfig=[REDACTED]", executingMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task DefaultProcessRunner_RedactsSpaceSeparatedKubeconfigPathInDebugLog() + { + var runner = new DefaultProcessRunner(); + var logger = new CapturingLogger(); + const string kubeconfigPath = "C:\\Users\\tamirdresher\\.kube\\kind-config.yaml"; + + _ = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? await runner.RunAsync(logger, "cmd", ["/c", "echo", "hello", "--kubeconfig", kubeconfigPath]) + : await runner.RunAsync(logger, "sh", ["-c", "echo hello", "--kubeconfig", kubeconfigPath]); + + var executingMessage = Assert.Single(logger.Messages, message => message.StartsWith("Executing:", StringComparison.Ordinal)); + Assert.DoesNotContain(kubeconfigPath, executingMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains("--kubeconfig [REDACTED]", executingMessage, StringComparison.Ordinal); + } + // ── Edge-case tests ────────────────────────────────────────────────── [Fact] @@ -553,6 +586,7 @@ public async Task WithWorkerNodes_Zero_IsValid() Assert.Contains("- role: control-plane", yaml); Assert.DoesNotContain("- role: worker", yaml); } + finally { File.Delete(configPath); @@ -570,4 +604,24 @@ public void KindClusterResource_Constructor_SetsPathsCorrectly() } private sealed class TestResource(string name) : Resource(name), IResourceWithEnvironment; + + 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)); + } + } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs index cc7f02d20..136a1521b 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs @@ -19,31 +19,44 @@ internal sealed class FakeProcessRunner : IProcessRunner public ProcessResult NextResult { get; set; } = new(0, "", ""); public Queue Results { get; } = new(); public Dictionary ResultsByFileName { get; } = []; + public TimeSpan Delay { get; set; } + public Queue Delays { get; } = new(); - public Task RunAsync( + public async Task RunAsync( ILogger logger, string fileName, IReadOnlyList arguments, string? workingDirectory = null, IReadOnlyDictionary? environmentVariables = null, + string? standardInput = null, CancellationToken cancellationToken = default) { + ProcessResult result; + TimeSpan delay; lock (_lock) { - Commands.Add(new(fileName, string.Join(" ", arguments), workingDirectory, environmentVariables)); + Commands.Add(new(fileName, string.Join(" ", arguments), workingDirectory, environmentVariables, standardInput)); - return Task.FromResult( - ResultsByFileName.TryGetValue(fileName, out var result) - ? result + result = ResultsByFileName.TryGetValue(fileName, out var fileResult) + ? fileResult : Results.Count > 0 ? Results.Dequeue() - : NextResult); + : NextResult; + delay = Delays.Count > 0 ? Delays.Dequeue() : Delay; } + + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, cancellationToken); + } + + return result; } internal sealed record ExecutedCommand( string FileName, string Arguments, string? WorkingDirectory, - IReadOnlyDictionary? EnvironmentVariables); + IReadOnlyDictionary? EnvironmentVariables, + string? StandardInput); } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs new file mode 100644 index 000000000..6293562fc --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs @@ -0,0 +1,913 @@ +// 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; + +namespace CommunityToolkit.Aspire.Hosting.Kind.Tests; + +public class KindManifestTests +{ + [Fact] + public void AddManifestCreatesResource() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", "./manifests/crds.yaml"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("crds", resource.Name); + Assert.True(Path.IsPathRooted(resource.ManifestPath)); + Assert.EndsWith(Path.Combine("manifests", "crds.yaml"), resource.ManifestPath, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AddManifestSetsParent() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", "./manifests/crds.yaml"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var manifestResource = Assert.Single(appModel.Resources.OfType()); + var clusterResource = Assert.Single(appModel.Resources.OfType()); + Assert.Same(clusterResource, manifestResource.Parent); + } + + [Fact] + public void AddManifestFromContentCreatesResource() + { + const string content = "apiVersion: v1\nkind: Namespace\nmetadata:\n name: aspire-demo"; + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifestFromContent("demo-ns", content); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("demo-ns", resource.Name); + Assert.Equal(K8sManifestResource.InlineManifestPath, resource.ManifestPath); + Assert.Equal(content, resource.InlineContent); + Assert.False(resource.IsKustomize); + } + + [Fact] + public void AddManifestThrowsOnNullBuilder() + { + IResourceBuilder builder = null!; + Assert.Throws(() => builder.AddManifest("crds", "./crds.yaml")); + } + + [Fact] + public void AddManifestThrowsOnNullName() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + Assert.Throws(() => cluster.AddManifest(null!, "./crds.yaml")); + } + + [Fact] + public void AddManifestThrowsOnNullManifestPath() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + Assert.Throws(() => cluster.AddManifest("crds", null!)); + } + + [Fact] + public void AddManifestFromContentThrowsOnNullBuilder() + { + IResourceBuilder builder = null!; + Assert.Throws(() => builder.AddManifestFromContent("crds", "apiVersion: v1")); + } + + [Fact] + public void AddManifestFromContentThrowsOnNullName() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + Assert.Throws(() => cluster.AddManifestFromContent(null!, "apiVersion: v1")); + } + + [Fact] + public void AddManifestFromContentThrowsOnNullContent() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + Assert.Throws(() => cluster.AddManifestFromContent("crds", null!)); + } + + [Fact] + public void AddManifestUsesAppHostRelativePath() + { + var builder = DistributedApplication.CreateBuilder(); + var relativePath = Path.Combine("manifests", "crds.yaml"); + var expected = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, relativePath)); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", relativePath); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(expected, resource.ManifestPath); + } + + [Fact] + public void AddManifestUsesAbsolutePathAsIs() + { + var builder = DistributedApplication.CreateBuilder(); + var absolutePath = Path.Combine(AppContext.BaseDirectory, "manifests", "crds.yaml"); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", absolutePath); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(absolutePath, resource.ManifestPath); + } + + [Fact] + public void AddManifestDetectsKustomizationYaml() + { + var directory = CreateTestDirectory(); + + try + { + File.WriteAllText(Path.Combine(directory, "kustomization.yaml"), "resources: []"); + var resource = AddManifestAndGetResource(directory); + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.True(resource.IsKustomize); + Assert.Contains("-k", args); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void AddManifestDetectsKustomizationYml() + { + var directory = CreateTestDirectory(); + + try + { + File.WriteAllText(Path.Combine(directory, "kustomization.yml"), "resources: []"); + var resource = AddManifestAndGetResource(directory); + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.True(resource.IsKustomize); + Assert.Contains("-k", args); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void AddManifestOnDirectoryWithoutKustomization_IsNotKustomize() + { + var directory = CreateTestDirectory(); + + try + { + File.WriteAllText(Path.Combine(directory, "manifest.yaml"), "apiVersion: v1"); + var resource = AddManifestAndGetResource(directory); + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.False(resource.IsKustomize); + Assert.Contains("-f", args); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void WithRecursiveSetsRecursive() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("all", "./manifests") + .WithRecursive(); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.True(resource.Recursive); + } + + [Fact] + public void WithServerSideApplySetsServerSide() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", "./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() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", "./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() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", "./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() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", "./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 WithApplyTimeoutRejectsZero() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", "./crds.yaml") + .WithApplyTimeout(TimeSpan.Zero)); + } + + [Fact] + public void WithApplyTimeoutRejectsNegative() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", "./crds.yaml") + .WithApplyTimeout(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void WithApplyTimeoutRoundsSubSecondUpToOneSecond() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", "./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() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", "./crds.yaml") + .WithApplyTimeout(TimeSpan.MaxValue)); + } + + [Fact] + public void WithCrdWaitTimeoutSetsCrdWaitTimeout() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", "./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() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", "./crds.yaml") + .WithCrdWaitTimeout(TimeSpan.Zero)); + } + + [Fact] + public void WithCrdWaitTimeoutRejectsNegative() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", "./crds.yaml") + .WithCrdWaitTimeout(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void WithCrdWaitTimeoutRoundsSubSecondUpToOneSecond() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", "./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() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", "./crds.yaml") + .WithCrdWaitTimeout(TimeSpan.MaxValue)); + } + + [Fact] + public void WithCrdWaitBehaviorSetsCrdWaitBehavior() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", "./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 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); + } + + [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(); + 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_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) + { + var builder = DistributedApplication.CreateBuilder(); + 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; + } +} \ No newline at end of file From 7be3aacf0eaacea330a344d798b3fb5cbe7fdae2 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 29 Jul 2026 10:31:27 +0200 Subject: [PATCH 02/16] fix: make manifest cluster-ready timeout configurable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../K8sManifestResource.cs | 6 ++ .../KindManifestResourceBuilderExtensions.cs | 22 +++++- .../KindManifestTests.cs | 74 +++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs index 8d4b20f48..fc264f5e1 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs @@ -71,6 +71,12 @@ public class K8sManifestResource(string name, string manifestPath, KindClusterRe /// public TimeSpan ApplyTimeout { get; set; } = TimeSpan.FromMinutes(5); + /// + /// Gets or sets the maximum time to wait for the Kubernetes API to become reachable + /// before running kubectl apply. + /// + public TimeSpan ClusterReadyTimeout { get; set; } = TimeSpan.FromSeconds(60); + /// /// Gets or sets the maximum time to wait for applied CRDs to reach the Established condition. /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs index bf21d131a..1feddddbf 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs @@ -110,7 +110,9 @@ await notifications.PublishUpdateAsync(resource, try { var processRunner = e.Services.GetRequiredService(); - var kubectlManager = new KubectlManager(processRunner); + var kubectlManager = new KubectlManager( + processRunner, + clusterInfoMaxWait: resource.ClusterReadyTimeout); await kubectlManager.ApplyAsync(resource, logger, ct); await notifications.PublishUpdateAsync(resource, @@ -196,6 +198,24 @@ public static IResourceBuilder WithFieldManager( return builder; } + /// + /// Sets the maximum time to wait for the Kubernetes API to become reachable + /// before running kubectl apply. + /// + /// The manifest resource builder. + /// The cluster readiness timeout. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithClusterReadyTimeout( + this IResourceBuilder builder, + TimeSpan timeout) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Resource.ClusterReadyTimeout = KubectlTimeouts.Normalize(timeout, nameof(timeout)); + return builder; + } + /// /// Sets the maximum time to wait for kubectl apply to complete. /// diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs index 6293562fc..8d3403de4 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs @@ -282,6 +282,71 @@ public void WithApplyTimeoutSetsApplyTimeout() Assert.Equal(TimeSpan.FromSeconds(30), resource.ApplyTimeout); } + [Fact] + public void WithClusterReadyTimeoutSetsClusterReadyTimeout() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", "./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 WithClusterReadyTimeoutRejectsZero() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", "./crds.yaml") + .WithClusterReadyTimeout(TimeSpan.Zero)); + } + + [Fact] + public void WithClusterReadyTimeoutRejectsNegative() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", "./crds.yaml") + .WithClusterReadyTimeout(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void WithClusterReadyTimeoutRoundsSubSecondUpToOneSecond() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", "./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() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", "./crds.yaml") + .WithClusterReadyTimeout(TimeSpan.MaxValue)); + } + [Fact] public void WithApplyTimeoutRejectsZero() { @@ -449,6 +514,15 @@ public void DefaultApplyTimeoutIsFiveMinutes() 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() { From 5d81fe0d3df51e1e46f23fc104001ea66ff8b2e5 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 29 Jul 2026 10:31:38 +0200 Subject: [PATCH 03/16] fix: harden Helm installs for CRD races Add opt-in CRD wait retries and --set-string support for Helm chart values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../HelmManager.cs | 132 ++++++++++++-- .../KindHelmChartResource.cs | 9 + .../KindHelmChartResourceBuilderExtensions.cs | 46 +++++ .../KubectlManager.cs | 104 +++++++++++ .../KindHelmChartTests.cs | 172 ++++++++++++++++++ 5 files changed, 447 insertions(+), 16 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs index 950127c61..e94722232 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs @@ -9,33 +9,87 @@ namespace CommunityToolkit.Aspire.Hosting.Kind; /// /// Manages Helm chart deployments to a Kind cluster by orchestrating Helm CLI calls. /// -internal sealed class HelmManager(IProcessRunner processRunner) +internal sealed class HelmManager( + IProcessRunner processRunner, + Func? delayAsync = null, + KubectlManager? kubectlManager = null) { + private static readonly TimeSpan DefaultCrdWaitTimeout = TimeSpan.FromMinutes(5); + + private readonly Func _delayAsync = delayAsync ?? Task.Delay; + private readonly KubectlManager _kubectlManager = kubectlManager ?? new KubectlManager(processRunner, delayAsync); + /// /// Installs or upgrades the Helm release. /// public async Task InstallAsync(KindHelmChartResource resource, ILogger logger, CancellationToken cancellationToken) { var args = CreateInstallArguments(resource); + var maxAttempts = resource.CrdWaitRetryMaxAttempts; + IReadOnlySet knownCrds = maxAttempts > 1 + ? await TryGetCustomResourceDefinitionsAsync(resource, logger, cancellationToken).ConfigureAwait(false) + : new HashSet(StringComparer.OrdinalIgnoreCase); - logger.LogInformation( - "Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}'...", - resource.ChartRef, resource.ReleaseName, resource.Parent.Name); + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + logger.LogInformation( + "Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}' (attempt {Attempt}/{MaxAttempts})...", + resource.ChartRef, + resource.ReleaseName, + resource.Parent.Name, + attempt, + maxAttempts); - var result = await processRunner.RunAsync( - logger, - "helm", - args, - cancellationToken: cancellationToken).ConfigureAwait(false); + var result = await processRunner.RunAsync( + logger, + "helm", + args, + cancellationToken: cancellationToken).ConfigureAwait(false); - if (result.ExitCode != 0) - { - throw new InvalidOperationException( - $"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {result.Error}"); - } + if (result.ExitCode == 0) + { + logger.LogInformation( + "Helm release '{ReleaseName}' installed successfully.", resource.ReleaseName); + return; + } + + if (attempt >= maxAttempts || !ShouldRetryForCrdRace(result)) + { + throw new InvalidOperationException( + $"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {result.Error}"); + } + + var discoveredCrds = await TryGetCustomResourceDefinitionsAsync(resource, logger, cancellationToken).ConfigureAwait(false); + var newCrds = discoveredCrds + .Except(knownCrds, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (newCrds.Length == 0) + { + throw new InvalidOperationException( + $"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {result.Error}"); + } - logger.LogInformation( - "Helm release '{ReleaseName}' installed successfully.", resource.ReleaseName); + logger.LogWarning( + "Helm release '{ReleaseName}' failed before CRDs finished registering. Waiting for {CrdCount} CRD(s) before retrying.", + resource.ReleaseName, + newCrds.Length); + + await _kubectlManager.WaitForCrdsAsync( + newCrds, + resource.Parent.KubeconfigPath, + DefaultCrdWaitTimeout, + logger, + cancellationToken).ConfigureAwait(false); + + knownCrds = discoveredCrds; + var backoff = ComputeRetryBackoff(resource.CrdWaitRetryBackoff, attempt); + logger.LogInformation( + "Retrying Helm release '{ReleaseName}' in {DelaySeconds:n1}s.", + resource.ReleaseName, + backoff.TotalSeconds); + await _delayAsync(backoff, cancellationToken).ConfigureAwait(false); + } } internal static IReadOnlyList CreateInstallArguments(KindHelmChartResource resource) @@ -70,6 +124,12 @@ internal static IReadOnlyList CreateInstallArguments(KindHelmChartResour arguments.Add($"{key}={value}"); } + foreach (var (key, value) in resource.StringValues) + { + arguments.Add("--set-string"); + arguments.Add($"{key}={value}"); + } + foreach (string valuesFile in resource.ValuesFiles) { arguments.Add("-f"); @@ -78,4 +138,44 @@ internal static IReadOnlyList CreateInstallArguments(KindHelmChartResour return arguments; } + + internal static TimeSpan ComputeRetryBackoff(TimeSpan initialBackoff, int failureCount) + { + ArgumentOutOfRangeException.ThrowIfLessThan(failureCount, 1); + + var multiplier = 1 << (failureCount - 1); + var scaledTicks = initialBackoff.Ticks * multiplier; + return scaledTicks >= TimeSpan.MaxValue.Ticks + ? TimeSpan.MaxValue + : TimeSpan.FromTicks(scaledTicks); + } + + private static bool ShouldRetryForCrdRace(ProcessResult result) + { + var combined = string.Concat(result.Error, "\n", result.Output); + return combined.Contains("no matches for kind", StringComparison.OrdinalIgnoreCase) + || combined.Contains("ensure CRDs are installed first", StringComparison.OrdinalIgnoreCase); + } + + private async Task> TryGetCustomResourceDefinitionsAsync( + KindHelmChartResource resource, + ILogger logger, + CancellationToken cancellationToken) + { + try + { + return await _kubectlManager.GetCustomResourceDefinitionsAsync( + resource.Parent.KubeconfigPath, + logger, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogDebug( + ex, + "Unable to snapshot CRDs for Helm release '{ReleaseName}'.", + resource.ReleaseName); + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs index e363579a1..a469c5765 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs @@ -41,4 +41,13 @@ public class KindHelmChartResource(string name, string chartRef, KindClusterReso /// Gets the paths to values files (each maps to -f path). /// public List ValuesFiles { get; } = []; + + /// + /// Gets the inline Helm values that must be applied with --set-string key=value. + /// + public Dictionary StringValues { get; } = []; + + internal int CrdWaitRetryMaxAttempts { get; set; } = 1; + + internal TimeSpan CrdWaitRetryBackoff { get; set; } = TimeSpan.FromSeconds(5); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs index cfb479ea9..711c75e86 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs @@ -153,6 +153,52 @@ public static IResourceBuilder WithHelmValue( 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 . + [AspireExport] + public static IResourceBuilder WithHelmStringValue( + this IResourceBuilder builder, + string key, + string value) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(value); + + builder.Resource.StringValues[key] = value; + return builder; + } + + /// + /// Retries Helm installs that race newly-created CRDs which have not reached the + /// Established condition yet. + /// + /// The Helm chart resource builder. + /// The total number of install attempts. Must be 2 or greater. + /// + /// The initial delay before retrying. Later retries back off exponentially. + /// When , Kind uses a 5 second initial backoff. + /// + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithCrdWaitRetry( + this IResourceBuilder builder, + int maxAttempts = 3, + TimeSpan? backoff = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentOutOfRangeException.ThrowIfLessThan(maxAttempts, 2); + + builder.Resource.CrdWaitRetryMaxAttempts = maxAttempts; + builder.Resource.CrdWaitRetryBackoff = KubectlTimeouts.Normalize(backoff ?? TimeSpan.FromSeconds(5), nameof(backoff)); + return builder; + } + /// /// Adds a values file (maps to helm install -f path). /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs index b64704e5a..dc07f54dd 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs @@ -138,6 +138,37 @@ internal async Task WaitForCrdsAsync( } } + internal Task WaitForCrdsAsync( + IEnumerable crdNames, + string kubeconfigPath, + TimeSpan timeout, + ILogger logger, + CancellationToken cancellationToken) => + WaitForCrdsCoreAsync(crdNames, kubeconfigPath, timeout, logger, cancellationToken, bestEffort: false); + + internal async Task> GetCustomResourceDefinitionsAsync( + string kubeconfigPath, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(kubeconfigPath); + + var result = await processRunner.RunAsync( + logger, + "kubectl", + 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. /// @@ -240,6 +271,20 @@ internal static IReadOnlyList CreateClusterInfoArguments(string kubeconf ]; } + internal static IReadOnlyList CreateGetCrdsArguments(string kubeconfigPath) + { + ArgumentNullException.ThrowIfNull(kubeconfigPath); + + return + [ + "get", + "crd", + "-o", + "name", + $"--kubeconfig={kubeconfigPath}", + ]; + } + /// /// Returns whether the directory contains a Kustomize marker file recognized by kubectl apply -k. /// @@ -376,4 +421,63 @@ private static IReadOnlyList GetAppliedCrdNames(string output) return [.. crds]; } + + private async Task WaitForCrdsCoreAsync( + IEnumerable crdNames, + string kubeconfigPath, + TimeSpan timeout, + ILogger logger, + CancellationToken cancellationToken, + bool bestEffort) + { + ArgumentNullException.ThrowIfNull(crdNames); + ArgumentException.ThrowIfNullOrEmpty(kubeconfigPath); + + var crds = crdNames.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (crds.Length == 0) + { + return; + } + + var args = CreateWaitArguments(crds, kubeconfigPath, timeout); + + logger.LogInformation( + "Waiting for {CrdCount} custom resource definition(s) to become Established...", + crds.Length); + + var result = await processRunner.RunAsync( + logger, + "kubectl", + args, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.ExitCode == 0) + { + return; + } + + var message = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error; + if (bestEffort) + { + logger.LogWarning( + "Timed out or failed while waiting for custom resource definition(s) to become Established: {Error}", + message); + return; + } + + throw new InvalidOperationException( + $"Timed out or failed while waiting for custom resource definition(s) to become Established: {message}"); + } + + private static IReadOnlySet ParseResourceNames(string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + return output + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs index ed3a3e16f..ef4f70f9b 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs @@ -3,6 +3,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Logging; namespace CommunityToolkit.Aspire.Hosting.Kind.Tests; @@ -84,6 +85,79 @@ public void WithHelmValueAddsValue() Assert.Equal("false", resource.Values["auth.enabled"]); } + [Fact] + public void WithHelmStringValueAddsStringValue() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithHelmStringValue("auth.password", "000123") + .WithHelmStringValue("feature.flag", "false"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(2, resource.StringValues.Count); + Assert.Equal("000123", resource.StringValues["auth.password"]); + Assert.Equal("false", resource.StringValues["feature.flag"]); + } + + [Fact] + public void WithCrdWaitRetrySetsRetryConfiguration() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithCrdWaitRetry(maxAttempts: 3, backoff: TimeSpan.FromSeconds(7)); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(3, resource.CrdWaitRetryMaxAttempts); + Assert.Equal(TimeSpan.FromSeconds(7), resource.CrdWaitRetryBackoff); + } + + [Fact] + public void WithCrdWaitRetryUsesDefaultBackoff() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithCrdWaitRetry(); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(3, resource.CrdWaitRetryMaxAttempts); + Assert.Equal(TimeSpan.FromSeconds(5), resource.CrdWaitRetryBackoff); + } + + [Fact] + public void WithCrdWaitRetryRejectsLessThanTwoAttempts() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddHelmChart("redis", "chart/ref").WithCrdWaitRetry(1, TimeSpan.FromSeconds(5))); + } + + [Fact] + public void WithCrdWaitRetryRejectsInvalidBackoff() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddHelmChart("redis", "chart/ref").WithCrdWaitRetry(3, TimeSpan.Zero)); + } + [Fact] public void WithHelmValuesFileAddsPath() { @@ -144,6 +218,7 @@ public void ValuesAndValuesFilesStartEmpty() var resource = new KindHelmChartResource("redis", "chart/ref", cluster); Assert.Empty(resource.Values); + Assert.Empty(resource.StringValues); Assert.Empty(resource.ValuesFiles); } @@ -173,6 +248,7 @@ public void FluentApiChainingWorks() cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") .WithChartVersion("20.0.0") .WithHelmValue("replica.replicaCount", "2") + .WithHelmStringValue("auth.password", "000123") .WithHelmValuesFile("./values.yaml") .WithNamespace("cache"); @@ -182,6 +258,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 +274,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 +293,84 @@ public void CreateInstallArgumentsPreservesArgumentBoundaries() "--create-namespace", "--set", "annotations.description=My \"Redis\" App", + "--set-string", + "auth.password=000123", "-f", @"C:\temp path\values file.yaml", ], arguments); } + [Fact] + public async Task InstallAsync_RetriesAfterWaitingForNewCrds() + { + var cluster = new KindClusterResource("cluster"); + var resource = new KindHelmChartResource("redis", "chart/ref", cluster) + { + CrdWaitRetryMaxAttempts = 3, + CrdWaitRetryBackoff = TimeSpan.FromSeconds(2), + }; + var processRunner = new FakeProcessRunner(); + processRunner.Results.Enqueue(new(0, "", "")); // kubectl get crd baseline + processRunner.Results.Enqueue(new(1, "", "no matches for kind \"Widget\" in version \"widgets.example.com/v1\"; ensure CRDs are installed first")); + processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", "")); // kubectl get crd after failure + processRunner.Results.Enqueue(new(0, "", "")); // kubectl wait + processRunner.Results.Enqueue(new(0, "release installed", "")); // retry succeeds + var delays = new List(); + var manager = new HelmManager( + processRunner, + (delay, _) => + { + delays.Add(delay); + return Task.CompletedTask; + }); + using var loggerFactory = LoggerFactory.Create(_ => { }); + + await manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None); + + Assert.Equal(5, processRunner.Commands.Count); + Assert.Equal("kubectl", processRunner.Commands[0].FileName); + Assert.Contains("get crd -o name", processRunner.Commands[0].Arguments); + Assert.Equal("helm", processRunner.Commands[1].FileName); + Assert.Equal("kubectl", processRunner.Commands[2].FileName); + Assert.Contains("get crd -o name", processRunner.Commands[2].Arguments); + Assert.Equal("kubectl", processRunner.Commands[3].FileName); + Assert.Contains("wait --for=condition=Established customresourcedefinition.apiextensions.k8s.io/widgets.example.com", processRunner.Commands[3].Arguments); + Assert.Equal("helm", processRunner.Commands[4].FileName); + Assert.Equal([TimeSpan.FromSeconds(2)], delays); + } + + [Fact] + public async Task InstallAsync_DoesNotRetryWithoutNewCrds() + { + var cluster = new KindClusterResource("cluster"); + var resource = new KindHelmChartResource("redis", "chart/ref", cluster) + { + CrdWaitRetryMaxAttempts = 3, + CrdWaitRetryBackoff = TimeSpan.FromSeconds(2), + }; + var processRunner = new FakeProcessRunner(); + processRunner.Results.Enqueue(new(0, "", "")); // kubectl get crd baseline + processRunner.Results.Enqueue(new(1, "", "no matches for kind \"Widget\" in version \"widgets.example.com/v1\"; ensure CRDs are installed first")); + processRunner.Results.Enqueue(new(0, "", "")); // kubectl get crd after failure + var manager = new HelmManager(processRunner, static (_, _) => Task.CompletedTask); + using var loggerFactory = LoggerFactory.Create(_ => { }); + + var ex = await Assert.ThrowsAsync( + () => manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None)); + + Assert.Contains("Failed to install Helm chart", ex.Message); + Assert.Equal(3, processRunner.Commands.Count); + } + + [Fact] + public void ComputeRetryBackoffDoublesPerFailure() + { + Assert.Equal(TimeSpan.FromSeconds(5), HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 1)); + Assert.Equal(TimeSpan.FromSeconds(10), HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 2)); + Assert.Equal(TimeSpan.FromSeconds(20), HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 3)); + } + // ── Null-check tests ───────────────────────────────────────────────── [Fact] @@ -282,6 +432,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 +454,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() { From a008d83bf26000982c7b2bca66d7a40f0f0ad169 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 29 Jul 2026 10:31:50 +0200 Subject: [PATCH 04/16] feat: expose Kind node config and earlier cleanup Add exported node image and host mount APIs and trigger session cluster cleanup on application stopping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../KindClusterLifecycleHook.cs | 24 ++- .../KindClusterResourceBuilderExtensions.cs | 64 +++++++ .../KindConfigAnnotation.cs | 14 ++ .../KindConfigGenerator.cs | 31 ++- .../AddKindClusterTests.cs | 180 ++++++++++++++++++ .../KindPublicApiTests.cs | 22 +++ .../WithKindTests.cs | 10 +- 7 files changed, 341 insertions(+), 4 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs index 81121fa68..4ad409d77 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs @@ -5,6 +5,7 @@ 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; @@ -18,18 +19,37 @@ 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; + /// public Task SubscribeAsync( IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(eventing); + + hostApplicationLifetime.ApplicationStopping.Register(() => _ = EnsureCleanupStarted()); return Task.CompletedTask; } + /// - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() => new(EnsureCleanupStarted()); + + private Task EnsureCleanupStarted() + { + lock (_cleanupLock) + { + _cleanupTask ??= CleanupClustersAsync(); + return _cleanupTask; + } + } + + private async Task CleanupClustersAsync() { var clusters = appModel.Resources.OfType(); diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs index 943ce78b4..8ea979c2e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs @@ -137,6 +137,58 @@ public static IResourceBuilder WithKubernetesVersion( return builder; } + /// + /// Sets the Kind node image for every node in the cluster. + /// + /// A resource type implementing . + /// The resource builder. + /// The fully qualified Kind node image. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithNodeImage( + this IResourceBuilder builder, + string image) + where T : IKindResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(image); + + var annotation = GetOrCreateNodeImageAnnotation(builder.Resource); + annotation.Image = image; + return builder; + } + + /// + /// Adds an extra host mount to every Kind node container. + /// + /// A resource type implementing . + /// The resource builder. + /// The path on the host. + /// The path inside the Kind node container. + /// to mount the path read-only. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithNodeMount( + this IResourceBuilder builder, + string hostPath, + string containerPath, + bool readOnly = false) + where T : IKindResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(hostPath); + ArgumentException.ThrowIfNullOrEmpty(containerPath); + + var annotation = GetOrCreateNodeMountsAnnotation(builder.Resource); + annotation.Mounts.Add(new KindMountModel + { + HostPath = hostPath, + ContainerPath = containerPath, + ReadOnly = readOnly, + }); + return builder; + } + /// /// Sets the number of worker nodes for the Kind cluster. /// @@ -211,6 +263,18 @@ private static KindNodeImageAnnotation GetOrCreateNodeImageAnnotation(IResource return annotation; } + private static KindNodeMountsAnnotation GetOrCreateNodeMountsAnnotation(IResource resource) + { + if (resource.TryGetLastAnnotation(out var existing)) + { + return existing; + } + + var annotation = new KindNodeMountsAnnotation(); + resource.Annotations.Add(annotation); + return annotation; + } + /// /// Verifies that the Kind CLI is installed and available on PATH. /// 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/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs index eddbb2c91..cac5d45af 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs @@ -4,8 +4,10 @@ using System.Runtime.InteropServices; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Eventing; using Aspire.Hosting.Lifecycle; using CommunityToolkit.Aspire.Testing; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -51,6 +53,58 @@ public async Task WithKubernetesVersionSetsVersion() } } + [Fact] + public async Task WithNodeImageSetsImageOnAllNodes() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddKindCluster("test-cluster") + .WithWorkerNodes(2) + .WithNodeImage("myacr.azurecr.io/kindest/node:v1.32.2"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + var configPath = await KindConfigGenerator.GenerateConfigAsync(resource, CancellationToken.None); + try + { + var yaml = await File.ReadAllTextAsync(configPath); + Assert.Equal(3, yaml.Split("image: myacr.azurecr.io/kindest/node:v1.32.2").Length - 1); + } + finally + { + File.Delete(configPath); + } + } + + [Fact] + public async Task WithNodeMountAddsMountOnAllNodes() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddKindCluster("test-cluster") + .WithWorkerNodes(1) + .WithNodeMount(@"C:\host-data", "/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: C:\\host-data").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 WithWorkerNodesSetsCount() { @@ -126,6 +180,33 @@ public void AddKindClusterThrowsOnNullName() Assert.Throws(() => builder.AddKindCluster(null!)); } + [Fact] + public void WithNodeImageRejectsNull() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => cluster.WithNodeImage(null!)); + } + + [Fact] + public void WithNodeMountRejectsNullHostPath() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => cluster.WithNodeMount(null!, "/container-data")); + } + + [Fact] + public void WithNodeMountRejectsNullContainerPath() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => cluster.WithNodeMount(@"C:\host-data", null!)); + } + [Fact] public async Task GeneratedConfigContainsImageFromKindContainerImageTags() { @@ -230,6 +311,59 @@ public void AddKindClusterRegistersLifecycleHook() Assert.NotNull(descriptor); } + [Fact] + public async Task KindClusterLifecycleHook_CleansUpOnApplicationStopping() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddKindCluster("test-cluster"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var loggerService = app.Services.GetRequiredService(); + var processRunner = new FakeProcessRunner(); + var hostLifetime = new TestHostApplicationLifetime(); + var hook = new KindClusterLifecycleHook( + model, + loggerService, + processRunner, + new TestKindContainerRuntimeResolver(), + hostLifetime); + + await hook.SubscribeAsync(new NoOpEventing(), null!); + hostLifetime.StopApplication(); + await hook.DisposeAsync(); + + Assert.Single( + processRunner.Commands, + command => command.FileName == "kind" && command.Arguments.Contains("delete cluster --name=test-cluster", StringComparison.Ordinal)); + } + + [Fact] + public async Task KindClusterLifecycleHook_DoesNotDeletePersistentClustersOnApplicationStopping() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddKindCluster("persistent-cluster") + .WithClusterLifetime(ClusterLifetime.Persistent); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var loggerService = app.Services.GetRequiredService(); + var processRunner = new FakeProcessRunner(); + var hostLifetime = new TestHostApplicationLifetime(); + var hook = new KindClusterLifecycleHook( + model, + loggerService, + processRunner, + new TestKindContainerRuntimeResolver(), + hostLifetime); + + await hook.SubscribeAsync(new NoOpEventing(), null!); + hostLifetime.StopApplication(); + await hook.DisposeAsync(); + + Assert.DoesNotContain(processRunner.Commands, command => command.FileName == "kind" && command.Arguments.Contains("delete cluster", StringComparison.Ordinal)); + } + // ── KindConfigGenerator tests ──────────────────────────────────────── [Fact] @@ -605,6 +739,52 @@ 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 sealed class CapturingLogger : ILogger { public List Messages { get; } = []; diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPublicApiTests.cs index b66fe1147..9ffa195cc 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPublicApiTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPublicApiTests.cs @@ -54,6 +54,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() { diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs index 1f9c626d3..ffd5804ef 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs @@ -49,7 +49,9 @@ public void FluentMethodsWorkAfterWithKind() builder.AddKubernetesEnvironment("k8s") .WithKind() .WithKubernetesVersion("v1.32.2") - .WithWorkerNodes(2); + .WithWorkerNodes(2) + .WithNodeImage("myacr.azurecr.io/kindest/node:v1.32.2") + .WithNodeMount(@"C:\kind-data", "/kind-data", readOnly: true); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -57,8 +59,14 @@ public void FluentMethodsWorkAfterWithKind() var kindEnv = Assert.Single(model.Resources.OfType()); Assert.True(kindEnv.TryGetLastAnnotation(out var imageAnnotation)); Assert.Equal("v1.32.2", imageAnnotation.Version); + Assert.Equal("myacr.azurecr.io/kindest/node:v1.32.2", imageAnnotation.Image); Assert.True(kindEnv.TryGetLastAnnotation(out var workerAnnotation)); Assert.Equal(2, workerAnnotation.Count); + Assert.True(kindEnv.TryGetLastAnnotation(out var mountAnnotation)); + var mount = Assert.Single(mountAnnotation.Mounts); + Assert.Equal(@"C:\kind-data", mount.HostPath); + Assert.Equal("/kind-data", mount.ContainerPath); + Assert.True(mount.ReadOnly); } [Fact] From 61fff8b191a0e2f1935953859cbf671d6d1ff1a9 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 29 Jul 2026 10:31:59 +0200 Subject: [PATCH 05/16] docs: describe new Kind manifest and Helm options Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md index fac1f7328..25a78717d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md @@ -53,6 +53,16 @@ var cluster = builder.AddKindCluster("mycluster") .WithKubernetesVersion("v1.32.2"); ``` +#### Node image and host mounts + +When you need the full Kind node image name (for example, a private registry mirror) use `WithNodeImage`. To project local content into every Kind node, use `WithNodeMount`. + +```csharp +var cluster = builder.AddKindCluster("mycluster") + .WithNodeImage("registry.example.com/kindest/node:v1.32.2") + .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`: @@ -151,9 +161,13 @@ 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"); ``` +Use `WithHelmStringValue` when a value looks numeric or boolean but must remain a string in the rendered chart. `WithCrdWaitRetry` retries installs that race CRD registration, waiting for newly-created CRDs to reach `Established` before re-running Helm. + ### Applying raw manifests to the cluster Use `AddManifest` to apply a Kubernetes manifest (file, directory, or Kustomize overlay) to the cluster after it becomes healthy. This runs `kubectl apply -f --kubeconfig ` against the cluster kubeconfig and is the natural equivalent of `AddHelmChart` for scenarios where a chart would be overkill. Relative paths are resolved against the AppHost project directory. @@ -202,6 +216,8 @@ Manifests persist with the cluster - deleted with session clusters, retained wit 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. +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. + #### Namespace behavior `.WithNamespace(ns)` passes `--namespace ` to `kubectl apply`, but it does **not** create the namespace. This differs from `AddHelmChart`, which passes `--create-namespace`. From d1f5f16498b875040764e86d917ae6e4d1aed9c7 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 29 Jul 2026 10:40:41 +0200 Subject: [PATCH 06/16] docs: show new Kind APIs in sample AppHost Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Program.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs index 9e95c22e2..f96493fa6 100644 --- a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs +++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs @@ -1,9 +1,11 @@ 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") + .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,10 +19,13 @@ .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 to the Kind cluster and target the namespace declared by the sample manifest. var manifestResource = cluster.AddManifest("extra-config", "manifests/extra-config.yaml") + .WithClusterReadyTimeout(TimeSpan.FromMinutes(2)) .WithNamespace("aspire-demo"); // Demonstrate recursive directory apply for manifest folders. From c6a4ff26c0e0338833383256fe28c55eb6fb25c4 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 29 Jul 2026 10:43:54 +0200 Subject: [PATCH 07/16] docs: expand Kind API reference examples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../README.md | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md index 25a78717d..b5a5d4c08 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md @@ -53,13 +53,21 @@ var cluster = builder.AddKindCluster("mycluster") .WithKubernetesVersion("v1.32.2"); ``` -#### Node image and host mounts +#### WithNodeImage -When you need the full Kind node image name (for example, a private registry mirror) use `WithNodeImage`. To project local content into every Kind node, use `WithNodeMount`. +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. ```csharp var cluster = builder.AddKindCluster("mycluster") - .WithNodeImage("registry.example.com/kindest/node:v1.32.2") .WithNodeMount(@"C:\dev\charts", "/var/local/charts", readOnly: true); ``` @@ -166,7 +174,23 @@ var redis = cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicha .WithNamespace("cache"); ``` -Use `WithHelmStringValue` when a value looks numeric or boolean but must remain a string in the rendered chart. `WithCrdWaitRetry` retries installs that race CRD registration, waiting for newly-created CRDs to reach `Established` before re-running Helm. +#### WithHelmStringValue + +Use `WithHelmStringValue` when a value looks numeric or boolean but must remain a string in the rendered chart. Kind emits `--set-string` for these values instead of `--set`. + +```csharp +var redis = cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithHelmStringValue("auth.password", "000123"); +``` + +#### WithCrdWaitRetry + +Use `WithCrdWaitRetry` for charts that install CRDs and then immediately reference them. Kind retries the Helm install, waits for newly-created CRDs to reach `Established`, then re-runs Helm. + +```csharp +var certManager = cluster.AddHelmChart("cert-manager", "jetstack/cert-manager") + .WithCrdWaitRetry(maxAttempts: 3, backoff: TimeSpan.FromSeconds(5)); +``` ### Applying raw manifests to the cluster @@ -216,7 +240,14 @@ Manifests persist with the cluster - deleted with session clusters, retained wit 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. -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. +#### WithClusterReadyTimeout + +If the Kubernetes API needs longer to become reachable before `kubectl apply`, use `WithClusterReadyTimeout` to extend the `kubectl cluster-info` readiness budget for that manifest resource. + +```csharp +cluster.AddManifest("platform", "./manifests/platform") + .WithClusterReadyTimeout(TimeSpan.FromMinutes(2)); +``` #### Namespace behavior From 5fe2af92e74f39f5a9b3b3ab8b112813b1ea45b7 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 29 Jul 2026 11:11:20 +0200 Subject: [PATCH 08/16] fix: broaden Kind cleanup signals Add best-effort process-exit and Ctrl+C cleanup on top of ApplicationStopping, and cover standalone stop-trigger cleanup plus idempotence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../KindClusterLifecycleHook.cs | 83 +++++++++++++++++- .../AddKindClusterTests.cs | 84 +++++++++++++++++++ 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs index 4ad409d77..a103bc46d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs @@ -7,11 +7,12 @@ using Aspire.Hosting.Lifecycle; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using System.Runtime.Loader; namespace CommunityToolkit.Aspire.Hosting.Kind; /// -/// Handles cleanup of Kind clusters on application shutdown. +/// Handles cleanup of Kind clusters on graceful shutdown and best-effort process-exit signals. /// Clusters with lifetime are deleted; /// clusters with lifetime are left running. /// @@ -23,7 +24,13 @@ internal sealed class KindClusterLifecycleHook( IHostApplicationLifetime hostApplicationLifetime) : IDistributedApplicationEventingSubscriber, IAsyncDisposable { private readonly object _cleanupLock = new(); + private readonly object _registrationLock = new(); private Task? _cleanupTask; + private CancellationTokenRegistration _applicationStoppingRegistration; + private EventHandler? _processExitHandler; + private Action? _unloadingHandler; + private ConsoleCancelEventHandler? _cancelKeyPressHandler; + private bool _terminationHandlersRegistered; /// public Task SubscribeAsync( @@ -33,12 +40,22 @@ public Task SubscribeAsync( { ArgumentNullException.ThrowIfNull(eventing); - hostApplicationLifetime.ApplicationStopping.Register(() => _ = EnsureCleanupStarted()); + RegisterTerminationHandlers(); return Task.CompletedTask; } /// - public ValueTask DisposeAsync() => new(EnsureCleanupStarted()); + public async ValueTask DisposeAsync() + { + try + { + await EnsureCleanupStarted().ConfigureAwait(false); + } + finally + { + UnregisterTerminationHandlers(); + } + } private Task EnsureCleanupStarted() { @@ -49,6 +66,66 @@ private Task EnsureCleanupStarted() } } + private void RegisterTerminationHandlers() + { + lock (_registrationLock) + { + if (_terminationHandlersRegistered) + { + return; + } + + _applicationStoppingRegistration = hostApplicationLifetime.ApplicationStopping.Register(() => _ = EnsureCleanupStarted()); + _processExitHandler ??= (_, _) => RunCleanupSynchronously(); + _unloadingHandler ??= _ => RunCleanupSynchronously(); + _cancelKeyPressHandler ??= (_, _) => RunCleanupSynchronously(); + AppDomain.CurrentDomain.ProcessExit += _processExitHandler; + AssemblyLoadContext.Default.Unloading += _unloadingHandler; + Console.CancelKeyPress += _cancelKeyPressHandler; + _terminationHandlersRegistered = true; + } + } + + private void UnregisterTerminationHandlers() + { + lock (_registrationLock) + { + if (!_terminationHandlersRegistered) + { + return; + } + + _applicationStoppingRegistration.Dispose(); + if (_processExitHandler is not null) + { + AppDomain.CurrentDomain.ProcessExit -= _processExitHandler; + } + + if (_unloadingHandler is not null) + { + AssemblyLoadContext.Default.Unloading -= _unloadingHandler; + } + + if (_cancelKeyPressHandler is not null) + { + Console.CancelKeyPress -= _cancelKeyPressHandler; + } + + _terminationHandlersRegistered = false; + } + } + + private void RunCleanupSynchronously() + { + try + { + EnsureCleanupStarted().GetAwaiter().GetResult(); + } + catch + { + } + } + private async Task CleanupClustersAsync() { var clusters = appModel.Resources.OfType(); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs index cac5d45af..afdce6ef4 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs @@ -105,6 +105,32 @@ public async Task WithNodeMountAddsMountOnAllNodes() } } + [Fact] + public async Task WithNodeMountResolvesRelativeHostPathFromAppHostDirectory() + { + var builder = DistributedApplication.CreateBuilder(); + var relativeHostPath = Path.Combine("mounts", "charts"); + var expectedHostPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, relativeHostPath)); + + builder.AddKindCluster("test-cluster") + .WithNodeMount(relativeHostPath, "/container-data"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + var configPath = await KindConfigGenerator.GenerateConfigAsync(resource, CancellationToken.None); + try + { + var yaml = await File.ReadAllTextAsync(configPath); + Assert.Contains($"hostPath: {expectedHostPath}", yaml); + } + finally + { + File.Delete(configPath); + } + } + [Fact] public async Task WithWorkerNodesSetsCount() { @@ -207,6 +233,15 @@ public void WithNodeMountRejectsNullContainerPath() Assert.Throws(() => cluster.WithNodeMount(@"C:\host-data", null!)); } + [Fact] + public void WithNodeMountRejectsEmptyHostPath() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => cluster.WithNodeMount("", "/container-data")); + } + [Fact] public async Task GeneratedConfigContainsImageFromKindContainerImageTags() { @@ -331,6 +366,40 @@ public async Task KindClusterLifecycleHook_CleansUpOnApplicationStopping() await hook.SubscribeAsync(new NoOpEventing(), null!); hostLifetime.StopApplication(); + + await WaitForConditionAsync(() => + processRunner.Commands.Any(command => command.FileName == "kind" && command.Arguments.Contains("delete cluster --name=test-cluster", StringComparison.Ordinal))); + + Assert.Single( + processRunner.Commands, + command => command.FileName == "kind" && command.Arguments.Contains("delete cluster --name=test-cluster", StringComparison.Ordinal)); + + await hook.DisposeAsync(); + } + + [Fact] + public async Task KindClusterLifecycleHook_DoesNotDoubleDeleteWhenStoppingThenDisposed() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddKindCluster("test-cluster"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var loggerService = app.Services.GetRequiredService(); + var processRunner = new FakeProcessRunner(); + var hostLifetime = new TestHostApplicationLifetime(); + var hook = new KindClusterLifecycleHook( + model, + loggerService, + processRunner, + new TestKindContainerRuntimeResolver(), + hostLifetime); + + await hook.SubscribeAsync(new NoOpEventing(), null!); + hostLifetime.StopApplication(); + await WaitForConditionAsync(() => + processRunner.Commands.Any(command => command.FileName == "kind" && command.Arguments.Contains("delete cluster --name=test-cluster", StringComparison.Ordinal))); + await hook.DisposeAsync(); Assert.Single( @@ -785,6 +854,21 @@ public Task PublishAsync(T @event, EventDispatchBehavior dispatchBehavior, Ca 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; } = []; From ff318914143faa479469cb892ab66b9d6a5a2868 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 29 Jul 2026 11:11:39 +0200 Subject: [PATCH 09/16] test: verify manifest readiness timeout wiring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../KindManifestResourceBuilderExtensions.cs | 16 +++++++++++++--- .../KubectlManager.cs | 2 ++ .../KindManifestTests.cs | 18 ++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs index 1feddddbf..62cdbbdfd 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs @@ -110,9 +110,7 @@ await notifications.PublishUpdateAsync(resource, try { var processRunner = e.Services.GetRequiredService(); - var kubectlManager = new KubectlManager( - processRunner, - clusterInfoMaxWait: resource.ClusterReadyTimeout); + var kubectlManager = CreateKubectlManager(processRunner, resource); await kubectlManager.ApplyAsync(resource, logger, ct); await notifications.PublishUpdateAsync(resource, @@ -138,6 +136,18 @@ await notifications.PublishUpdateAsync(resource, return resourceBuilder; } + internal static KubectlManager CreateKubectlManager( + IProcessRunner processRunner, + K8sManifestResource resource) + { + ArgumentNullException.ThrowIfNull(processRunner); + ArgumentNullException.ThrowIfNull(resource); + + return new KubectlManager( + processRunner, + clusterInfoMaxWait: resource.ClusterReadyTimeout); + } + /// /// Recursively applies manifests from subdirectories (maps to kubectl apply --recursive). /// Only meaningful when the manifest path is a directory. diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs index dc07f54dd..9428a3b34 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs @@ -26,6 +26,8 @@ internal sealed class KubectlManager( 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. /// diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs index 8d3403de4..7d12aa6e8 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs @@ -298,6 +298,24 @@ public void WithClusterReadyTimeoutSetsClusterReadyTimeout() Assert.Equal(TimeSpan.FromSeconds(90), resource.ClusterReadyTimeout); } + [Fact] + public void WithClusterReadyTimeoutWiresValueIntoKubectlManagerCreation() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", "./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() { From 21d02d898944294719ce3d722830464fbce773bf Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 29 Jul 2026 11:11:55 +0200 Subject: [PATCH 10/16] fix: normalize node mounts and helm value precedence Resolve relative node mount paths against the AppHost directory and make Helm value APIs last-write-wins across --set and --set-string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../KindClusterResourceBuilderExtensions.cs | 14 +++-- .../KindHelmChartResourceBuilderExtensions.cs | 2 + .../KindHelmChartTests.cs | 53 +++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs index 8ea979c2e..9d172f115 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs @@ -176,13 +176,17 @@ public static IResourceBuilder WithNodeMount( where T : IKindResource { ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrEmpty(hostPath); - ArgumentException.ThrowIfNullOrEmpty(containerPath); + ArgumentException.ThrowIfNullOrWhiteSpace(hostPath); + ArgumentException.ThrowIfNullOrWhiteSpace(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 = hostPath, + HostPath = normalizedHostPath, ContainerPath = containerPath, ReadOnly = readOnly, }); @@ -210,8 +214,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. diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs index 711c75e86..0865d8e96 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs @@ -149,6 +149,7 @@ public static IResourceBuilder WithHelmValue( ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(value); + builder.Resource.StringValues.Remove(key); builder.Resource.Values[key] = value; return builder; } @@ -170,6 +171,7 @@ public static IResourceBuilder WithHelmStringValue( ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(value); + builder.Resource.Values.Remove(key); builder.Resource.StringValues[key] = value; return builder; } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs index ef4f70f9b..1975cf5f0 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs @@ -104,6 +104,59 @@ public void WithHelmStringValueAddsStringValue() Assert.Equal("false", resource.StringValues["feature.flag"]); } + [Fact] + public void WithHelmValueLastWriteWinsForDuplicateKey() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithHelmValue("replica.replicaCount", "1") + .WithHelmValue("replica.replicaCount", "2"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("2", resource.Values["replica.replicaCount"]); + } + + [Fact] + public void WithHelmValueAndStringValueUseLastWriteWinsAcrossModes() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithHelmValue("auth.password", "123") + .WithHelmStringValue("auth.password", "000123"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.False(resource.Values.ContainsKey("auth.password")); + Assert.Equal("000123", resource.StringValues["auth.password"]); + } + + [Fact] + public void WithHelmStringValueAndValueUseLastWriteWinsAcrossModes() + { + var builder = DistributedApplication.CreateBuilder(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithHelmStringValue("auth.password", "000123") + .WithHelmValue("auth.password", "123"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.False(resource.StringValues.ContainsKey("auth.password")); + Assert.Equal("123", resource.Values["auth.password"]); + } + [Fact] public void WithCrdWaitRetrySetsRetryConfiguration() { From 6b1db26597c2c1e0716f0a2bb7f8a43a57a848ea Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 29 Jul 2026 11:12:13 +0200 Subject: [PATCH 11/16] docs: clarify Kind cleanup and mount usage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Program.cs | 12 +++++++----- src/CommunityToolkit.Aspire.Hosting.Kind/README.md | 8 ++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs index f96493fa6..ab5b3160b 100644 --- a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs +++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs @@ -5,6 +5,8 @@ // The cluster appears in the Aspire dashboard, your apps get KUBECONFIG injected. var cluster = builder.AddKindCluster("kind-cluster") .WithNodeImage("kindest/node:v1.32.2") + // Mount the same manifest directory that the sample applies from the host so + // workloads inside the cluster can also reference it if needed. .WithNodeMount(manifestMountSource, "/var/local/aspire/manifests", readOnly: true); // Run Headlamp (a lightweight Kubernetes web UI) as an Aspire-managed container @@ -23,22 +25,22 @@ .WithCrdWaitRetry(maxAttempts: 3, backoff: TimeSpan.FromSeconds(5)) .WithNamespace("cache"); -// Apply raw Kubernetes YAML to the Kind cluster and target the namespace declared by the sample manifest. -var manifestResource = cluster.AddManifest("extra-config", "manifests/extra-config.yaml") +// 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", "manifests") +cluster.AddManifest("recursive-config", manifestMountSource) .WithRecursive(); // Demonstrate server-side apply with conflict forcing and a stable field manager. -cluster.AddManifest("ssa-config", "manifests/extra-config.yaml") +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", "manifests/extra-config.yaml") +cluster.AddManifest("best-effort-crds", Path.Combine(manifestMountSource, "extra-config.yaml")) .WithCrdWaitBehavior(CrdWaitBehavior.BestEffort); cluster.AddManifestFromContent("demo-ns", """ diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md index b5a5d4c08..cffd61a10 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md @@ -27,7 +27,7 @@ var cluster = builder.AddKindCluster("mycluster"); builder.Build().Run(); ``` -This creates a Kind cluster named **mycluster** that is provisioned when the AppHost starts and deleted when it shuts down. +This creates a Kind cluster named **mycluster** that is provisioned when the AppHost starts and is cleaned up on graceful shutdown, Ctrl+C, or other process-exit signals on a best-effort basis. ## Scenario 1: Kind cluster as a managed dependency (F5 mode) @@ -64,7 +64,7 @@ var cluster = builder.AddKindCluster("mycluster") #### 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. +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. ```csharp var cluster = builder.AddKindCluster("mycluster") @@ -73,7 +73,7 @@ var cluster = builder.AddKindCluster("mycluster") #### Cluster lifetime -By default the cluster is deleted when the AppHost shuts down (`ClusterLifetime.Session`). To keep the cluster across AppHost restarts, use `ClusterLifetime.Persistent`: +By default the cluster is deleted on graceful shutdown or other process-exit signals (`ClusterLifetime.Session`) on a best-effort basis. To keep the cluster across AppHost restarts, use `ClusterLifetime.Persistent`: ```csharp var cluster = builder.AddKindCluster("mycluster") @@ -82,7 +82,7 @@ var cluster = builder.AddKindCluster("mycluster") | Value | Behavior | |---|---| -| `ClusterLifetime.Session` | Cluster is deleted on AppHost shutdown (default). | +| `ClusterLifetime.Session` | Cluster is deleted on graceful shutdown or process exit on a best-effort basis (default). | | `ClusterLifetime.Persistent` | Cluster survives AppHost restarts and is reused on next startup. | ## Networking model From 9f3e530a94fd591598a0c19e24777b3ee45dffd4 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 29 Jul 2026 11:34:48 +0200 Subject: [PATCH 12/16] test: make Kind node mount assertions cross-platform Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AddKindClusterTests.cs | 5 +++-- .../WithKindTests.cs | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs index afdce6ef4..a75bc938b 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs @@ -82,10 +82,11 @@ public async Task WithNodeImageSetsImageOnAllNodes() public async Task WithNodeMountAddsMountOnAllNodes() { var builder = DistributedApplication.CreateBuilder(); + var hostPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "host-data")); builder.AddKindCluster("test-cluster") .WithWorkerNodes(1) - .WithNodeMount(@"C:\host-data", "/container-data", readOnly: true); + .WithNodeMount(hostPath, "/container-data", readOnly: true); using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); @@ -95,7 +96,7 @@ public async Task WithNodeMountAddsMountOnAllNodes() try { var yaml = await File.ReadAllTextAsync(configPath); - Assert.Equal(2, yaml.Split("hostPath: C:\\host-data").Length - 1); + 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); } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs index ffd5804ef..c8e5c91bb 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs @@ -45,13 +45,14 @@ 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) .WithNodeImage("myacr.azurecr.io/kindest/node:v1.32.2") - .WithNodeMount(@"C:\kind-data", "/kind-data", readOnly: true); + .WithNodeMount(hostPath, "/kind-data", readOnly: true); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -64,7 +65,7 @@ public void FluentMethodsWorkAfterWithKind() Assert.Equal(2, workerAnnotation.Count); Assert.True(kindEnv.TryGetLastAnnotation(out var mountAnnotation)); var mount = Assert.Single(mountAnnotation.Mounts); - Assert.Equal(@"C:\kind-data", mount.HostPath); + Assert.Equal(hostPath, mount.HostPath); Assert.Equal("/kind-data", mount.ContainerPath); Assert.True(mount.ReadOnly); } From ddd90d1b56bc1b57a5e32b945c640fa455a12ca8 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 29 Jul 2026 11:58:58 +0200 Subject: [PATCH 13/16] test: enable plain dotnet test for Kind projects Also clarify that the node-mount sample is a configuration example rather than an end-to-end in-cluster mount consumer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs | 4 ++-- src/CommunityToolkit.Aspire.Hosting.Kind/README.md | 2 +- tests/Directory.Build.props | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs index ab5b3160b..4c5d71ed4 100644 --- a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs +++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs @@ -5,8 +5,8 @@ // The cluster appears in the Aspire dashboard, your apps get KUBECONFIG injected. var cluster = builder.AddKindCluster("kind-cluster") .WithNodeImage("kindest/node:v1.32.2") - // Mount the same manifest directory that the sample applies from the host so - // workloads inside the cluster can also reference it if needed. + // 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 diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md index cffd61a10..26caddee1 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md @@ -64,7 +64,7 @@ var cluster = builder.AddKindCluster("mycluster") #### 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. +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") diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index c053eb938..330e59f68 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -42,6 +42,8 @@ https://learn.microsoft.com/dotnet/core/testing/microsoft-testing-platform-exit-codes --> $(TestingPlatformCommandLineArguments) --ignore-exit-code 8 --filter-not-trait "category=failing" + + true true From 02c197349694b256e7fa72872f1debfe2fc40e75 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 29 Jul 2026 12:25:43 +0200 Subject: [PATCH 14/16] chore: align Kind formatting and API baselines Add the generated Kind API reference source expected by the repo's GenAPI workflow and apply the repo's formatting conventions to the touched sample/source/test files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Program.cs | 2 +- .../KindClusterLifecycleHook.cs | 2 +- .../KindClusterResourceBuilderExtensions.cs | 4 +- .../KindHelmChartResourceBuilderExtensions.cs | 4 +- .../KindManifestResourceBuilderExtensions.cs | 2 +- .../KubectlManager.cs | 6 +- .../CommunityToolkit.Aspire.Hosting.Kind.cs | 280 ++++++++++++++++++ .../AddKindClusterTests.cs | 4 +- .../KindHelmChartTests.cs | 2 +- .../WithKindTests.cs | 2 +- 10 files changed, 294 insertions(+), 14 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Kind/api/CommunityToolkit.Aspire.Hosting.Kind.cs diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs index 4c5d71ed4..a1b201e80 100644 --- a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs +++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs @@ -60,4 +60,4 @@ .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/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs index a103bc46d..0f5640c15 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs @@ -155,4 +155,4 @@ private async Task CleanupClustersAsync() } } } -} +} \ 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 9d172f115..ab2248a4e 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 @@ -338,4 +338,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/KindHelmChartResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs index 0865d8e96..56cff9e15 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 }); @@ -244,4 +244,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 index 62cdbbdfd..243f03827 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs @@ -279,4 +279,4 @@ public static IResourceBuilder WithCrdWaitBehavior( } -#pragma warning restore ASPIREATS001 +#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 index 9428a3b34..766b22c46 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs @@ -1,10 +1,10 @@ // 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; -using System.Diagnostics; +using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; +using System.Diagnostics; namespace CommunityToolkit.Aspire.Hosting.Kind; @@ -482,4 +482,4 @@ private static IReadOnlySet ParseResourceNames(string 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/api/CommunityToolkit.Aspire.Hosting.Kind.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/api/CommunityToolkit.Aspire.Hosting.Kind.cs new file mode 100644 index 000000000..976e33c41 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/api/CommunityToolkit.Aspire.Hosting.Kind.cs @@ -0,0 +1,280 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace Aspire.Hosting +{ + public enum ClusterLifetime + { + Session = 0, + Persistent = 1 + } + + public enum CrdWaitBehavior + { + Fail = 0, + BestEffort = 1 + } + + public static partial class KindClusterResourceBuilderExtensions + { + [AspireExport] + public static ApplicationModel.IResourceBuilder AddKindCluster(this IDistributedApplicationBuilder builder, string name) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithClusterLifetime(this ApplicationModel.IResourceBuilder builder, ClusterLifetime lifetime) + where T : ApplicationModel.IKindResource { throw null; } + + [AspireExportIgnore(Reason = "Action is not ATS-compatible. Configure Kind config through ATS-supported APIs.")] + public static ApplicationModel.IResourceBuilder WithKindConfig(this ApplicationModel.IResourceBuilder builder, System.Action configure) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithKubernetesVersion(this ApplicationModel.IResourceBuilder builder, string version) + where T : ApplicationModel.IKindResource { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithNodeImage(this ApplicationModel.IResourceBuilder builder, string image) + where T : ApplicationModel.IKindResource { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithNodeMount(this ApplicationModel.IResourceBuilder builder, string hostPath, string containerPath, bool readOnly = false) + where T : ApplicationModel.IKindResource { throw null; } + + [AspireExport("withKindClusterReference")] + public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder kind) + where T : ApplicationModel.IResourceWithEnvironment { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithWorkerNodes(this ApplicationModel.IResourceBuilder builder, int count) + where T : ApplicationModel.IKindResource { throw null; } + } + + public static partial class KindContainerExtensions + { + [AspireExport] + public static ApplicationModel.IResourceBuilder WithKindNetwork(this ApplicationModel.IResourceBuilder builder) { throw null; } + + [AspireExport("withKindContainerReference")] + public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder kind) { throw null; } + } + + public static partial class KindHelmChartResourceBuilderExtensions + { + [AspireExport] + public static ApplicationModel.IResourceBuilder AddHelmChart(this ApplicationModel.IResourceBuilder builder, string name, string chartRef) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithChartVersion(this ApplicationModel.IResourceBuilder builder, string version) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithCrdWaitRetry(this ApplicationModel.IResourceBuilder builder, int maxAttempts = 3, System.TimeSpan? backoff = null) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithHelmStringValue(this ApplicationModel.IResourceBuilder builder, string key, string value) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithHelmValue(this ApplicationModel.IResourceBuilder builder, string key, string value) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithHelmValuesFile(this ApplicationModel.IResourceBuilder builder, string path) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithNamespace(this ApplicationModel.IResourceBuilder builder, string @namespace) + where T : ApplicationModel.KindDeployedResource { throw null; } + } + + public static partial class KindManifestResourceBuilderExtensions + { + [AspireExport] + public static ApplicationModel.IResourceBuilder AddManifest(this ApplicationModel.IResourceBuilder builder, string name, string manifestPath) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder AddManifestFromContent(this ApplicationModel.IResourceBuilder builder, string name, string content) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithApplyTimeout(this ApplicationModel.IResourceBuilder builder, System.TimeSpan timeout) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithClusterReadyTimeout(this ApplicationModel.IResourceBuilder builder, System.TimeSpan timeout) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithCrdWaitBehavior(this ApplicationModel.IResourceBuilder builder, CrdWaitBehavior behavior) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithCrdWaitTimeout(this ApplicationModel.IResourceBuilder builder, System.TimeSpan timeout) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithFieldManager(this ApplicationModel.IResourceBuilder builder, string fieldManager) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithRecursive(this ApplicationModel.IResourceBuilder builder) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithServerSideApply(this ApplicationModel.IResourceBuilder builder, bool forceConflicts = false) { throw null; } + } + + public static partial class KubernetesEnvironmentKindExtensions + { + [AspireExport] + public static ApplicationModel.IResourceBuilder WithKind(this ApplicationModel.IResourceBuilder builder) { throw null; } + } +} + +namespace Aspire.Hosting.ApplicationModel +{ + public partial interface IKindResource : IResource + { + string KubeconfigPath { get; } + } + + [AspireExport(ExposeProperties = true)] + public partial class K8sManifestResource : KindDeployedResource + { + public K8sManifestResource(string name, string manifestPath, KindClusterResource parent) : base(default!, default!) { } + + public System.TimeSpan ApplyTimeout { get { throw null; } set { } } + + public System.TimeSpan ClusterReadyTimeout { get { throw null; } set { } } + + public CrdWaitBehavior CrdWaitBehavior { get { throw null; } set { } } + + public System.TimeSpan CrdWaitTimeout { get { throw null; } set { } } + + public string? FieldManager { get { throw null; } set { } } + + public bool ForceConflicts { get { throw null; } set { } } + + public string? InlineContent { get { throw null; } set { } } + + public bool IsKustomize { get { throw null; } set { } } + + public string ManifestPath { get { throw null; } } + + public bool Recursive { get { throw null; } set { } } + + public bool ServerSide { get { throw null; } set { } } + } + + public sealed partial class KindClusterResource : Resource, IKindResource, IResource, IResourceWithWaitSupport + { + public KindClusterResource(string name) : base(default!) { } + + public string ContainerKubeconfigPath { get { throw null; } } + + public string KubeconfigPath { get { throw null; } } + } + + public sealed partial class KindConfigModel + { + public string ApiVersion { get { throw null; } set { } } + + public System.Collections.Generic.IList? ContainerdConfigPatches { get { throw null; } set { } } + + public System.Collections.Generic.IDictionary? FeatureGates { get { throw null; } set { } } + + public string Kind { get { throw null; } set { } } + + public System.Collections.Generic.IList? KubeadmConfigPatches { get { throw null; } set { } } + + public KindNetworkingModel? Networking { get { throw null; } set { } } + + public System.Collections.Generic.IList Nodes { get { throw null; } } + + public System.Collections.Generic.IDictionary? RuntimeConfig { get { throw null; } set { } } + } + + public abstract partial class KindDeployedResource : Resource, IResourceWithParent, IResourceWithParent, IResource, IResourceWithWaitSupport + { + protected KindDeployedResource(string name, KindClusterResource parent) : base(default!) { } + + public string? Namespace { get { throw null; } set { } } + + public KindClusterResource Parent { get { throw null; } } + } + + public sealed partial class KindEnvironmentResource : Resource, IKindResource, IResource, IResourceWithParent, IResourceWithParent + { + public KindEnvironmentResource(string name, Kubernetes.KubernetesEnvironmentResource parent) : base(default!) { } + + public string KubeconfigPath { get { throw null; } } + + public Kubernetes.KubernetesEnvironmentResource Parent { get { throw null; } } + } + + public partial class KindHelmChartResource : KindDeployedResource + { + public KindHelmChartResource(string name, string chartRef, KindClusterResource parent) : base(default!, default!) { } + + public string ChartRef { get { throw null; } } + + public string ReleaseName { get { throw null; } } + + public System.Collections.Generic.Dictionary StringValues { get { throw null; } } + + public System.Collections.Generic.Dictionary Values { get { throw null; } } + + public System.Collections.Generic.List ValuesFiles { get { throw null; } } + + public string? Version { get { throw null; } set { } } + } + + public sealed partial class KindMountModel + { + public string? ContainerPath { get { throw null; } set { } } + + public string? HostPath { get { throw null; } set { } } + + public string? Propagation { get { throw null; } set { } } + + public bool? ReadOnly { get { throw null; } set { } } + + public bool? SelinuxRelabel { get { throw null; } set { } } + } + + public sealed partial class KindNetworkingModel + { + public string? ApiServerAddress { get { throw null; } set { } } + + public int? ApiServerPort { get { throw null; } set { } } + + public bool? DisableDefaultCNI { get { throw null; } set { } } + + public string? IpFamily { get { throw null; } set { } } + + public string? KubeProxyMode { get { throw null; } set { } } + + public string? PodSubnet { get { throw null; } set { } } + + public string? ServiceSubnet { get { throw null; } set { } } + } + + public sealed partial class KindNodeModel + { + public System.Collections.Generic.IList? ExtraMounts { get { throw null; } set { } } + + public System.Collections.Generic.IList? ExtraPortMappings { get { throw null; } set { } } + + public string? Image { get { throw null; } set { } } + + public System.Collections.Generic.IList? KubeadmConfigPatches { get { throw null; } set { } } + + public System.Collections.Generic.IDictionary? Labels { get { throw null; } set { } } + + public string Role { get { throw null; } set { } } + } + + public sealed partial class KindPortMappingModel + { + public int? ContainerPort { get { throw null; } set { } } + + public int? HostPort { get { throw null; } set { } } + + public string? ListenAddress { get { throw null; } set { } } + + public string? Protocol { get { throw null; } set { } } + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs index a75bc938b..75e3e3f5b 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.InteropServices; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Eventing; @@ -10,6 +9,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using System.Runtime.InteropServices; namespace CommunityToolkit.Aspire.Hosting.Kind.Tests; @@ -889,4 +889,4 @@ public void Log( Messages.Add(formatter(state, exception)); } } -} +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs index 1975cf5f0..3feb31651 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs @@ -589,4 +589,4 @@ public void AddHelmChartRegistersUniqueHealthCheckPerResource() Assert.NotEmpty(healthCheckAnnotations); } } -} +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs index c8e5c91bb..91bf429ec 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs @@ -148,4 +148,4 @@ public void WithKindIsInvisibleInRunMode() Assert.Empty(model.Resources.OfType()); Assert.Empty(model.Resources.OfType()); } -} +} \ No newline at end of file From 75c0b5049aaaa1723dc4a2b8828c7b9d41879a19 Mon Sep 17 00:00:00 2001 From: tamirdresher <342800+tamirdresher@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:04:50 +0200 Subject: [PATCH 15/16] docs: expand Kind XML API comments Add fuller XML documentation for the new public Kind fluent APIs so their timeout, CRD retry, Helm string-value, node image, and node mount semantics match the surrounding source documentation style. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../KindClusterResourceBuilderExtensions.cs | 16 +++++++++++++--- .../KindHelmChartResourceBuilderExtensions.cs | 11 +++++++++++ .../KindManifestResourceBuilderExtensions.cs | 9 ++++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs index ab2248a4e..0728478a5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs @@ -142,7 +142,10 @@ public static IResourceBuilder WithKubernetesVersion( /// /// A resource type implementing . /// The resource builder. - /// The fully qualified Kind node image. + /// + /// 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( @@ -163,10 +166,17 @@ public static IResourceBuilder WithNodeImage( /// /// A resource type implementing . /// The resource builder. - /// The path on the host. - /// The path inside the Kind node container. + /// + /// 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, diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs index 56cff9e15..d5096aec4 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs @@ -161,6 +161,12 @@ public static IResourceBuilder WithHelmValue( /// 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, @@ -187,6 +193,11 @@ public static IResourceBuilder WithHelmStringValue( /// When , Kind uses a 5 second initial backoff. /// /// A reference to the . + /// + /// This is useful for charts that create CRDs and immediately render custom resources + /// that depend on those CRDs. Between attempts, Kind waits for newly observed CRDs + /// to report Established. + /// [AspireExport] public static IResourceBuilder WithCrdWaitRetry( this IResourceBuilder builder, diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs index 243f03827..c5458aa44 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs @@ -213,8 +213,15 @@ public static IResourceBuilder WithFieldManager( /// before running kubectl apply. /// /// The manifest resource builder. - /// The cluster readiness timeout. + /// + /// 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, From 983ae85458d41f5a943f4ce6091fba64640b64f5 Mon Sep 17 00:00:00 2001 From: tamirdresher <342800+tamirdresher@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:48:27 +0200 Subject: [PATCH 16/16] fix: address Kind Copilot review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../HelmManager.cs | 12 ++-- .../KindClusterLifecycleHook.cs | 7 +- .../KindClusterResourceBuilderExtensions.cs | 4 ++ .../KindManifestResourceBuilderExtensions.cs | 2 +- .../KubectlManager.cs | 63 ++++++++++++----- .../AddKindClusterTests.cs | 9 +++ .../KindHelmChartTests.cs | 6 ++ .../KindManifestTests.cs | 68 +++++++++++++++++++ 8 files changed, 149 insertions(+), 22 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs index e94722232..1ec9a5067 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs @@ -143,11 +143,15 @@ internal static TimeSpan ComputeRetryBackoff(TimeSpan initialBackoff, int failur { ArgumentOutOfRangeException.ThrowIfLessThan(failureCount, 1); - var multiplier = 1 << (failureCount - 1); - var scaledTicks = initialBackoff.Ticks * multiplier; - return scaledTicks >= TimeSpan.MaxValue.Ticks + if (failureCount >= 64) + { + return TimeSpan.MaxValue; + } + + var multiplier = 1L << (failureCount - 1); + return initialBackoff.Ticks > TimeSpan.MaxValue.Ticks / multiplier ? TimeSpan.MaxValue - : TimeSpan.FromTicks(scaledTicks); + : TimeSpan.FromTicks(initialBackoff.Ticks * multiplier); } private static bool ShouldRetryForCrdRace(ProcessResult result) diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs index 0f5640c15..288dc5369 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs @@ -23,6 +23,7 @@ internal sealed class KindClusterLifecycleHook( IKindContainerRuntimeResolver containerRuntimeResolver, IHostApplicationLifetime hostApplicationLifetime) : IDistributedApplicationEventingSubscriber, IAsyncDisposable { + private static readonly TimeSpan SynchronousCleanupWaitTimeout = TimeSpan.FromSeconds(15); private readonly object _cleanupLock = new(); private readonly object _registrationLock = new(); private Task? _cleanupTask; @@ -119,7 +120,11 @@ private void RunCleanupSynchronously() { try { - EnsureCleanupStarted().GetAwaiter().GetResult(); + var cleanupTask = EnsureCleanupStarted(); + if (cleanupTask.Wait(SynchronousCleanupWaitTimeout)) + { + cleanupTask.GetAwaiter().GetResult(); + } } catch { diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs index 0728478a5..79366beb2 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs @@ -188,6 +188,10 @@ public static IResourceBuilder WithNodeMount( 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) diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs index c5458aa44..6cd8ceaa5 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs @@ -39,7 +39,7 @@ public static IResourceBuilder AddManifest( { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(name); - ArgumentNullException.ThrowIfNull(manifestPath); + ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath); var absolutePath = System.IO.Path.IsPathRooted(manifestPath) ? manifestPath diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs index 766b22c46..d661b7357 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs @@ -4,6 +4,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; +using System.ComponentModel; using System.Diagnostics; namespace CommunityToolkit.Aspire.Hosting.Kind; @@ -17,6 +18,7 @@ internal sealed class KubectlManager( 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); @@ -64,9 +66,8 @@ public async Task ApplyAsync(K8sManifestResource resource, ILogger logger, Cance try { - result = await processRunner.RunAsync( + result = await RunKubectlAsync( logger, - "kubectl", args, standardInput: resource.InlineContent, cancellationToken: applyCts.Token).ConfigureAwait(false); @@ -81,7 +82,7 @@ public async Task ApplyAsync(K8sManifestResource resource, ILogger logger, Cance if (result.ExitCode != 0) { throw new InvalidOperationException( - $"Failed to apply manifest '{resource.ManifestPath}' to cluster '{resource.Parent.Name}': {result.Error}"); + $"Failed to apply manifest '{resource.ManifestPath}' to cluster '{resource.Parent.Name}': {FormatFailureOutput(result)}"); } var crdNames = GetAppliedCrdNames(result.Output); @@ -118,9 +119,8 @@ internal async Task WaitForCrdsAsync( "Waiting for {CrdCount} custom resource definition(s) to become Established...", crds.Length); - var result = await processRunner.RunAsync( + var result = await RunKubectlAsync( logger, - "kubectl", args, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -153,11 +153,10 @@ internal async Task> GetCustomResourceDefinitionsAsync( ILogger logger, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrEmpty(kubeconfigPath); + ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath); - var result = await processRunner.RunAsync( + var result = await RunKubectlAsync( logger, - "kubectl", CreateGetCrdsArguments(kubeconfigPath), cancellationToken: cancellationToken).ConfigureAwait(false); @@ -244,7 +243,7 @@ internal static IReadOnlyList CreateWaitArguments( TimeSpan timeout) { ArgumentNullException.ThrowIfNull(crdNames); - ArgumentNullException.ThrowIfNull(kubeconfigPath); + ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath); List arguments = [ @@ -264,7 +263,7 @@ internal static IReadOnlyList CreateWaitArguments( /// internal static IReadOnlyList CreateClusterInfoArguments(string kubeconfigPath) { - ArgumentNullException.ThrowIfNull(kubeconfigPath); + ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath); return [ @@ -275,7 +274,7 @@ internal static IReadOnlyList CreateClusterInfoArguments(string kubeconf internal static IReadOnlyList CreateGetCrdsArguments(string kubeconfigPath) { - ArgumentNullException.ThrowIfNull(kubeconfigPath); + ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath); return [ @@ -338,9 +337,8 @@ internal async Task WaitForClusterInfoAsync( try { - result = await processRunner.RunAsync( + result = await RunKubectlAsync( logger, - "kubectl", args, cancellationToken: probeCts.Token).ConfigureAwait(false); } @@ -393,6 +391,40 @@ private static TimeSpan Min(TimeSpan left, TimeSpan right) return left <= right ? left : right; } + private async Task RunKubectlAsync( + ILogger logger, + IReadOnlyList arguments, + string? standardInput = null, + CancellationToken cancellationToken = default) + { + try + { + return await processRunner.RunAsync( + logger, + "kubectl", + arguments, + standardInput: standardInput, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (Win32Exception ex) + { + throw new InvalidOperationException(KubectlNotFoundMessage, ex); + } + } + + private static string FormatFailureOutput(ProcessResult result) + { + var messages = new[] + { + result.Error?.Trim(), + result.Output?.Trim(), + }; + + return string.Join( + Environment.NewLine, + messages.Where(static message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal)); + } + /// /// Extracts CRD resource names from kubectl apply output. /// @@ -433,7 +465,7 @@ private async Task WaitForCrdsCoreAsync( bool bestEffort) { ArgumentNullException.ThrowIfNull(crdNames); - ArgumentException.ThrowIfNullOrEmpty(kubeconfigPath); + ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath); var crds = crdNames.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); if (crds.Length == 0) @@ -447,9 +479,8 @@ private async Task WaitForCrdsCoreAsync( "Waiting for {CrdCount} custom resource definition(s) to become Established...", crds.Length); - var result = await processRunner.RunAsync( + var result = await RunKubectlAsync( logger, - "kubectl", args, cancellationToken: cancellationToken).ConfigureAwait(false); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs index 75e3e3f5b..052394bb3 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs @@ -243,6 +243,15 @@ public void WithNodeMountRejectsEmptyHostPath() Assert.Throws(() => cluster.WithNodeMount("", "/container-data")); } + [Fact] + public void WithNodeMountRejectsRelativeContainerPath() + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => cluster.WithNodeMount(@"C:\host-data", "container-data")); + } + [Fact] public async Task GeneratedConfigContainsImageFromKindContainerImageTags() { diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs index 3feb31651..40c9003d0 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs @@ -424,6 +424,12 @@ public void ComputeRetryBackoffDoublesPerFailure() 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] diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs index 7d12aa6e8..44cacd107 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs @@ -4,6 +4,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; +using System.ComponentModel; namespace CommunityToolkit.Aspire.Hosting.Kind.Tests; @@ -81,9 +82,21 @@ public void AddManifestThrowsOnNullManifestPath() { var builder = DistributedApplication.CreateBuilder(); var cluster = builder.AddKindCluster("test-cluster"); + Assert.Throws(() => cluster.AddManifest("crds", null!)); } + [Theory] + [InlineData("")] + [InlineData(" ")] + public void AddManifestThrowsOnWhitespaceManifestPath(string manifestPath) + { + var builder = DistributedApplication.CreateBuilder(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => cluster.AddManifest("crds", manifestPath)); + } + [Fact] public void AddManifestFromContentThrowsOnNullBuilder() { @@ -834,6 +847,20 @@ public void CreateWaitArguments_RoundsSubSecondTimeoutUpToOneSecond() 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() { @@ -957,6 +984,35 @@ await Assert.ThrowsAsync( () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None)); } + [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() { @@ -1002,4 +1058,16 @@ private static string CreateTestDirectory() 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