diff --git a/Directory.Packages.props b/Directory.Packages.props index 74e3b4e47..d8bf8eb20 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -54,6 +54,7 @@ + diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/CommunityToolkit.Aspire.Hosting.Kind.AppHost.csproj b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/CommunityToolkit.Aspire.Hosting.Kind.AppHost.csproj index 86b102f2c..05381e4b2 100644 --- a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/CommunityToolkit.Aspire.Hosting.Kind.AppHost.csproj +++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/CommunityToolkit.Aspire.Hosting.Kind.AppHost.csproj @@ -11,4 +11,8 @@ + + + + diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs index e5ad85a3e..a1b201e80 100644 --- a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs +++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/Program.cs @@ -1,9 +1,13 @@ var builder = DistributedApplication.CreateBuilder(args); +var manifestMountSource = Path.Combine(builder.AppHostDirectory, "manifests"); // Kind cluster as a managed dependency (F5 mode). // The cluster appears in the Aspire dashboard, your apps get KUBECONFIG injected. var cluster = builder.AddKindCluster("kind-cluster") - .WithKubernetesVersion("v1.32.2"); + .WithNodeImage("kindest/node:v1.32.2") + // Configuration example: mount the same manifest directory into each Kind node. + // This sample still applies manifests from the host path below rather than from inside a workload. + .WithNodeMount(manifestMountSource, "/var/local/aspire/manifests", readOnly: true); // Run Headlamp (a lightweight Kubernetes web UI) as an Aspire-managed container // connected to the Kind cluster. @@ -17,14 +21,43 @@ .WithHelmValue("replica.replicaCount", "0") .WithHelmValue("master.service.type", "NodePort") .WithHelmValue("master.service.nodePorts.redis", "30379") + .WithHelmStringValue("auth.password", "000123") + .WithCrdWaitRetry(maxAttempts: 3, backoff: TimeSpan.FromSeconds(5)) .WithNamespace("cache"); +// Apply raw Kubernetes YAML from the same host directory mounted into each Kind node. +var manifestResource = cluster.AddManifest("extra-config", Path.Combine(manifestMountSource, "extra-config.yaml")) + .WithClusterReadyTimeout(TimeSpan.FromMinutes(2)) + .WithNamespace("aspire-demo"); + +// Demonstrate recursive directory apply for manifest folders. +cluster.AddManifest("recursive-config", manifestMountSource) + .WithRecursive(); + +// Demonstrate server-side apply with conflict forcing and a stable field manager. +cluster.AddManifest("ssa-config", Path.Combine(manifestMountSource, "extra-config.yaml")) + .WithServerSideApply(forceConflicts: true) + .WithFieldManager("aspire-example"); + +// Demonstrate best-effort CRD waiting when a local demo should continue after a CRD wait timeout. +cluster.AddManifest("best-effort-crds", Path.Combine(manifestMountSource, "extra-config.yaml")) + .WithCrdWaitBehavior(CrdWaitBehavior.BestEffort); + +cluster.AddManifestFromContent("demo-ns", """ + apiVersion: v1 + kind: Namespace + metadata: + name: aspire-demo + """); + // Test Aspire-container → Kind-workload connectivity by pinging Redis // through the Kind container network on the NodePort. builder.AddContainer("redis-ping", "nicolaka/netshoot") .WithKindNetwork() .WaitFor(cluster) + // Wait for the manifest resource before starting a downstream container. + .WaitFor(manifestResource) .WithEntrypoint("sh") .WithArgs("-c", "while true; do nc -zv kind-cluster-control-plane 30379; sleep 5; done"); -builder.Build().Run(); +builder.Build().Run(); \ No newline at end of file diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/manifests/extra-config.yaml b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/manifests/extra-config.yaml new file mode 100644 index 000000000..dafb34631 --- /dev/null +++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/manifests/extra-config.yaml @@ -0,0 +1,16 @@ +# This file demonstrates a multi-document manifest: +# 1. A Namespace for grouping demo resources. +# 2. A ConfigMap scoped to that namespace. +apiVersion: v1 +kind: Namespace +metadata: + name: aspire-demo +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: extra-config + namespace: aspire-demo +data: + greeting: hello-kind + purpose: aspire-demo diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/CommunityToolkit.Aspire.Hosting.Kind.csproj b/src/CommunityToolkit.Aspire.Hosting.Kind/CommunityToolkit.Aspire.Hosting.Kind.csproj index 31757c868..6bd638d8a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/CommunityToolkit.Aspire.Hosting.Kind.csproj +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/CommunityToolkit.Aspire.Hosting.Kind.csproj @@ -9,6 +9,7 @@ + diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs new file mode 100644 index 000000000..b3c1a54e1 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREATS001 // AspireExport APIs are experimental + +[assembly: Aspire.Hosting.AspireExport(typeof(Aspire.Hosting.CrdWaitBehavior))] + +namespace Aspire.Hosting; + +/// +/// Specifies how Kind manifest resources handle CRD Established-condition wait failures. +/// +public enum CrdWaitBehavior +{ + /// + /// Fail the manifest resource when waiting for applied CRDs fails or times out. + /// + Fail, + + /// + /// Log a warning and continue when waiting for applied CRDs fails or times out. + /// + BestEffort, +} + +#pragma warning restore ASPIREATS001 diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs index af1093ae1..142d9cc8c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/DefaultProcessRunner.cs @@ -20,6 +20,7 @@ public async Task RunAsync( IReadOnlyList arguments, string? workingDirectory = null, IReadOnlyDictionary? environmentVariables = null, + string? standardInput = null, CancellationToken cancellationToken = default) { logger.LogDebug("Executing: {FileName} {Arguments}", fileName, string.Join(' ', arguments)); @@ -29,6 +30,7 @@ public async Task RunAsync( FileName = fileName, RedirectStandardOutput = true, RedirectStandardError = true, + RedirectStandardInput = standardInput is not null, UseShellExecute = false, CreateNoWindow = true }; @@ -79,6 +81,12 @@ public async Task RunAsync( try { + if (standardInput is not null) + { + await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken).ConfigureAwait(false); + process.StandardInput.Close(); + } + // WaitForExitAsync observes process termination; WaitForExit() then lets async stdout/stderr event handlers finish draining redirected output. await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); process.WaitForExit(); diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs index 950127c61..df7731003 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs @@ -3,38 +3,145 @@ using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; +using Polly; +using Polly.Retry; namespace CommunityToolkit.Aspire.Hosting.Kind; /// /// Manages Helm chart deployments to a Kind cluster by orchestrating Helm CLI calls. /// -internal sealed class HelmManager(IProcessRunner processRunner) +internal sealed class HelmManager( + IProcessRunner processRunner, + Func? delayAsync = null, + KubectlManager? kubectlManager = null) { + private readonly Func _delayAsync = delayAsync ?? Task.Delay; + private readonly KubectlManager _kubectlManager = kubectlManager ?? new KubectlManager(processRunner, delayAsync); + /// /// Installs or upgrades the Helm release. + /// Callers should pass the Helm resource's scoped logger from . /// - public async Task InstallAsync(KindHelmChartResource resource, ILogger logger, CancellationToken cancellationToken) + public async Task InstallAsync(KindHelmChartResource resource, ILogger resourceLogger, CancellationToken cancellationToken) { var args = CreateInstallArguments(resource); + var maxAttempts = resource.CrdWaitRetryMaxAttempts; + if (maxAttempts <= 1) + { + resourceLogger.LogInformation( + "Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}' (attempt 1/1)...", + resource.ChartRef, + resource.ReleaseName, + resource.Parent.Name); + + var result = await processRunner.RunAsync( + resourceLogger, + "helm", + args, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {FormatFailureOutput(result)}"); + } + + resourceLogger.LogInformation( + "Helm release '{ReleaseName}' installed successfully.", resource.ReleaseName); + return; + } + + IReadOnlySet knownCrds = maxAttempts > 1 + ? await TryGetCustomResourceDefinitionsAsync(resource, resourceLogger, cancellationToken).ConfigureAwait(false) + : new HashSet(StringComparer.OrdinalIgnoreCase); + var attempt = 0; + var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = Math.Max(0, maxAttempts - 1), + Delay = TimeSpan.Zero, + UseJitter = false, + ShouldHandle = new PredicateBuilder() + .HandleResult(static result => result.ShouldRetry), + OnRetry = async arguments => + { + var retryResult = arguments.Outcome.Result!; + if (retryResult.NewCrds.Length > 0) + { + resourceLogger.LogWarning( + "Helm release '{ReleaseName}' failed and discovered {CrdCount} new CRD(s). Waiting for them to become Established before retrying.", + resource.ReleaseName, + retryResult.NewCrds.Length); + + await _kubectlManager.WaitForCrdsAsync( + retryResult.NewCrds, + resource.Parent.KubeconfigPath, + resource.CrdWaitRetryTimeout, + resourceLogger, + arguments.Context.CancellationToken).ConfigureAwait(false); + } + else + { + resourceLogger.LogWarning( + "Helm release '{ReleaseName}' failed. Retrying because {MethodName} is enabled.", + resource.ReleaseName, + "WithCrdWaitRetry"); + } + + knownCrds = retryResult.DiscoveredCrds; + var backoff = ComputeRetryBackoff(resource.CrdWaitRetryBackoff, arguments.AttemptNumber + 1); + resourceLogger.LogInformation( + "Retrying Helm release '{ReleaseName}' in {DelaySeconds:n1}s.", + resource.ReleaseName, + backoff.TotalSeconds); + await _delayAsync(backoff, arguments.Context.CancellationToken).ConfigureAwait(false); + } + }) + .Build(); + + var finalResult = await pipeline.ExecuteAsync(async token => + { + attempt++; + resourceLogger.LogInformation( + "Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}' (attempt {Attempt}/{MaxAttempts})...", + resource.ChartRef, + resource.ReleaseName, + resource.Parent.Name, + attempt, + maxAttempts); + + var result = await processRunner.RunAsync( + resourceLogger, + "helm", + args, + cancellationToken: token).ConfigureAwait(false); + + if (result.ExitCode == 0) + { + return HelmInstallAttemptResult.Success(result); + } - logger.LogInformation( - "Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}'...", - resource.ChartRef, resource.ReleaseName, resource.Parent.Name); + if (attempt >= maxAttempts) + { + return HelmInstallAttemptResult.Fail(result); + } - var result = await processRunner.RunAsync( - logger, - "helm", - args, - cancellationToken: cancellationToken).ConfigureAwait(false); + var discoveredCrds = await TryGetCustomResourceDefinitionsAsync(resource, resourceLogger, token).ConfigureAwait(false); + var newCrds = discoveredCrds + .Except(knownCrds, StringComparer.OrdinalIgnoreCase) + .ToArray(); - if (result.ExitCode != 0) + return HelmInstallAttemptResult.Retry(result, discoveredCrds, newCrds); + }, cancellationToken).ConfigureAwait(false); + + if (finalResult.Result.ExitCode != 0) { throw new InvalidOperationException( - $"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {result.Error}"); + $"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {FormatFailureOutput(finalResult.Result)}"); } - logger.LogInformation( + resourceLogger.LogInformation( "Helm release '{ReleaseName}' installed successfully.", resource.ReleaseName); } @@ -70,6 +177,12 @@ internal static IReadOnlyList CreateInstallArguments(KindHelmChartResour arguments.Add($"{key}={value}"); } + foreach (var (key, value) in resource.StringValues) + { + arguments.Add("--set-string"); + arguments.Add($"{key}={value}"); + } + foreach (string valuesFile in resource.ValuesFiles) { arguments.Add("-f"); @@ -78,4 +191,58 @@ internal static IReadOnlyList CreateInstallArguments(KindHelmChartResour return arguments; } + + internal static TimeSpan ComputeRetryBackoff(TimeSpan initialBackoff, int failureCount) + { + ArgumentOutOfRangeException.ThrowIfLessThan(failureCount, 1); + + if (failureCount >= 64) + { + return TimeSpan.MaxValue; + } + + var multiplier = 1L << (failureCount - 1); + return initialBackoff.Ticks > TimeSpan.MaxValue.Ticks / multiplier + ? TimeSpan.MaxValue + : TimeSpan.FromTicks(initialBackoff.Ticks * multiplier); + } + + private static string FormatFailureOutput(ProcessResult result) + { + return string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error; + } + + private async Task> TryGetCustomResourceDefinitionsAsync( + KindHelmChartResource resource, + ILogger resourceLogger, + CancellationToken cancellationToken) + { + try + { + return await _kubectlManager.GetCustomResourceDefinitionsAsync( + resource.Parent.KubeconfigPath, + resourceLogger, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + resourceLogger.LogDebug( + ex, + "Unable to snapshot CRDs for Helm release '{ReleaseName}'.", + resource.ReleaseName); + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + } + + private sealed record HelmInstallAttemptResult(ProcessResult Result, bool ShouldRetry, IReadOnlySet DiscoveredCrds, string[] NewCrds) + { + public static HelmInstallAttemptResult Success(ProcessResult result) => + new(result, ShouldRetry: false, new HashSet(StringComparer.OrdinalIgnoreCase), []); + + public static HelmInstallAttemptResult Fail(ProcessResult result) => + new(result, ShouldRetry: false, new HashSet(StringComparer.OrdinalIgnoreCase), []); + + public static HelmInstallAttemptResult Retry(ProcessResult result, IReadOnlySet discoveredCrds, string[] newCrds) => + new(result, ShouldRetry: true, discoveredCrds, newCrds); + } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/IProcessRunner.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/IProcessRunner.cs index 165cdf03d..eba7808e8 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/IProcessRunner.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/IProcessRunner.cs @@ -25,5 +25,6 @@ Task RunAsync( IReadOnlyList arguments, string? workingDirectory = null, IReadOnlyDictionary? environmentVariables = null, + string? standardInput = null, CancellationToken cancellationToken = default); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestAnnotations.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestAnnotations.cs new file mode 100644 index 000000000..5bf2d19ac --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestAnnotations.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Kind; + +internal sealed class K8sManifestApplyOptionsAnnotation : IResourceAnnotation +{ + public bool IsKustomize { get; set; } + + public bool Recursive { get; set; } + + public bool ServerSide { get; set; } + + public bool ForceConflicts { get; set; } + + public string? FieldManager { get; set; } + + public TimeSpan ApplyTimeout { get; set; } = KubectlTimeouts.DefaultApplyTimeout; +} + +internal sealed class K8sManifestCrdWaitPolicy +{ + public TimeSpan Timeout { get; set; } = KubectlTimeouts.DefaultCrdWaitTimeout; + + public CrdWaitBehavior FailureBehavior { get; set; } = CrdWaitBehavior.Fail; +} + +internal sealed class K8sManifestWaitPolicyAnnotation : IResourceAnnotation +{ + public TimeSpan ClusterReadyTimeout { get; set; } = TimeSpan.FromSeconds(60); + + public K8sManifestCrdWaitPolicy Crd { get; } = new(); +} + +internal static class K8sManifestAnnotations +{ + public static K8sManifestApplyOptionsAnnotation GetApplyOptions(K8sManifestResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + return resource.TryGetLastAnnotation(out var annotation) + ? annotation + : new K8sManifestApplyOptionsAnnotation(); + } + + public static K8sManifestApplyOptionsAnnotation GetOrCreateApplyOptions(K8sManifestResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + + if (resource.TryGetLastAnnotation(out var annotation)) + { + return annotation; + } + + annotation = new K8sManifestApplyOptionsAnnotation(); + resource.Annotations.Add(annotation); + return annotation; + } + + public static K8sManifestWaitPolicyAnnotation GetWaitPolicy(K8sManifestResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + return resource.TryGetLastAnnotation(out var annotation) + ? annotation + : new K8sManifestWaitPolicyAnnotation(); + } + + public static K8sManifestWaitPolicyAnnotation GetOrCreateWaitPolicy(K8sManifestResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + + if (resource.TryGetLastAnnotation(out var annotation)) + { + return annotation; + } + + annotation = new K8sManifestWaitPolicyAnnotation(); + resource.Annotations.Add(annotation); + return annotation; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs new file mode 100644 index 000000000..607bddd15 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs @@ -0,0 +1,127 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; + +#pragma warning disable ASPIREATS001 // AspireExport APIs are experimental + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A Kubernetes manifest applied to a Kind cluster via kubectl apply. +/// +/// The name of the resource. +/// +/// Absolute path to a Kubernetes manifest file or directory of manifest files, +/// or <inline> for manifests provided via standard input. +/// +/// The parent Kind cluster resource. +[AspireExport(ExposeProperties = true)] +public class K8sManifestResource(string name, string manifestPath, KindClusterResource parent) + : KindDeployedResource(name, parent) +{ + internal const string InlineManifestPath = ""; + + /// + /// Gets the manifest path passed to kubectl apply. + /// Accepts an absolute file path, an absolute directory path, or <inline> for standard input. + /// + public string ManifestPath { get; } = manifestPath ?? throw new ArgumentNullException(nameof(manifestPath)); + + /// + /// Gets or sets whether this resource represents a Kustomize overlay directory. + /// + public bool IsKustomize + { + get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetApplyOptions(this).IsKustomize; + set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateApplyOptions(this).IsKustomize = value; + } + + /// + /// Gets or sets inline manifest content applied with kubectl apply -f -. + /// + public string? InlineContent { get; set; } + + /// + /// Gets or sets whether to recursively apply manifests in subdirectories + /// (maps to kubectl apply --recursive). + /// Only meaningful when is a directory. + /// + public bool Recursive + { + get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetApplyOptions(this).Recursive; + set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateApplyOptions(this).Recursive = value; + } + + /// + /// Gets or sets whether to apply the manifest server-side + /// (maps to kubectl apply --server-side). + /// Server-side apply is required for large CRDs that exceed the client-side annotation size limit. + /// + public bool ServerSide + { + get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetApplyOptions(this).ServerSide; + set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateApplyOptions(this).ServerSide = value; + } + + /// + /// Gets or sets whether to force conflicts on server-side apply + /// (maps to kubectl apply --server-side --force-conflicts). + /// Only meaningful when is . + /// + public bool ForceConflicts + { + get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetApplyOptions(this).ForceConflicts; + set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateApplyOptions(this).ForceConflicts = value; + } + + /// + /// Gets the field manager name used with server-side apply + /// (maps to kubectl apply --field-manager). + /// When , kubectl uses its default (kubectl). + /// + public string? FieldManager + { + get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetApplyOptions(this).FieldManager; + set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateApplyOptions(this).FieldManager = value; + } + + /// + /// Gets or sets the maximum time to wait for kubectl apply to complete. + /// + public TimeSpan ApplyTimeout + { + get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetApplyOptions(this).ApplyTimeout; + set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateApplyOptions(this).ApplyTimeout = value; + } + + /// + /// Gets or sets the maximum time to wait for the Kubernetes API to become reachable + /// before running kubectl apply. + /// + public TimeSpan ClusterReadyTimeout + { + get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetWaitPolicy(this).ClusterReadyTimeout; + set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateWaitPolicy(this).ClusterReadyTimeout = value; + } + + /// + /// Gets or sets the maximum time to wait for applied CRDs to reach the Established condition. + /// + public TimeSpan CrdWaitTimeout + { + get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetWaitPolicy(this).Crd.Timeout; + set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateWaitPolicy(this).Crd.Timeout = value; + } + + /// + /// Gets or sets how CRD wait failures are handled. + /// + public CrdWaitBehavior CrdWaitBehavior + { + get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetWaitPolicy(this).Crd.FailureBehavior; + set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateWaitPolicy(this).Crd.FailureBehavior = value; + } +} + +#pragma warning restore ASPIREATS001 diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs index 81121fa68..9f5e3cc7d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterLifecycleHook.cs @@ -5,12 +5,12 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Eventing; using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; - namespace CommunityToolkit.Aspire.Hosting.Kind; /// -/// Handles cleanup of Kind clusters on application shutdown. +/// Handles cleanup of Kind clusters during graceful host shutdown and disposal. /// Clusters with lifetime are deleted; /// clusters with lifetime are left running. /// @@ -18,18 +18,48 @@ internal sealed class KindClusterLifecycleHook( DistributedApplicationModel appModel, ResourceLoggerService loggerService, IProcessRunner processRunner, - IKindContainerRuntimeResolver containerRuntimeResolver) : IDistributedApplicationEventingSubscriber, IAsyncDisposable + IKindContainerRuntimeResolver containerRuntimeResolver, + IHostApplicationLifetime hostApplicationLifetime) : IDistributedApplicationEventingSubscriber, IAsyncDisposable { + private readonly object _cleanupLock = new(); + private Task? _cleanupTask; + private CancellationTokenRegistration _applicationStoppingRegistration; + /// public Task SubscribeAsync( IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(eventing); + + _applicationStoppingRegistration = hostApplicationLifetime.ApplicationStopping.Register(() => _ = EnsureCleanupStarted()); return Task.CompletedTask; } + /// public async ValueTask DisposeAsync() + { + try + { + await EnsureCleanupStarted().ConfigureAwait(false); + } + finally + { + _applicationStoppingRegistration.Dispose(); + } + } + + private Task EnsureCleanupStarted() + { + lock (_cleanupLock) + { + _cleanupTask ??= CleanupClustersAsync(); + return _cleanupTask; + } + } + + private async Task CleanupClustersAsync() { var clusters = appModel.Resources.OfType(); @@ -58,4 +88,4 @@ public async ValueTask DisposeAsync() } } } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs index 943ce78b4..807fb9a79 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindClusterResourceBuilderExtensions.cs @@ -1,13 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.ComponentModel; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Lifecycle; using CommunityToolkit.Aspire.Hosting.Kind; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; +using System.ComponentModel; #pragma warning disable ASPIREATS001 // AspireExport APIs are experimental @@ -133,10 +133,82 @@ public static IResourceBuilder WithKubernetesVersion( ArgumentException.ThrowIfNullOrEmpty(version); var annotation = GetOrCreateNodeImageAnnotation(builder.Resource); + ThrowIfNodeImageConfigurationConflicts(annotation, configuredValueName: nameof(annotation.Version), conflictingValueName: nameof(annotation.Image)); annotation.Version = version; return builder; } + /// + /// Sets the Kind node image for every node in the cluster. + /// + /// A resource type implementing . + /// The resource builder. + /// + /// The fully qualified Kind node image, such as kindest/node:v1.33.1. + /// This overrides the image that would otherwise be derived from . + /// + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithNodeImage( + this IResourceBuilder builder, + string image) + where T : IKindResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(image); + + var annotation = GetOrCreateNodeImageAnnotation(builder.Resource); + ThrowIfNodeImageConfigurationConflicts(annotation, configuredValueName: nameof(annotation.Image), conflictingValueName: nameof(annotation.Version)); + annotation.Image = image; + return builder; + } + + /// + /// Adds an extra host mount to every Kind node container. + /// + /// A resource type implementing . + /// The resource builder. + /// + /// The path on the host. Relative paths are resolved against the AppHost directory before being written to the Kind config. + /// + /// The absolute path inside the Kind node container. + /// to mount the path read-only. + /// A reference to the . + /// + /// This configures Kind node-container mounts, which are useful for surfacing host-side assets + /// such as local manifest directories, certificates, or other development-time inputs to workloads + /// that later mount paths from the node filesystem. + /// + [AspireExport] + public static IResourceBuilder WithNodeMount( + this IResourceBuilder builder, + string hostPath, + string containerPath, + bool readOnly = false) + where T : IKindResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(hostPath); + ArgumentException.ThrowIfNullOrWhiteSpace(containerPath); + if (!containerPath.StartsWith("/", StringComparison.Ordinal)) + { + throw new ArgumentException("Container path must be an absolute Linux path (for example '/var/local/data').", nameof(containerPath)); + } + + var normalizedHostPath = Path.IsPathRooted(hostPath) + ? Path.GetFullPath(hostPath) + : Path.GetFullPath(Path.Combine(builder.ApplicationBuilder.AppHostDirectory, hostPath)); + + var annotation = GetOrCreateNodeMountsAnnotation(builder.Resource); + annotation.Mounts.Add(new KindMountModel + { + HostPath = normalizedHostPath, + ContainerPath = containerPath, + ReadOnly = readOnly, + }); + return builder; + } + /// /// Sets the number of worker nodes for the Kind cluster. /// @@ -158,8 +230,8 @@ public static IResourceBuilder WithWorkerNodes( /// /// Sets the cluster lifetime. When (the default), - /// the cluster is deleted when the AppHost shuts down. When , - /// the cluster survives AppHost restarts and is reused on next startup. + /// the cluster is deleted on graceful shutdown or other process-exit signals on a best-effort basis. + /// When , the cluster survives AppHost restarts and is reused on next startup. /// /// A resource type implementing . /// The resource builder. @@ -211,6 +283,36 @@ private static KindNodeImageAnnotation GetOrCreateNodeImageAnnotation(IResource return annotation; } + private static KindNodeMountsAnnotation GetOrCreateNodeMountsAnnotation(IResource resource) + { + if (resource.TryGetLastAnnotation(out var existing)) + { + return existing; + } + + var annotation = new KindNodeMountsAnnotation(); + resource.Annotations.Add(annotation); + return annotation; + } + + private static void ThrowIfNodeImageConfigurationConflicts( + KindNodeImageAnnotation annotation, + string configuredValueName, + string conflictingValueName) + { + ArgumentNullException.ThrowIfNull(annotation); + + var hasConflictingValue = configuredValueName == nameof(annotation.Version) + ? !string.IsNullOrEmpty(annotation.Image) + : !string.IsNullOrEmpty(annotation.Version); + + if (hasConflictingValue) + { + throw new InvalidOperationException( + $"Kind node image configuration cannot set both {configuredValueName} and {conflictingValueName} on the same cluster. Use either {nameof(WithKubernetesVersion)} or {nameof(WithNodeImage)}."); + } + } + /// /// Verifies that the Kind CLI is installed and available on PATH. /// @@ -270,4 +372,4 @@ public static IResourceBuilder WithReference( } } -#pragma warning restore ASPIREATS001 +#pragma warning restore ASPIREATS001 \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigAnnotation.cs index e6ef296c4..e07fff109 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigAnnotation.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigAnnotation.cs @@ -42,6 +42,12 @@ internal sealed class KindNodeImageAnnotation : IResourceAnnotation /// Defaults to "kindest/node". /// public string Registry { get; set; } = "kindest/node"; + + /// + /// Gets or sets the fully qualified node image. + /// When set, it takes precedence over + . + /// + public string? Image { get; set; } } /// @@ -63,3 +69,11 @@ public WorkerNodesAnnotation(int count) /// public int Count { get; } } + +/// +/// Represents annotations that add extra mounts to every Kind node. +/// +internal sealed class KindNodeMountsAnnotation : IResourceAnnotation +{ + public IList Mounts { get; } = []; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigGenerator.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigGenerator.cs index dc5e135f4..fa67edf1c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigGenerator.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindConfigGenerator.cs @@ -49,7 +49,15 @@ internal static async Task GenerateConfigAsync(IKindResource resource, C // Apply Kubernetes version after all config callbacks so every node gets the image, // regardless of the order WithKubernetesVersion and WithWorkerNodes/WithKindConfig were called. if (resource.TryGetLastAnnotation(out var imageAnnotation) && - imageAnnotation.Version is not null) + imageAnnotation.Image is not null) + { + foreach (var node in config.Nodes) + { + node.Image ??= imageAnnotation.Image; + } + } + else if (resource.TryGetLastAnnotation(out imageAnnotation) && + imageAnnotation.Version is not null) { var image = $"{imageAnnotation.Registry}:{imageAnnotation.Version}"; foreach (var node in config.Nodes) @@ -58,6 +66,27 @@ internal static async Task GenerateConfigAsync(IKindResource resource, C } } + if (resource.TryGetLastAnnotation(out var mountsAnnotation) && + mountsAnnotation.Mounts.Count > 0) + { + foreach (var node in config.Nodes) + { + node.ExtraMounts ??= []; + + foreach (var mount in mountsAnnotation.Mounts) + { + node.ExtraMounts.Add(new KindMountModel + { + HostPath = mount.HostPath, + ContainerPath = mount.ContainerPath, + ReadOnly = mount.ReadOnly, + SelinuxRelabel = mount.SelinuxRelabel, + Propagation = mount.Propagation, + }); + } + } + } + var yaml = s_serializer.Serialize(config); await File.WriteAllTextAsync(configPath, yaml, cancellationToken).ConfigureAwait(false); return configPath; diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs index e363579a1..af718bbb6 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using CommunityToolkit.Aspire.Hosting.Kind; + namespace Aspire.Hosting.ApplicationModel; /// @@ -41,4 +43,15 @@ public class KindHelmChartResource(string name, string chartRef, KindClusterReso /// Gets the paths to values files (each maps to -f path). /// public List ValuesFiles { get; } = []; + + /// + /// Gets the inline Helm values that must be applied with --set-string key=value. + /// + public Dictionary StringValues { get; } = []; + + internal int CrdWaitRetryMaxAttempts { get; set; } = 1; + + internal TimeSpan CrdWaitRetryTimeout { get; set; } = KubectlTimeouts.DefaultCrdWaitTimeout; + + internal TimeSpan CrdWaitRetryBackoff { get; set; } = KubectlTimeouts.DefaultCrdWaitRetryBackoff; } diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs index 027fc121d..fc1c82b52 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs @@ -81,7 +81,7 @@ public static IResourceBuilder AddHelmChart( await notifications.WaitForResourceAsync(resource.Parent.Name, KnownResourceStates.Running, ct); await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, e.Services), ct); - + await notifications.PublishUpdateAsync(resource, state => state with { State = KnownResourceStates.Starting }); @@ -149,10 +149,75 @@ public static IResourceBuilder WithHelmValue( ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(value); + builder.Resource.StringValues.Remove(key); builder.Resource.Values[key] = value; return builder; } + /// + /// Sets a Helm value while preserving it as a string (maps to helm install --set-string key=value). + /// + /// The Helm chart resource builder. + /// The Helm value key. + /// The Helm value. + /// A reference to the . + /// + /// Use this overload when Helm would otherwise coerce the value into a numeric, boolean, + /// or other non-string type. If the same key was previously configured with + /// , + /// the string-preserving value replaces it. + /// + [AspireExport] + public static IResourceBuilder WithHelmStringValue( + this IResourceBuilder builder, + string key, + string value) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(value); + + builder.Resource.Values.Remove(key); + builder.Resource.StringValues[key] = value; + return builder; + } + + /// + /// Retries Helm installs that race newly-created CRDs which have not reached the + /// Established condition yet. + /// + /// The Helm chart resource builder. + /// The total number of install attempts. Must be 2 or greater. + /// + /// The initial delay before retrying. Later retries back off exponentially. + /// When , Kind uses a 5 second initial backoff. + /// + /// + /// The timeout used while waiting for newly discovered CRDs to become Established + /// between retry attempts. When , Kind uses a 5 minute timeout. + /// + /// A reference to the . + /// + /// This is useful for charts that create CRDs and immediately render custom resources + /// that depend on those CRDs. Between attempts, Kind waits for newly observed CRDs + /// to report Established. + /// + [AspireExport] + public static IResourceBuilder WithCrdWaitRetry( + this IResourceBuilder builder, + int maxAttempts = 3, + TimeSpan? backoff = null, + TimeSpan? crdWaitTimeout = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentOutOfRangeException.ThrowIfLessThan(maxAttempts, 2); + + builder.Resource.CrdWaitRetryMaxAttempts = maxAttempts; + builder.Resource.CrdWaitRetryBackoff = KubectlTimeouts.Normalize(backoff ?? KubectlTimeouts.DefaultCrdWaitRetryBackoff, nameof(backoff)); + builder.Resource.CrdWaitRetryTimeout = KubectlTimeouts.Normalize(crdWaitTimeout ?? KubectlTimeouts.DefaultCrdWaitTimeout, nameof(crdWaitTimeout)); + return builder; + } + /// /// Adds a values file (maps to helm install -f path). /// @@ -174,6 +239,10 @@ public static IResourceBuilder WithHelmValuesFile( /// /// Sets the Kubernetes namespace for the deployment. /// + /// + /// Helm chart resources pass --create-namespace. Manifest resources only pass + /// --namespace to kubectl apply; they do not create the namespace. + /// /// The deployed resource type. /// The resource builder. /// The Kubernetes namespace. @@ -192,4 +261,4 @@ public static IResourceBuilder WithNamespace( } } -#pragma warning restore ASPIREATS001 +#pragma warning restore ASPIREATS001 \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs new file mode 100644 index 000000000..79f141041 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs @@ -0,0 +1,292 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Kind; +using Microsoft.Extensions.DependencyInjection; + +#pragma warning disable ASPIREATS001 // AspireExport APIs are experimental + +namespace Aspire.Hosting; + +/// +/// Extension methods for adding Kubernetes manifest resources to Kind clusters. +/// +public static class KindManifestResourceBuilderExtensions +{ + /// + /// Adds a Kubernetes manifest to be applied to the Kind cluster via kubectl apply. + /// + /// The Kind cluster resource builder. + /// The name of the manifest resource. + /// + /// Absolute path to a Kubernetes manifest file, a directory of manifest files, + /// or a Kustomize overlay directory. Relative paths are not supported. + /// URL fetch is not supported; reference a local file or directory. + /// + /// A reference to the . + /// + /// The manifest is applied after the parent Kind cluster reaches the + /// state. Downstream resources that call + /// WaitFor on the manifest resource only start once kubectl apply succeeds. + /// Requires kubectl on PATH. + /// + [AspireExport] + public static IResourceBuilder AddManifest( + this IResourceBuilder builder, + [ResourceName] string name, + string manifestPath) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); + ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath); + if (!Path.IsPathRooted(manifestPath)) + { + throw new ArgumentException( + "Manifest path must be an absolute path. Pass a rooted file or directory path so published AppHosts do not depend on the original AppHost project location.", + nameof(manifestPath)); + } + + var resource = new K8sManifestResource(name, Path.GetFullPath(manifestPath), builder.Resource); + + return AddManifestResource(builder, resource); + } + + /// + /// Adds Kubernetes manifest content to be applied to the Kind cluster via kubectl apply -f -. + /// + /// The Kind cluster resource builder. + /// The name of the manifest resource. + /// The Kubernetes manifest YAML content. + /// A reference to the . + [AspireExport] + public static IResourceBuilder AddManifestFromContent( + this IResourceBuilder builder, + [ResourceName] string name, + string content) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(content); + + var resource = new K8sManifestResource(name, K8sManifestResource.InlineManifestPath, builder.Resource) + { + InlineContent = content, + }; + + return AddManifestResource(builder, resource); + } + + private static IResourceBuilder AddManifestResource( + IResourceBuilder builder, + K8sManifestResource resource) + { + var resourceBuilder = builder.ApplicationBuilder + .AddResource(resource) + .ExcludeFromManifest() + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "K8s Manifest", + State = KnownResourceStates.NotStarted, + Properties = [ + new("ManifestPath", resource.ManifestPath), + new("Mode", "apply"), + ] + }); + + resourceBuilder.OnInitializeResource(async (resource, e, ct) => + { + var notifications = e.Notifications; + var loggerService = e.Services.GetRequiredService(); + var logger = loggerService.GetLogger(resource); + + // Wait for the parent Kind cluster to be running before applying the manifest. + await notifications.WaitForResourceAsync(resource.Parent.Name, KnownResourceStates.Running, ct); + + await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, e.Services), ct); + + await notifications.PublishUpdateAsync(resource, + state => state with { State = KnownResourceStates.Starting }); + + try + { + var processRunner = e.Services.GetRequiredService(); + var kubectlManager = CreateKubectlManager(processRunner, resource); + await kubectlManager.ApplyAsync(resource, logger, ct); + var applyOptions = K8sManifestAnnotations.GetApplyOptions(resource); + + await notifications.PublishUpdateAsync(resource, + state => state with + { + State = KnownResourceStates.Running, + Properties = [ + new("ManifestPath", resource.ManifestPath), + new("Namespace", resource.Namespace ?? "(default)"), + new("ServerSide", applyOptions.ServerSide.ToString()), + new("Mode", applyOptions.IsKustomize ? "kustomize" : "apply"), + ] + }); + } + catch (Exception) + { + await notifications.PublishUpdateAsync(resource, + state => state with { State = KnownResourceStates.FailedToStart }); + throw; + } + }); + + return resourceBuilder; + } + + internal static KubectlManager CreateKubectlManager( + IProcessRunner processRunner, + K8sManifestResource resource) + { + ArgumentNullException.ThrowIfNull(processRunner); + ArgumentNullException.ThrowIfNull(resource); + + return new KubectlManager( + processRunner, + clusterInfoMaxWait: K8sManifestAnnotations.GetWaitPolicy(resource).ClusterReadyTimeout); + } + + /// + /// Recursively applies manifests from subdirectories (maps to kubectl apply --recursive). + /// Only meaningful when the manifest path is a directory. + /// + /// The manifest resource builder. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithRecursive( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + K8sManifestAnnotations.GetOrCreateApplyOptions(builder.Resource).Recursive = true; + return builder; + } + + /// + /// Applies the manifest server-side (maps to kubectl apply --server-side). + /// Required for large CRDs that exceed the client-side annotation size limit. + /// + /// The manifest resource builder. + /// + /// When , also passes --force-conflicts to override field ownership + /// held by another field manager (e.g., a controller). Defaults to . + /// + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithServerSideApply( + this IResourceBuilder builder, + bool forceConflicts = false) + { + ArgumentNullException.ThrowIfNull(builder); + + var applyOptions = K8sManifestAnnotations.GetOrCreateApplyOptions(builder.Resource); + applyOptions.ServerSide = true; + applyOptions.ForceConflicts = forceConflicts; + return builder; + } + + /// + /// Sets the field manager name passed to kubectl apply --field-manager. + /// + /// + /// The flag is passed whenever this method is used, but it is primarily meaningful with + /// server-side apply because Kubernetes records managed fields for server-side operations. + /// + /// The manifest resource builder. + /// The field manager name. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithFieldManager( + this IResourceBuilder builder, + string fieldManager) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(fieldManager); + + K8sManifestAnnotations.GetOrCreateApplyOptions(builder.Resource).FieldManager = fieldManager; + return builder; + } + + /// + /// Sets the maximum time to wait for the Kubernetes API to become reachable + /// before running kubectl apply. + /// + /// The manifest resource builder. + /// + /// The total readiness budget shared across repeated kubectl cluster-info probes. + /// Values are normalized to whole seconds and must fall within the supported timeout range. + /// + /// A reference to the . + /// + /// Use this when the cluster control plane can take longer than the default 60 seconds + /// to begin serving the Kubernetes API, such as on slower developer machines or busy CI hosts. + /// + [AspireExport] + public static IResourceBuilder WithClusterReadyTimeout( + this IResourceBuilder builder, + TimeSpan timeout) + { + ArgumentNullException.ThrowIfNull(builder); + + K8sManifestAnnotations.GetOrCreateWaitPolicy(builder.Resource).ClusterReadyTimeout = KubectlTimeouts.Normalize(timeout, nameof(timeout)); + return builder; + } + + /// + /// Sets the maximum time to wait for kubectl apply to complete. + /// + /// The manifest resource builder. + /// The apply timeout. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithApplyTimeout( + this IResourceBuilder builder, + TimeSpan timeout) + { + ArgumentNullException.ThrowIfNull(builder); + + K8sManifestAnnotations.GetOrCreateApplyOptions(builder.Resource).ApplyTimeout = KubectlTimeouts.Normalize(timeout, nameof(timeout)); + return builder; + } + + /// + /// Sets the maximum time to wait for applied CRDs to reach the Established condition. + /// + /// The manifest resource builder. + /// The CRD wait timeout. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithCrdWaitTimeout( + this IResourceBuilder builder, + TimeSpan timeout) + { + ArgumentNullException.ThrowIfNull(builder); + + K8sManifestAnnotations.GetOrCreateWaitPolicy(builder.Resource).Crd.Timeout = KubectlTimeouts.Normalize(timeout, nameof(timeout)); + return builder; + } + + /// + /// Sets whether CRD wait failures fail the manifest resource or are logged as best-effort warnings. + /// + /// The manifest resource builder. + /// The CRD wait behavior. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithCrdWaitBehavior( + this IResourceBuilder builder, + CrdWaitBehavior behavior) + { + ArgumentNullException.ThrowIfNull(builder); + + K8sManifestAnnotations.GetOrCreateWaitPolicy(builder.Resource).Crd.FailureBehavior = behavior; + return builder; + } + +} + +#pragma warning restore ASPIREATS001 \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs new file mode 100644 index 000000000..797f8faca --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs @@ -0,0 +1,540 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Retry; +using System.ComponentModel; +using System.Diagnostics; + +namespace CommunityToolkit.Aspire.Hosting.Kind; + +/// +/// Manages Kubernetes manifest applies to a Kind cluster by orchestrating kubectl CLI calls. +/// +internal sealed class KubectlManager( + IProcessRunner processRunner, + Func? delayAsync = null, + TimeSpan? clusterInfoMaxWait = null, + TimeSpan? clusterInfoProbeTimeout = null) +{ + private const string KubectlNotFoundMessage = "kubectl CLI not found. Install it from https://kubernetes.io/docs/tasks/tools/"; + private static readonly TimeSpan ClusterInfoMaxWait = TimeSpan.FromSeconds(60); + private static readonly TimeSpan ClusterInfoProbeTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan ClusterInfoInitialDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan ClusterInfoMaxDelay = TimeSpan.FromSeconds(10); + + private readonly Func _delayAsync = delayAsync ?? Task.Delay; + private readonly TimeSpan _clusterInfoMaxWait = clusterInfoMaxWait ?? ClusterInfoMaxWait; + private readonly TimeSpan _clusterInfoProbeTimeout = clusterInfoProbeTimeout ?? ClusterInfoProbeTimeout; + + internal TimeSpan ClusterInfoMaxWaitForTesting => _clusterInfoMaxWait; + + /// + /// Waits for the cluster API to answer, then applies the manifest via kubectl apply. + /// Callers should pass the manifest resource's scoped logger from . + /// + public async Task ApplyAsync(K8sManifestResource resource, ILogger resourceLogger, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + var applyOptions = K8sManifestAnnotations.GetApplyOptions(resource); + + await WaitForClusterInfoAsync(resource, resourceLogger, cancellationToken).ConfigureAwait(false); + ValidateRecursiveManifestTarget(resource); + LogConfigurationWarnings(resource, resourceLogger, applyOptions); + + var args = CreateApplyArguments(resource); + + resourceLogger.LogInformation( + "Applying manifest '{ManifestPath}' to cluster '{ClusterName}'...", + resource.ManifestPath, resource.Parent.Name); + + ProcessResult result; + var applyTimeout = KubectlTimeouts.Normalize(applyOptions.ApplyTimeout, nameof(resource.ApplyTimeout)); + using (var applyCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + applyCts.CancelAfter(applyTimeout); + + try + { + result = await RunKubectlAsync( + resourceLogger, + args, + standardInput: resource.InlineContent, + cancellationToken: applyCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"Timed out applying manifest '{resource.ManifestPath}' to cluster '{resource.Parent.Name}' after {applyTimeout}."); + } + } + + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"Failed to apply manifest '{resource.ManifestPath}' to cluster '{resource.Parent.Name}': {FormatFailureOutput(result)}"); + } + + var crdNames = GetAppliedCrdNames(result.Output); + if (crdNames.Count > 0) + { + await WaitForCrdsAsync(crdNames, resource, resourceLogger, cancellationToken).ConfigureAwait(false); + } + + resourceLogger.LogInformation( + "Manifest '{ManifestPath}' applied successfully.", resource.ManifestPath); + } + + /// + /// Waits for applied CRDs to reach the Kubernetes Established condition. + /// + internal async Task WaitForCrdsAsync( + IEnumerable crdNames, + K8sManifestResource resource, + ILogger resourceLogger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(crdNames); + ArgumentNullException.ThrowIfNull(resource); + + var crds = crdNames.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (crds.Length == 0) + { + return; + } + + var waitPolicy = K8sManifestAnnotations.GetWaitPolicy(resource); + var args = CreateWaitArguments(crds, resource.Parent.KubeconfigPath, waitPolicy.Crd.Timeout); + + resourceLogger.LogInformation( + "Waiting for {CrdCount} custom resource definition(s) to become Established...", + crds.Length); + + var result = await RunKubectlAsync( + resourceLogger, + args, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.ExitCode != 0) + { + var message = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error; + if (waitPolicy.Crd.FailureBehavior == CrdWaitBehavior.BestEffort) + { + resourceLogger.LogWarning( + "Timed out or failed while waiting for custom resource definition(s) to become Established: {Error}", + message); + return; + } + + throw new InvalidOperationException( + $"Timed out or failed while waiting for custom resource definition(s) to become Established: {message}"); + } + } + + internal Task WaitForCrdsAsync( + IEnumerable crdNames, + string kubeconfigPath, + TimeSpan timeout, + ILogger resourceLogger, + CancellationToken cancellationToken) => + WaitForCrdsCoreAsync(crdNames, kubeconfigPath, timeout, resourceLogger, cancellationToken, bestEffort: false); + + internal async Task> GetCustomResourceDefinitionsAsync( + string kubeconfigPath, + ILogger resourceLogger, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath); + + var result = await RunKubectlAsync( + resourceLogger, + CreateGetCrdsArguments(kubeconfigPath), + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + "Failed to query custom resource definitions: " + + (string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error)); + } + + return ParseResourceNames(result.Output); + } + + /// + /// Creates the kubectl apply argument list for a manifest resource. + /// + internal static IReadOnlyList CreateApplyArguments(K8sManifestResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + var applyOptions = K8sManifestAnnotations.GetApplyOptions(resource); + var isKustomize = resource.InlineContent is null && + Directory.Exists(resource.ManifestPath) && + IsKustomizeDirectory(resource.ManifestPath); + K8sManifestAnnotations.GetOrCreateApplyOptions(resource).IsKustomize = isKustomize; + + List arguments = + [ + "apply", + ]; + + if (resource.InlineContent is not null) + { + arguments.Add("-f"); + arguments.Add("-"); + } + else if (isKustomize) + { + arguments.Add("-k"); + arguments.Add(resource.ManifestPath); + } + else + { + arguments.Add("-f"); + arguments.Add(resource.ManifestPath); + } + + arguments.Add($"--kubeconfig={resource.Parent.KubeconfigPath}"); + + if (!string.IsNullOrEmpty(resource.Namespace)) + { + arguments.Add("--namespace"); + arguments.Add(resource.Namespace); + } + + if (applyOptions.Recursive && !isKustomize && resource.InlineContent is null) + { + arguments.Add("--recursive"); + } + + if (applyOptions.ServerSide) + { + arguments.Add("--server-side"); + + if (applyOptions.ForceConflicts) + { + arguments.Add("--force-conflicts"); + } + } + + if (!string.IsNullOrEmpty(applyOptions.FieldManager)) + { + arguments.Add("--field-manager"); + arguments.Add(applyOptions.FieldManager); + } + + return arguments; + } + + /// + /// Creates the kubectl wait argument list for applied CRDs. + /// + internal static IReadOnlyList CreateWaitArguments( + IEnumerable crdNames, + string kubeconfigPath, + TimeSpan timeout) + { + ArgumentNullException.ThrowIfNull(crdNames); + ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath); + + List arguments = + [ + "wait", + "--for=condition=Established", + ]; + + arguments.AddRange(crdNames); + arguments.Add($"--timeout={KubectlTimeouts.ToSeconds(timeout, nameof(timeout))}s"); + arguments.Add($"--kubeconfig={kubeconfigPath}"); + + return arguments; + } + + /// + /// Creates the kubectl cluster-info argument list for an API reachability probe. + /// + internal static IReadOnlyList CreateClusterInfoArguments(string kubeconfigPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath); + + return + [ + "cluster-info", + $"--kubeconfig={kubeconfigPath}", + ]; + } + + internal static IReadOnlyList CreateGetCrdsArguments(string kubeconfigPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath); + + return + [ + "get", + "crd", + "-o", + "name", + $"--kubeconfig={kubeconfigPath}", + ]; + } + + /// + /// Returns whether the directory contains a Kustomize marker file recognized by kubectl apply -k. + /// + internal static bool IsKustomizeDirectory(string directory) + { + ArgumentNullException.ThrowIfNull(directory); + + if (!Directory.Exists(directory)) + { + return false; + } + + return Directory.EnumerateFiles(directory, "*", SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .Any(fileName => + string.Equals(fileName, "kustomization.yaml", StringComparison.OrdinalIgnoreCase) || + string.Equals(fileName, "kustomization.yml", StringComparison.OrdinalIgnoreCase) || + string.Equals(fileName, "kustomization", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Waits until the cluster API server is reachable through kubectl cluster-info. + /// + internal async Task WaitForClusterInfoAsync( + K8sManifestResource resource, + ILogger resourceLogger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + var args = CreateClusterInfoArguments(resource.Parent.KubeconfigPath); + string? lastFailureMessage = null; + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_clusterInfoMaxWait); + + var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = int.MaxValue, + Delay = TimeSpan.Zero, + UseJitter = false, + ShouldHandle = new PredicateBuilder() + .HandleResult(static result => result.ExitCode != 0), + OnRetry = async arguments => + { + var delay = ComputeClusterInfoRetryDelay(arguments.AttemptNumber + 1); + var result = arguments.Outcome.Result!; + var error = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error; + resourceLogger.LogWarning( + "Cluster '{ClusterName}' API is not reachable yet; retrying kubectl cluster-info in {DelaySeconds:n1}s. Last error: {Error}", + resource.Parent.Name, + delay.TotalSeconds, + error); + await _delayAsync(delay, arguments.Context.CancellationToken).ConfigureAwait(false); + } + }) + .Build(); + + try + { + await pipeline.ExecuteAsync(async token => + { + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(token); + probeCts.CancelAfter(_clusterInfoProbeTimeout); + + try + { + var result = await RunKubectlAsync( + resourceLogger, + args, + cancellationToken: probeCts.Token).ConfigureAwait(false); + lastFailureMessage = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error; + return result; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && !timeoutCts.IsCancellationRequested) + { + lastFailureMessage = "kubectl cluster-info probe timed out."; + return new ProcessResult(1, "", lastFailureMessage); + } + }, timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new InvalidOperationException( + $"Timed out waiting for cluster '{resource.Parent.Name}' API to become reachable" + + (string.IsNullOrWhiteSpace(lastFailureMessage) ? "." : $": {lastFailureMessage}")); + } + } + + private static TimeSpan Min(TimeSpan left, TimeSpan right) + { + return left <= right ? left : right; + } + + private static TimeSpan ComputeClusterInfoRetryDelay(int failureCount) + { + var delay = TimeSpan.FromSeconds(ClusterInfoInitialDelay.TotalSeconds * Math.Pow(2, failureCount - 1)); + return Min(delay, ClusterInfoMaxDelay); + } + + private async Task RunKubectlAsync( + ILogger resourceLogger, + IReadOnlyList arguments, + string? standardInput = null, + CancellationToken cancellationToken = default) + { + try + { + return await processRunner.RunAsync( + resourceLogger, + "kubectl", + arguments, + standardInput: standardInput, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (Win32Exception ex) + { + throw new InvalidOperationException(KubectlNotFoundMessage, ex); + } + } + + private static string FormatFailureOutput(ProcessResult result) + { + var messages = new[] + { + result.Error?.Trim(), + result.Output?.Trim(), + }; + + return string.Join( + Environment.NewLine, + messages.Where(static message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal)); + } + + private static void ValidateRecursiveManifestTarget(K8sManifestResource resource) + { + var applyOptions = K8sManifestAnnotations.GetApplyOptions(resource); + if (!applyOptions.Recursive || resource.InlineContent is not null || applyOptions.IsKustomize) + { + return; + } + + if (!Directory.Exists(resource.ManifestPath)) + { + throw new InvalidOperationException( + $"Manifest '{resource.ManifestPath}' must be an existing directory when {nameof(KindManifestResourceBuilderExtensions.WithRecursive)} is used."); + } + } + + private static void LogConfigurationWarnings( + K8sManifestResource resource, + ILogger resourceLogger, + K8sManifestApplyOptionsAnnotation applyOptions) + { + if (applyOptions.Recursive && applyOptions.IsKustomize) + { + resourceLogger.LogWarning( + "Ignoring recursive apply for Kustomize manifest '{ManifestPath}' because kubectl apply -k does not support --recursive.", + resource.ManifestPath); + } + else if (applyOptions.Recursive && resource.InlineContent is not null) + { + resourceLogger.LogWarning( + "Ignoring recursive apply for inline manifest '{ManifestPath}' because kubectl apply -f - does not support --recursive.", + resource.ManifestPath); + } + } + + /// + /// Extracts CRD resource names from kubectl apply output. + /// + private static IReadOnlyList GetAppliedCrdNames(string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return []; + } + + var crds = new HashSet(StringComparer.OrdinalIgnoreCase); + + using var reader = new StringReader(output); + string? line; + while ((line = reader.ReadLine()) is not null) + { + if (!line.StartsWith("customresourcedefinition.", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var resourceName = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(resourceName)) + { + crds.Add(resourceName); + } + } + + return [.. crds]; + } + + private async Task WaitForCrdsCoreAsync( + IEnumerable crdNames, + string kubeconfigPath, + TimeSpan timeout, + ILogger resourceLogger, + CancellationToken cancellationToken, + bool bestEffort) + { + ArgumentNullException.ThrowIfNull(crdNames); + ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath); + + var crds = crdNames.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (crds.Length == 0) + { + return; + } + + var args = CreateWaitArguments(crds, kubeconfigPath, timeout); + + resourceLogger.LogInformation( + "Waiting for {CrdCount} custom resource definition(s) to become Established...", + crds.Length); + + var result = await RunKubectlAsync( + resourceLogger, + args, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.ExitCode == 0) + { + return; + } + + var message = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error; + if (bestEffort) + { + resourceLogger.LogWarning( + "Timed out or failed while waiting for custom resource definition(s) to become Established: {Error}", + message); + return; + } + + throw new InvalidOperationException( + $"Timed out or failed while waiting for custom resource definition(s) to become Established: {message}"); + } + + private static IReadOnlySet ParseResourceNames(string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + return output + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs new file mode 100644 index 000000000..cee6dda79 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace CommunityToolkit.Aspire.Hosting.Kind; + +internal static class KubectlTimeouts +{ + internal static readonly TimeSpan DefaultApplyTimeout = TimeSpan.FromMinutes(5); + internal static readonly TimeSpan DefaultCrdWaitTimeout = TimeSpan.FromMinutes(5); + internal static readonly TimeSpan DefaultCrdWaitRetryBackoff = TimeSpan.FromSeconds(5); + internal static readonly TimeSpan MaximumTimeout = TimeSpan.FromHours(1); + + internal static TimeSpan Normalize(TimeSpan timeout, string parameterName) + { + if (timeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(parameterName, timeout, "Timeout must be greater than zero."); + } + + if (timeout > MaximumTimeout) + { + throw new ArgumentOutOfRangeException(parameterName, timeout, $"Timeout must be less than or equal to {MaximumTimeout}."); + } + + return TimeSpan.FromSeconds(Math.Ceiling(timeout.TotalSeconds)); + } + + internal static int ToSeconds(TimeSpan timeout, string parameterName) + { + return (int)Normalize(timeout, parameterName).TotalSeconds; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md index 1cb74338b..5bb79a3fb 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md @@ -6,6 +6,7 @@ An [Aspire](https://learn.microsoft.com/dotnet/aspire) hosting integration that - **Docker or Podman** - Kind runs Kubernetes nodes as containers. Install [Docker](https://docs.docker.com/get-docker/) or [Podman](https://podman.io/docs/installation). - **Kind CLI** - The `kind` command must be available on your `PATH`. Install from [kind.sigs.k8s.io](https://kind.sigs.k8s.io/docs/user/quick-start/#installation). +- **kubectl CLI** - Required for `AddManifest` / `AddManifestFromContent`. Install from [kubernetes.io](https://kubernetes.io/docs/tasks/tools/). - **Helm CLI** - Required for deploy scenarios and Helm chart resources. Install from [helm.sh](https://helm.sh/docs/intro/install/). ## Getting started @@ -26,7 +27,7 @@ var cluster = builder.AddKindCluster("mycluster"); builder.Build().Run(); ``` -This creates a Kind cluster named **mycluster** that is provisioned when the AppHost starts and deleted when it shuts down. +This creates a Kind cluster named **mycluster** that is provisioned when the AppHost starts and is cleaned up during graceful host shutdown (via `ApplicationStopping`) or when the lifecycle hook is disposed, on a best-effort basis. ## Scenario 1: Kind cluster as a managed dependency (F5 mode) @@ -52,9 +53,27 @@ var cluster = builder.AddKindCluster("mycluster") .WithKubernetesVersion("v1.32.2"); ``` +#### WithNodeImage + +Use `WithNodeImage` when you need to pin the full Kind node image name instead of composing one from a Kubernetes version. This is useful with private mirrors or pre-approved node images. + +```csharp +var cluster = builder.AddKindCluster("mycluster") + .WithNodeImage("registry.example.com/kindest/node:v1.32.2"); +``` + +#### WithNodeMount + +Use `WithNodeMount` to project host content into every Kind node. This is useful for local charts, registries, or other host-side assets that must be visible from inside the cluster nodes. Relative host paths are resolved against the AppHost project directory. The sample AppHost shows this as a cluster-configuration example; it does not include a workload that reads the mounted path from inside the cluster. + +```csharp +var cluster = builder.AddKindCluster("mycluster") + .WithNodeMount(@"C:\dev\charts", "/var/local/charts", readOnly: true); +``` + #### Cluster lifetime -By default the cluster is deleted when the AppHost shuts down (`ClusterLifetime.Session`). To keep the cluster across AppHost restarts, use `ClusterLifetime.Persistent`: +By default the cluster is deleted during graceful host shutdown (`ClusterLifetime.Session`) or when the lifecycle hook is disposed, on a best-effort basis. To keep the cluster across AppHost restarts, use `ClusterLifetime.Persistent`: ```csharp var cluster = builder.AddKindCluster("mycluster") @@ -63,7 +82,7 @@ var cluster = builder.AddKindCluster("mycluster") | Value | Behavior | |---|---| -| `ClusterLifetime.Session` | Cluster is deleted on AppHost shutdown (default). | +| `ClusterLifetime.Session` | Cluster is deleted during graceful host shutdown or lifecycle-hook disposal on a best-effort basis (default). | | `ClusterLifetime.Persistent` | Cluster survives AppHost restarts and is reused on next startup. | ## Networking model @@ -150,9 +169,109 @@ var cluster = builder.AddKindCluster("mycluster") var redis = cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") .WithChartVersion("20.0.0") .WithHelmValue("replica.replicaCount", "0") + .WithHelmStringValue("auth.password", "000123") + .WithCrdWaitRetry() .WithNamespace("cache"); ``` +#### WithHelmStringValue + +Use `WithHelmStringValue` when a value looks numeric or boolean but must remain a string in the rendered chart. Kind emits `--set-string` for these values instead of `--set`. + +```csharp +var redis = cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithHelmStringValue("auth.password", "000123"); +``` + +#### WithCrdWaitRetry + +Use `WithCrdWaitRetry` as an explicit opt-in retry policy for Helm installs. Without it, Kind makes a single Helm attempt and surfaces the original failure immediately. With it, Kind retries failed installs up to the configured attempt count, waits for any newly observed CRDs to reach `Established` between attempts, and then re-runs Helm after the configured backoff. + +Prefer `.WaitFor(...)` ordering or packaging tightly coupled CRDs and dependents into a single chart when you can; `WithCrdWaitRetry` is a fallback for charts that still need bounded retry behavior. + +```csharp +var certManager = cluster.AddHelmChart("cert-manager", "jetstack/cert-manager") + .WithCrdWaitRetry( + maxAttempts: 3, + backoff: TimeSpan.FromSeconds(5), + crdWaitTimeout: TimeSpan.FromMinutes(2)); +``` + +### Applying raw manifests to the cluster + +Use `AddManifest` to apply a Kubernetes manifest (file, directory, or Kustomize overlay) to the cluster after it becomes healthy. This runs `kubectl apply -f --kubeconfig ` against the cluster kubeconfig and is the natural equivalent of `AddHelmChart` for scenarios where a chart would be overkill. Manifest paths must be absolute so published AppHosts do not depend on the original AppHost project directory. + +`AddManifest` supports a single file, a directory, or a Kustomize overlay. URL fetch is not supported today — use a local file. For URL support, `curl` the file down as a build step and reference the local path. + +If the path is a directory containing a Kustomize marker file such as `kustomization.yaml`, `kustomization.yml`, `Kustomization`, or `Kustomization.yml`, Kind automatically switches to `kubectl apply -k ` at apply time. `WithRecursive()` is ignored for Kustomize overlays because `kubectl apply -k` does not support `--recursive`. + +```csharp +var cluster = builder.AddKindCluster("mycluster"); +var manifestsRoot = Path.Combine(builder.AppHostDirectory, "manifests"); + +// Single file +cluster.AddManifest("crds", Path.Combine(manifestsRoot, "crds.yaml")); + +// Directory, recursive, into a namespace +cluster.AddManifest("platform", Path.Combine(manifestsRoot, "platform")) + .WithRecursive() + .WithNamespace("platform"); + +// Server-side apply with a stable field manager (useful for CRDs and controllers +// that reconcile large objects) +cluster.AddManifest("operator", Path.Combine(manifestsRoot, "argocd", "install.yaml")) + .WithServerSideApply(forceConflicts: true) + .WithFieldManager("aspire-apphost"); + +// Inline content, applied via kubectl apply -f - +cluster.AddManifestFromContent("demo-ns", """ + apiVersion: v1 + kind: Namespace + metadata: + name: aspire-demo + """); +``` + +Downstream resources can wait on the manifest resource before starting so they only see the cluster after the manifests have been applied: + +```csharp +var crds = cluster.AddManifest("crds", Path.Combine(manifestsRoot, "crds.yaml")); + +var operatorContainer = builder.AddContainer("my-operator", "my-org/operator") + .WithReference(cluster) + .WaitFor(crds); +``` + +Manifests persist with the cluster - deleted with session clusters, retained with persistent clusters. + +When `kubectl apply` reports custom resource definitions, Kind waits up to 5 minutes for those CRDs to reach the `Established` condition before marking the manifest resource running. The default behavior is fail-fast: a CRD wait timeout fails the manifest resource. Use `.WithCrdWaitTimeout(...)` to adjust the timeout, or `.WithCrdWaitBehavior(CrdWaitBehavior.BestEffort)` to log a warning and continue. + +#### WithClusterReadyTimeout + +If the Kubernetes API needs longer to become reachable before `kubectl apply`, use `WithClusterReadyTimeout` to extend the `kubectl cluster-info` readiness budget for that manifest resource. + +```csharp +cluster.AddManifest("platform", Path.Combine(builder.AppHostDirectory, "manifests", "platform")) + .WithClusterReadyTimeout(TimeSpan.FromMinutes(2)); +``` + +#### Namespace behavior + +`.WithNamespace(ns)` passes `--namespace ` to `kubectl apply`, but it does **not** create the namespace. This differs from `AddHelmChart`, which passes `--create-namespace`. + +If a manifest declares its own `metadata.namespace` that conflicts with `--namespace`, `kubectl` rejects the apply. Prefer one of these patterns: + +- Create the namespace with a separate `AddManifest("ns", Path.Combine(builder.AppHostDirectory, "manifests", "namespace.yaml"))` and `.WaitFor()` it before applying namespaced manifests. +- Omit `.WithNamespace()` and let each manifest declare its own namespace. + +#### WithServerSideApply + +> **Warning:** `forceConflicts: true` overrides field ownership held by other controllers. Use sparingly — you're telling `kubectl` to overwrite whatever another controller (for example, Helm) had set for those fields. + +#### Security scope + +Manifests apply with the cluster kubeconfig, which for a local Kind cluster is typically cluster-admin. `AddManifest` can create cluster-scoped resources such as `ClusterRole`, `ClusterRoleBinding`, and CRDs. Only apply manifests you trust. + ### Full F5 example ```csharp @@ -235,9 +354,28 @@ builder.AddContainer("my-container", "my-image") | `WithReference(kind)` | Injects `KUBECONFIG` and `K8S_CLUSTER_NAME` into another resource | | `WithKindNetwork()` | Connects a container to the Kind container network | | `AddHelmChart(name, chartRef)` | Deploys a Helm chart to the Kind cluster during F5 | +| `AddManifest(name, manifestPath)` | Applies a Kubernetes manifest (file or directory) via `kubectl apply` after the cluster is healthy | +| `AddManifestFromContent(name, content)` | Applies inline Kubernetes manifest content via `kubectl apply -f -` after the cluster is healthy | +| `WithRecursive()` | For `AddManifest`: recurse into subdirectories when applying | +| `WithServerSideApply(bool forceConflicts = false)` | For `AddManifest`: use server-side apply, optionally with `--force-conflicts` | +| `WithFieldManager(string)` | For `AddManifest`: set the `kubectl apply --field-manager` identifier | +| `WithApplyTimeout(TimeSpan)` | For `AddManifest`: set the maximum time for `kubectl apply` | +| `WithCrdWaitTimeout(TimeSpan)` | For `AddManifest`: set the CRD `Established` wait timeout | +| `WithCrdWaitBehavior(CrdWaitBehavior)` | For `AddManifest`: choose fail-fast or best-effort CRD wait behavior | | `WithKind()` | Configures a `KubernetesEnvironmentResource` to deploy to a local Kind cluster (scenario 2) | + +## Security & scope notes + +- Absolute manifest paths are not sandboxed; do not apply untrusted manifests. +- `AddManifestFromContent(string)` holds stdin content in memory. For very large manifests, use `AddManifest(file)` instead. +- `AddManifest` runs with whatever authority the cluster kubeconfig has — cluster-admin on a default Kind cluster. + ## Additional information - [Kind documentation](https://kind.sigs.k8s.io/) - [.NET Aspire documentation](https://learn.microsoft.com/dotnet/aspire) - [Aspire Community Toolkit](https://github.com/CommunityToolkit/Aspire) +- [`kubectl apply` documentation](https://kubernetes.io/docs/reference/kubectl/generated/kubectl_apply/) +- [Kubernetes server-side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/) +- [Kustomize documentation](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/) +- [CRD Established condition](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs index 05db84cd2..841fc7f32 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/AddKindClusterTests.cs @@ -1,12 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.InteropServices; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; +using Aspire.Hosting.Eventing; using Aspire.Hosting.Lifecycle; using CommunityToolkit.Aspire.Testing; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using System.Runtime.InteropServices; namespace CommunityToolkit.Aspire.Hosting.Kind.Tests; @@ -15,7 +19,7 @@ public class AddKindClusterTests [Fact] public void AddKindClusterCreatesResource() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); builder.AddKindCluster("test-cluster"); @@ -29,7 +33,7 @@ public void AddKindClusterCreatesResource() [Fact] public async Task WithKubernetesVersionSetsVersion() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); builder.AddKindCluster("test-cluster") .WithKubernetesVersion("v1.32.2"); @@ -50,10 +54,108 @@ public async Task WithKubernetesVersionSetsVersion() } } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void WithKubernetesVersionAndNodeImageConflict_Throws(bool setVersionFirst) + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + if (setVersionFirst) + { + cluster.WithKubernetesVersion("v1.32.2"); + Assert.Throws(() => cluster.WithNodeImage("kindest/node:v1.32.2")); + return; + } + + cluster.WithNodeImage("kindest/node:v1.32.2"); + Assert.Throws(() => cluster.WithKubernetesVersion("v1.32.2")); + } + + [Fact] + public async Task WithNodeImageSetsImageOnAllNodes() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddKindCluster("test-cluster") + .WithWorkerNodes(2) + .WithNodeImage("myacr.azurecr.io/kindest/node:v1.32.2"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + var configPath = await KindConfigGenerator.GenerateConfigAsync(resource, CancellationToken.None); + try + { + var yaml = await File.ReadAllTextAsync(configPath); + Assert.Equal(3, yaml.Split("image: myacr.azurecr.io/kindest/node:v1.32.2").Length - 1); + } + finally + { + File.Delete(configPath); + } + } + + [Fact] + public async Task WithNodeMountAddsMountOnAllNodes() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var hostPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "host-data")); + + builder.AddKindCluster("test-cluster") + .WithWorkerNodes(1) + .WithNodeMount(hostPath, "/container-data", readOnly: true); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + var configPath = await KindConfigGenerator.GenerateConfigAsync(resource, CancellationToken.None); + try + { + var yaml = await File.ReadAllTextAsync(configPath); + Assert.Equal(2, yaml.Split($"hostPath: {hostPath}").Length - 1); + Assert.Equal(2, yaml.Split("containerPath: /container-data").Length - 1); + Assert.Equal(2, yaml.Split("readOnly: true").Length - 1); + } + finally + { + File.Delete(configPath); + } + } + + [Fact] + public async Task WithNodeMountResolvesRelativeHostPathFromAppHostDirectory() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var relativeHostPath = Path.Combine("mounts", "charts"); + var expectedHostPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, relativeHostPath)); + + builder.AddKindCluster("test-cluster") + .WithNodeMount(relativeHostPath, "/container-data"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + var configPath = await KindConfigGenerator.GenerateConfigAsync(resource, CancellationToken.None); + try + { + var yaml = await File.ReadAllTextAsync(configPath); + Assert.Contains($"hostPath: {expectedHostPath}", yaml); + } + finally + { + File.Delete(configPath); + } + } + [Fact] public async Task WithWorkerNodesSetsCount() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); builder.AddKindCluster("test-cluster") .WithWorkerNodes(3); @@ -78,7 +180,7 @@ public async Task WithWorkerNodesSetsCount() [Fact] public async Task DefaultsAreCorrect() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); builder.AddKindCluster("test-cluster"); @@ -103,7 +205,7 @@ public async Task DefaultsAreCorrect() [Fact] public void WithWorkerNodesRejectsNegative() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var resourceBuilder = builder.AddKindCluster("test-cluster"); Assert.Throws(() => resourceBuilder.WithWorkerNodes(-1)); @@ -120,11 +222,56 @@ public void AddKindClusterThrowsOnNullBuilder() [Fact] public void AddKindClusterThrowsOnNullName() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); Assert.Throws(() => builder.AddKindCluster(null!)); } + [Fact] + public void WithNodeImageRejectsNull() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => cluster.WithNodeImage(null!)); + } + + [Fact] + public void WithNodeMountRejectsNullHostPath() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => cluster.WithNodeMount(null!, "/container-data")); + } + + [Fact] + public void WithNodeMountRejectsNullContainerPath() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => cluster.WithNodeMount(@"C:\host-data", null!)); + } + + [Fact] + public void WithNodeMountRejectsEmptyHostPath() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => cluster.WithNodeMount("", "/container-data")); + } + + [Fact] + public void WithNodeMountRejectsRelativeContainerPath() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => cluster.WithNodeMount(@"C:\host-data", "container-data")); + } + [Fact] public async Task GeneratedConfigContainsImageFromKindContainerImageTags() { @@ -154,7 +301,7 @@ public async Task GeneratedConfigContainsImageFromKindContainerImageTags() [Fact] public void WithReferenceInjectsEnvironmentAnnotation() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var kind = builder.AddKindCluster("test-cluster"); builder.AddResource(new TestResource("svc")) @@ -170,7 +317,7 @@ public void WithReferenceInjectsEnvironmentAnnotation() [Fact] public async Task WithReference_NonContainer_SetsHostKubeconfigEnvironmentValue() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var kind = builder.AddKindCluster("test-cluster"); var service = builder.AddResource(new TestResource("svc")) @@ -186,7 +333,7 @@ public async Task WithReference_NonContainer_SetsHostKubeconfigEnvironmentValue( [Fact] public void DefaultClusterLifetimeIsSession() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); builder.AddKindCluster("test-cluster"); @@ -201,7 +348,7 @@ public void DefaultClusterLifetimeIsSession() [Fact] public void WithClusterLifetimeSetsAnnotation() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); builder.AddKindCluster("test-cluster") .WithClusterLifetime(ClusterLifetime.Persistent); @@ -217,7 +364,7 @@ public void WithClusterLifetimeSetsAnnotation() [Fact] public void AddKindClusterRegistersLifecycleHook() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); builder.AddKindCluster("test-cluster"); @@ -229,6 +376,119 @@ public void AddKindClusterRegistersLifecycleHook() Assert.NotNull(descriptor); } + [Fact] + public async Task KindClusterLifecycleHook_CleansUpOnApplicationStopping() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.AddKindCluster("test-cluster"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var loggerService = app.Services.GetRequiredService(); + var processRunner = new FakeProcessRunner(); + var hostLifetime = new TestHostApplicationLifetime(); + var hook = new KindClusterLifecycleHook( + model, + loggerService, + processRunner, + new TestKindContainerRuntimeResolver(), + hostLifetime); + + await hook.SubscribeAsync(new NoOpEventing(), null!); + hostLifetime.StopApplication(); + + await WaitForConditionAsync(() => + processRunner.Commands.Any(command => command.FileName == "kind" && command.Arguments.Contains("delete cluster --name=test-cluster", StringComparison.Ordinal))); + + Assert.Single( + processRunner.Commands, + command => command.FileName == "kind" && command.Arguments.Contains("delete cluster --name=test-cluster", StringComparison.Ordinal)); + + await hook.DisposeAsync(); + } + + [Fact] + public async Task KindClusterLifecycleHook_CleansUpOnDisposeAsyncWithoutApplicationStopping() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.AddKindCluster("test-cluster"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var loggerService = app.Services.GetRequiredService(); + var processRunner = new FakeProcessRunner(); + var hostLifetime = new TestHostApplicationLifetime(); + var hook = new KindClusterLifecycleHook( + model, + loggerService, + processRunner, + new TestKindContainerRuntimeResolver(), + hostLifetime); + + await hook.SubscribeAsync(new NoOpEventing(), null!); + await hook.DisposeAsync(); + + Assert.Single( + processRunner.Commands, + command => command.FileName == "kind" && command.Arguments.Contains("delete cluster --name=test-cluster", StringComparison.Ordinal)); + } + + [Fact] + public async Task KindClusterLifecycleHook_DoesNotDoubleDeleteWhenStoppingThenDisposed() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.AddKindCluster("test-cluster"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var loggerService = app.Services.GetRequiredService(); + var processRunner = new FakeProcessRunner(); + var hostLifetime = new TestHostApplicationLifetime(); + var hook = new KindClusterLifecycleHook( + model, + loggerService, + processRunner, + new TestKindContainerRuntimeResolver(), + hostLifetime); + + await hook.SubscribeAsync(new NoOpEventing(), null!); + hostLifetime.StopApplication(); + await WaitForConditionAsync(() => + processRunner.Commands.Any(command => command.FileName == "kind" && command.Arguments.Contains("delete cluster --name=test-cluster", StringComparison.Ordinal))); + + await hook.DisposeAsync(); + + Assert.Single( + processRunner.Commands, + command => command.FileName == "kind" && command.Arguments.Contains("delete cluster --name=test-cluster", StringComparison.Ordinal)); + } + + [Fact] + public async Task KindClusterLifecycleHook_DoesNotDeletePersistentClustersOnApplicationStopping() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.AddKindCluster("persistent-cluster") + .WithClusterLifetime(ClusterLifetime.Persistent); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var loggerService = app.Services.GetRequiredService(); + var processRunner = new FakeProcessRunner(); + var hostLifetime = new TestHostApplicationLifetime(); + var hook = new KindClusterLifecycleHook( + model, + loggerService, + processRunner, + new TestKindContainerRuntimeResolver(), + hostLifetime); + + await hook.SubscribeAsync(new NoOpEventing(), null!); + hostLifetime.StopApplication(); + await hook.DisposeAsync(); + + Assert.DoesNotContain(processRunner.Commands, command => command.FileName == "kind" && command.Arguments.Contains("delete cluster", StringComparison.Ordinal)); + } + // ── KindConfigGenerator tests ──────────────────────────────────────── [Fact] @@ -470,7 +730,7 @@ public async Task WithKubernetesVersionAndWorkerNodes_BothOrders_AllNodesHaveIma var expectedImage = $"{"kindest/node"}:v1.31.0"; // Order 1: workers first, then version - var builder1 = DistributedApplication.CreateBuilder(); + using var builder1 = TestDistributedApplicationBuilder.Create(); builder1.AddKindCluster("order1") .WithWorkerNodes(2) .WithKubernetesVersion("v1.31.0"); @@ -488,7 +748,7 @@ public async Task WithKubernetesVersionAndWorkerNodes_BothOrders_AllNodesHaveIma finally { File.Delete(path1); } // Order 2: version first, then workers - var builder2 = DistributedApplication.CreateBuilder(); + using var builder2 = TestDistributedApplicationBuilder.Create(); builder2.AddKindCluster("order2") .WithKubernetesVersion("v1.31.0") .WithWorkerNodes(2); @@ -533,12 +793,44 @@ public async Task DefaultProcessRunner_InvalidCommand_NonZeroExitCode() Assert.NotEqual(0, result.ExitCode); } + [Fact] + public async Task DefaultProcessRunner_LogsInlineKubeconfigPathInDebugLog() + { + var runner = new DefaultProcessRunner(); + var logger = new CapturingLogger(); + const string kubeconfigPath = "C:\\Users\\tamirdresher\\.kube\\kind-config.yaml"; + + _ = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? await runner.RunAsync(logger, "cmd", ["/c", "echo", "hello", $"--kubeconfig={kubeconfigPath}"]) + : await runner.RunAsync(logger, "sh", ["-c", "echo hello", $"--kubeconfig={kubeconfigPath}"]); + + var executingMessage = Assert.Single(logger.Messages, message => message.StartsWith("Executing:", StringComparison.Ordinal)); + Assert.Contains(kubeconfigPath, executingMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains($"--kubeconfig={kubeconfigPath}", executingMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task DefaultProcessRunner_LogsSpaceSeparatedKubeconfigPathInDebugLog() + { + var runner = new DefaultProcessRunner(); + var logger = new CapturingLogger(); + const string kubeconfigPath = "C:\\Users\\tamirdresher\\.kube\\kind-config.yaml"; + + _ = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? await runner.RunAsync(logger, "cmd", ["/c", "echo", "hello", "--kubeconfig", kubeconfigPath]) + : await runner.RunAsync(logger, "sh", ["-c", "echo hello", "--kubeconfig", kubeconfigPath]); + + var executingMessage = Assert.Single(logger.Messages, message => message.StartsWith("Executing:", StringComparison.Ordinal)); + Assert.Contains(kubeconfigPath, executingMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains($"--kubeconfig {kubeconfigPath}", executingMessage, StringComparison.Ordinal); + } + // ── Edge-case tests ────────────────────────────────────────────────── [Fact] public async Task WithWorkerNodes_Zero_IsValid() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); builder.AddKindCluster("edge-zero") .WithWorkerNodes(0); @@ -553,6 +845,7 @@ public async Task WithWorkerNodes_Zero_IsValid() Assert.Contains("- role: control-plane", yaml); Assert.DoesNotContain("- role: worker", yaml); } + finally { File.Delete(configPath); @@ -570,4 +863,85 @@ public void KindClusterResource_Constructor_SetsPathsCorrectly() } private sealed class TestResource(string name) : Resource(name), IResourceWithEnvironment; -} + + private sealed class TestKindContainerRuntimeResolver : IKindContainerRuntimeResolver + { + public Task ResolveAsync(CancellationToken cancellationToken) => + Task.FromResult(new KindContainerRuntime("docker")); + } + + private sealed class TestHostApplicationLifetime : IHostApplicationLifetime + { + private readonly CancellationTokenSource _applicationStarted = new(); + private readonly CancellationTokenSource _applicationStopping = new(); + private readonly CancellationTokenSource _applicationStopped = new(); + + public CancellationToken ApplicationStarted => _applicationStarted.Token; + + public CancellationToken ApplicationStopping => _applicationStopping.Token; + + public CancellationToken ApplicationStopped => _applicationStopped.Token; + + public void StopApplication() + { + if (!_applicationStopping.IsCancellationRequested) + { + _applicationStopping.Cancel(); + } + } + } + + private sealed class NoOpEventing : IDistributedApplicationEventing + { + public DistributedApplicationEventSubscription Subscribe(Func callback) + where T : IDistributedApplicationEvent => null!; + + public DistributedApplicationEventSubscription Subscribe(IResource resource, Func callback) + where T : IDistributedApplicationResourceEvent => null!; + + public void Unsubscribe(DistributedApplicationEventSubscription subscription) + { + } + + public Task PublishAsync(T @event, CancellationToken cancellationToken = default) + where T : IDistributedApplicationEvent => Task.CompletedTask; + + public Task PublishAsync(T @event, EventDispatchBehavior dispatchBehavior, CancellationToken cancellationToken = default) + where T : IDistributedApplicationEvent => Task.CompletedTask; + } + + private static async Task WaitForConditionAsync(Func condition) + { + for (var attempt = 0; attempt < 50; attempt++) + { + if (condition()) + { + return; + } + + await Task.Delay(20); + } + + throw new Xunit.Sdk.XunitException("Timed out waiting for asynchronous condition."); + } + + private sealed class CapturingLogger : ILogger + { + public List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Messages.Add(formatter(state, exception)); + } + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs index cc7f02d20..f28971e39 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeProcessRunner.cs @@ -19,31 +19,45 @@ internal sealed class FakeProcessRunner : IProcessRunner public ProcessResult NextResult { get; set; } = new(0, "", ""); public Queue Results { get; } = new(); public Dictionary ResultsByFileName { get; } = []; + public TimeSpan Delay { get; set; } + public Queue Delays { get; } = new(); + public Func DelayAsync { get; set; } = Task.Delay; - public Task RunAsync( + public async Task RunAsync( ILogger logger, string fileName, IReadOnlyList arguments, string? workingDirectory = null, IReadOnlyDictionary? environmentVariables = null, + string? standardInput = null, CancellationToken cancellationToken = default) { + ProcessResult result; + TimeSpan delay; lock (_lock) { - Commands.Add(new(fileName, string.Join(" ", arguments), workingDirectory, environmentVariables)); + Commands.Add(new(fileName, string.Join(" ", arguments), workingDirectory, environmentVariables, standardInput)); - return Task.FromResult( - ResultsByFileName.TryGetValue(fileName, out var result) - ? result + result = ResultsByFileName.TryGetValue(fileName, out var fileResult) + ? fileResult : Results.Count > 0 ? Results.Dequeue() - : NextResult); + : NextResult; + delay = Delays.Count > 0 ? Delays.Dequeue() : Delay; } + + if (delay > TimeSpan.Zero) + { + await DelayAsync(delay, cancellationToken); + } + + return result; } internal sealed record ExecutedCommand( string FileName, string Arguments, string? WorkingDirectory, - IReadOnlyDictionary? EnvironmentVariables); + IReadOnlyDictionary? EnvironmentVariables, + string? StandardInput); } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindContainerExtensionsTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindContainerExtensionsTests.cs index 0454a9bad..43f8c193c 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindContainerExtensionsTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindContainerExtensionsTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting; +using Aspire.Hosting.Utils; using CommunityToolkit.Aspire.Testing; namespace CommunityToolkit.Aspire.Hosting.Kind.Tests; @@ -18,7 +19,7 @@ public async Task WithKindNetwork_UsesRuntimeConnectionStateForRestart() processRunner.Results.Enqueue(new(0, "", "")); processRunner.Results.Enqueue(new(0, "", "")); - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); builder.Services.AddSingleton(processRunner); builder.Services.AddSingleton( new KindContainerRuntimeResolver(new FakeContainerRuntimeResolver("Docker"))); @@ -70,7 +71,7 @@ await builder.Eventing.PublishAsync( [Fact] public void WithReference_Container_InjectsBindMountAnnotation() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var kind = builder.AddKindCluster("test-cluster"); var container = builder.AddContainer("test-container", "test-image") @@ -90,7 +91,7 @@ public void WithReference_Container_InjectsBindMountAnnotation() [Fact] public void WithReference_Container_InjectsEnvironmentAnnotation() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var kind = builder.AddKindCluster("test-cluster"); builder.AddContainer("test-container", "test-image") @@ -106,7 +107,7 @@ public void WithReference_Container_InjectsEnvironmentAnnotation() [Fact] public async Task WithReference_Container_SetsContainerKubeconfigEnvironmentValue() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var kind = builder.AddKindCluster("test-cluster"); var container = builder.AddContainer("test-container", "test-image") diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs index ed3a3e16f..c94f86ced 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs @@ -3,6 +3,8 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.Logging; namespace CommunityToolkit.Aspire.Hosting.Kind.Tests; @@ -11,7 +13,7 @@ public class KindHelmChartTests [Fact] public void AddHelmChartCreatesResource() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var cluster = builder.AddKindCluster("test-cluster"); cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis"); @@ -27,7 +29,7 @@ public void AddHelmChartCreatesResource() [Fact] public void AddHelmChartSetsParent() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var cluster = builder.AddKindCluster("test-cluster"); cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis"); @@ -52,7 +54,7 @@ public void ReleaseNameDefaultsToResourceName() [Fact] public void WithChartVersionSetsVersion() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var cluster = builder.AddKindCluster("test-cluster"); cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") @@ -68,7 +70,7 @@ public void WithChartVersionSetsVersion() [Fact] public void WithHelmValueAddsValue() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var cluster = builder.AddKindCluster("test-cluster"); cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") @@ -84,10 +86,148 @@ public void WithHelmValueAddsValue() Assert.Equal("false", resource.Values["auth.enabled"]); } + [Fact] + public void WithHelmStringValueAddsStringValue() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithHelmStringValue("auth.password", "000123") + .WithHelmStringValue("feature.flag", "false"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(2, resource.StringValues.Count); + Assert.Equal("000123", resource.StringValues["auth.password"]); + Assert.Equal("false", resource.StringValues["feature.flag"]); + } + + [Fact] + public void WithHelmValueLastWriteWinsForDuplicateKey() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithHelmValue("replica.replicaCount", "1") + .WithHelmValue("replica.replicaCount", "2"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("2", resource.Values["replica.replicaCount"]); + } + + [Fact] + public void WithHelmValueAndStringValueUseLastWriteWinsAcrossModes() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithHelmValue("auth.password", "123") + .WithHelmStringValue("auth.password", "000123"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.False(resource.Values.ContainsKey("auth.password")); + Assert.Equal("000123", resource.StringValues["auth.password"]); + } + + [Fact] + public void WithHelmStringValueAndValueUseLastWriteWinsAcrossModes() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithHelmStringValue("auth.password", "000123") + .WithHelmValue("auth.password", "123"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.False(resource.StringValues.ContainsKey("auth.password")); + Assert.Equal("123", resource.Values["auth.password"]); + } + + [Fact] + public void WithCrdWaitRetrySetsRetryConfiguration() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithCrdWaitRetry(maxAttempts: 3, backoff: TimeSpan.FromSeconds(7), crdWaitTimeout: TimeSpan.FromSeconds(42)); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(3, resource.CrdWaitRetryMaxAttempts); + Assert.Equal(TimeSpan.FromSeconds(7), resource.CrdWaitRetryBackoff); + Assert.Equal(TimeSpan.FromSeconds(42), resource.CrdWaitRetryTimeout); + } + + [Fact] + public void WithCrdWaitRetryUsesDefaultBackoff() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") + .WithCrdWaitRetry(); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(3, resource.CrdWaitRetryMaxAttempts); + Assert.Equal(KubectlTimeouts.DefaultCrdWaitRetryBackoff, resource.CrdWaitRetryBackoff); + Assert.Equal(KubectlTimeouts.DefaultCrdWaitTimeout, resource.CrdWaitRetryTimeout); + } + + [Fact] + public void WithCrdWaitRetryRejectsLessThanTwoAttempts() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddHelmChart("redis", "chart/ref").WithCrdWaitRetry(1, TimeSpan.FromSeconds(5))); + } + + [Fact] + public void WithCrdWaitRetryRejectsInvalidBackoff() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddHelmChart("redis", "chart/ref").WithCrdWaitRetry(3, TimeSpan.Zero)); + } + + [Fact] + public void WithCrdWaitRetryRejectsInvalidCrdWaitTimeout() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddHelmChart("redis", "chart/ref").WithCrdWaitRetry(3, TimeSpan.FromSeconds(5), TimeSpan.Zero)); + } + [Fact] public void WithHelmValuesFileAddsPath() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var cluster = builder.AddKindCluster("test-cluster"); cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") @@ -106,7 +246,7 @@ public void WithHelmValuesFileAddsPath() [Fact] public void WithNamespaceSetsNamespace() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var cluster = builder.AddKindCluster("test-cluster"); cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") @@ -144,13 +284,14 @@ public void ValuesAndValuesFilesStartEmpty() var resource = new KindHelmChartResource("redis", "chart/ref", cluster); Assert.Empty(resource.Values); + Assert.Empty(resource.StringValues); Assert.Empty(resource.ValuesFiles); } [Fact] public void MultipleHelmChartsCanBeAddedToSameCluster() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var cluster = builder.AddKindCluster("test-cluster"); cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis"); @@ -167,12 +308,13 @@ public void MultipleHelmChartsCanBeAddedToSameCluster() [Fact] public void FluentApiChainingWorks() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var cluster = builder.AddKindCluster("test-cluster"); cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis") .WithChartVersion("20.0.0") .WithHelmValue("replica.replicaCount", "2") + .WithHelmStringValue("auth.password", "000123") .WithHelmValuesFile("./values.yaml") .WithNamespace("cache"); @@ -182,6 +324,7 @@ public void FluentApiChainingWorks() var resource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("20.0.0", resource.Version); Assert.Equal("2", resource.Values["replica.replicaCount"]); + Assert.Equal("000123", resource.StringValues["auth.password"]); Assert.Single(resource.ValuesFiles); Assert.Equal("cache", resource.Namespace); } @@ -197,6 +340,7 @@ public void CreateInstallArgumentsPreservesArgumentBoundaries() }; resource.Values["annotations.description"] = "My \"Redis\" App"; + resource.StringValues["auth.password"] = "000123"; resource.ValuesFiles.Add(@"C:\temp path\values file.yaml"); var arguments = HelmManager.CreateInstallArguments(resource); @@ -215,12 +359,140 @@ public void CreateInstallArgumentsPreservesArgumentBoundaries() "--create-namespace", "--set", "annotations.description=My \"Redis\" App", + "--set-string", + "auth.password=000123", "-f", @"C:\temp path\values file.yaml", ], arguments); } + [Fact] + public async Task InstallAsync_RetriesAfterWaitingForNewCrds() + { + var cluster = new KindClusterResource("cluster"); + var resource = new KindHelmChartResource("redis", "chart/ref", cluster) + { + CrdWaitRetryMaxAttempts = 3, + CrdWaitRetryBackoff = TimeSpan.FromSeconds(2), + }; + var processRunner = new FakeProcessRunner(); + processRunner.Results.Enqueue(new(0, "", "")); // kubectl get crd baseline + processRunner.Results.Enqueue(new(1, "", "no matches for kind \"Widget\" in version \"widgets.example.com/v1\"; ensure CRDs are installed first")); + processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", "")); // kubectl get crd after failure + processRunner.Results.Enqueue(new(0, "", "")); // kubectl wait + processRunner.Results.Enqueue(new(0, "release installed", "")); // retry succeeds + var delays = new List(); + var manager = new HelmManager( + processRunner, + (delay, _) => + { + delays.Add(delay); + return Task.CompletedTask; + }); + using var loggerFactory = LoggerFactory.Create(_ => { }); + + await manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None); + + Assert.Equal(5, processRunner.Commands.Count); + Assert.Equal("kubectl", processRunner.Commands[0].FileName); + Assert.Contains("get crd -o name", processRunner.Commands[0].Arguments); + Assert.Equal("helm", processRunner.Commands[1].FileName); + Assert.Equal("kubectl", processRunner.Commands[2].FileName); + Assert.Contains("get crd -o name", processRunner.Commands[2].Arguments); + Assert.Equal("kubectl", processRunner.Commands[3].FileName); + Assert.Contains("wait --for=condition=Established customresourcedefinition.apiextensions.k8s.io/widgets.example.com", processRunner.Commands[3].Arguments); + Assert.Equal("helm", processRunner.Commands[4].FileName); + Assert.Equal([TimeSpan.FromSeconds(2)], delays); + } + + [Fact] + public async Task InstallAsync_UsesConfiguredCrdWaitTimeoutBetweenRetries() + { + var cluster = new KindClusterResource("cluster"); + var resource = new KindHelmChartResource("redis", "chart/ref", cluster) + { + CrdWaitRetryMaxAttempts = 2, + CrdWaitRetryBackoff = TimeSpan.FromSeconds(2), + CrdWaitRetryTimeout = TimeSpan.FromSeconds(42), + }; + var processRunner = new FakeProcessRunner(); + processRunner.Results.Enqueue(new(0, "", "")); + processRunner.Results.Enqueue(new(1, "", "no matches for kind \"Widget\" in version \"widgets.example.com/v1\"; ensure CRDs are installed first")); + processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", "")); + processRunner.Results.Enqueue(new(0, "", "")); + processRunner.Results.Enqueue(new(0, "release installed", "")); + var manager = new HelmManager(processRunner, static (_, _) => Task.CompletedTask); + using var loggerFactory = LoggerFactory.Create(_ => { }); + + await manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None); + + Assert.Contains("--timeout=42s", processRunner.Commands[3].Arguments); + } + + [Fact] + public async Task InstallAsync_DoesNotRetryByDefault() + { + var cluster = new KindClusterResource("cluster"); + var resource = new KindHelmChartResource("redis", "chart/ref", cluster); + var processRunner = new FakeProcessRunner(); + processRunner.Results.Enqueue(new(1, "", "release failed")); + var manager = new HelmManager(processRunner, static (_, _) => Task.CompletedTask); + using var loggerFactory = LoggerFactory.Create(_ => { }); + + var ex = await Assert.ThrowsAsync( + () => manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None)); + + Assert.Contains("Failed to install Helm chart", ex.Message); + Assert.Single(processRunner.Commands); + } + + [Fact] + public async Task InstallAsync_RetriesExplicitlyConfiguredFailuresEvenWithoutNewCrds() + { + var cluster = new KindClusterResource("cluster"); + var resource = new KindHelmChartResource("redis", "chart/ref", cluster) + { + CrdWaitRetryMaxAttempts = 3, + CrdWaitRetryBackoff = TimeSpan.FromSeconds(2), + }; + var processRunner = new FakeProcessRunner(); + processRunner.Results.Enqueue(new(0, "", "")); + processRunner.Results.Enqueue(new(1, "", "release failed")); + processRunner.Results.Enqueue(new(0, "", "")); + processRunner.Results.Enqueue(new(1, "", "release still failed")); + processRunner.Results.Enqueue(new(0, "", "")); + processRunner.Results.Enqueue(new(0, "release installed", "")); + var delays = new List(); + var manager = new HelmManager(processRunner, (delay, _) => + { + delays.Add(delay); + return Task.CompletedTask; + }); + using var loggerFactory = LoggerFactory.Create(_ => { }); + + await manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None); + + Assert.Equal(6, processRunner.Commands.Count); + Assert.Equal(3, processRunner.Commands.Count(command => command.FileName == "helm")); + Assert.Equal(3, processRunner.Commands.Count(command => command.FileName == "kubectl")); + Assert.Equal([TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4)], delays); + } + + [Fact] + public void ComputeRetryBackoffDoublesPerFailure() + { + Assert.Equal(TimeSpan.FromSeconds(5), HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 1)); + Assert.Equal(TimeSpan.FromSeconds(10), HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 2)); + Assert.Equal(TimeSpan.FromSeconds(20), HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 3)); + } + + [Fact] + public void ComputeRetryBackoffSaturatesInsteadOfOverflowing() + { + Assert.Equal(TimeSpan.MaxValue, HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 64)); + } + // ── Null-check tests ───────────────────────────────────────────────── [Fact] @@ -237,7 +509,7 @@ public void AddHelmChartShouldThrowWhenBuilderIsNull() [Fact] public void AddHelmChartShouldThrowWhenNameIsNull() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var cluster = builder.AddKindCluster("test-cluster"); string name = null!; @@ -250,7 +522,7 @@ public void AddHelmChartShouldThrowWhenNameIsNull() [Fact] public void AddHelmChartShouldThrowWhenChartRefIsNull() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var cluster = builder.AddKindCluster("test-cluster"); string chartRef = null!; @@ -282,6 +554,17 @@ public void WithHelmValueShouldThrowWhenBuilderIsNull() Assert.Equal(nameof(builder), exception.ParamName); } + [Fact] + public void WithHelmStringValueShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithHelmStringValue("key", "value"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + [Fact] public void WithHelmValuesFileShouldThrowWhenBuilderIsNull() { @@ -293,6 +576,17 @@ public void WithHelmValuesFileShouldThrowWhenBuilderIsNull() Assert.Equal(nameof(builder), exception.ParamName); } + [Fact] + public void WithCrdWaitRetryShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithCrdWaitRetry(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + [Fact] public void WithNamespaceShouldThrowWhenBuilderIsNull() { @@ -330,7 +624,7 @@ public void KindHelmChartResourceShouldThrowWhenChartRefIsNull() [Fact] public void AddHelmChartRegistersHealthCheck() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var cluster = builder.AddKindCluster("test-cluster"); cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis"); @@ -346,7 +640,7 @@ public void AddHelmChartRegistersHealthCheck() [Fact] public void AddHelmChartRegistersUniqueHealthCheckPerResource() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var cluster = builder.AddKindCluster("test-cluster"); cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis"); @@ -364,4 +658,4 @@ public void AddHelmChartRegistersUniqueHealthCheckPerResource() Assert.NotEmpty(healthCheckAnnotations); } } -} +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs new file mode 100644 index 000000000..e2b073c60 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs @@ -0,0 +1,1110 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.Logging; +using System.ComponentModel; + +namespace CommunityToolkit.Aspire.Hosting.Kind.Tests; + +public class KindManifestTests +{ + [Fact] + public void AddManifestCreatesResource() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + var absolutePath = Path.Combine(AppContext.BaseDirectory, "manifests", "crds.yaml"); + cluster.AddManifest("crds", absolutePath); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("crds", resource.Name); + Assert.True(Path.IsPathRooted(resource.ManifestPath)); + Assert.Equal(absolutePath, resource.ManifestPath); + } + + [Fact] + public void AddManifestSetsParent() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + var absolutePath = Path.Combine(AppContext.BaseDirectory, "manifests", "crds.yaml"); + cluster.AddManifest("crds", absolutePath); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var manifestResource = Assert.Single(appModel.Resources.OfType()); + var clusterResource = Assert.Single(appModel.Resources.OfType()); + Assert.Same(clusterResource, manifestResource.Parent); + } + + [Fact] + public void AddManifestFromContentCreatesResource() + { + const string content = "apiVersion: v1\nkind: Namespace\nmetadata:\n name: aspire-demo"; + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifestFromContent("demo-ns", content); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("demo-ns", resource.Name); + Assert.Equal(K8sManifestResource.InlineManifestPath, resource.ManifestPath); + Assert.Equal(content, resource.InlineContent); + Assert.False(resource.IsKustomize); + } + + [Fact] + public void AddManifestThrowsOnNullBuilder() + { + IResourceBuilder builder = null!; + Assert.Throws(() => builder.AddManifest("crds", @"C:\manifests\crds.yaml")); + } + + [Fact] + public void AddManifestThrowsOnNullName() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + Assert.Throws(() => cluster.AddManifest(null!, @"C:\manifests\crds.yaml")); + } + + [Fact] + public void AddManifestThrowsOnNullManifestPath() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => cluster.AddManifest("crds", null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void AddManifestThrowsOnWhitespaceManifestPath(string manifestPath) + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => cluster.AddManifest("crds", manifestPath)); + } + + [Fact] + public void AddManifestFromContentThrowsOnNullBuilder() + { + IResourceBuilder builder = null!; + Assert.Throws(() => builder.AddManifestFromContent("crds", "apiVersion: v1")); + } + + [Fact] + public void AddManifestFromContentThrowsOnNullName() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + Assert.Throws(() => cluster.AddManifestFromContent(null!, "apiVersion: v1")); + } + + [Fact] + public void AddManifestFromContentThrowsOnNullContent() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + Assert.Throws(() => cluster.AddManifestFromContent("crds", null!)); + } + + [Fact] + public void AddManifestRejectsRelativePath() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + var exception = Assert.Throws(() => cluster.AddManifest("crds", Path.Combine("manifests", "crds.yaml"))); + + Assert.Contains("absolute path", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AddManifestUsesAbsolutePathAsIs() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var absolutePath = Path.Combine(AppContext.BaseDirectory, "manifests", "crds.yaml"); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", absolutePath); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(absolutePath, resource.ManifestPath); + } + + [Fact] + public void AddManifestDetectsKustomizationYaml() + { + var directory = CreateTestDirectory(); + + try + { + File.WriteAllText(Path.Combine(directory, "kustomization.yaml"), "resources: []"); + var resource = AddManifestAndGetResource(directory); + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.True(resource.IsKustomize); + Assert.Contains("-k", args); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void AddManifestDetectsKustomizationYml() + { + var directory = CreateTestDirectory(); + + try + { + File.WriteAllText(Path.Combine(directory, "kustomization.yml"), "resources: []"); + var resource = AddManifestAndGetResource(directory); + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.True(resource.IsKustomize); + Assert.Contains("-k", args); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void AddManifestOnDirectoryWithoutKustomization_IsNotKustomize() + { + var directory = CreateTestDirectory(); + + try + { + File.WriteAllText(Path.Combine(directory, "manifest.yaml"), "apiVersion: v1"); + var resource = AddManifestAndGetResource(directory); + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.False(resource.IsKustomize); + Assert.Contains("-f", args); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void WithRecursiveSetsRecursive() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("all", Path.Combine(AppContext.BaseDirectory, "manifests")) + .WithRecursive(); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.True(resource.Recursive); + } + + [Fact] + public void WithServerSideApplySetsServerSide() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithServerSideApply(); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.True(resource.ServerSide); + Assert.False(resource.ForceConflicts); + } + + [Fact] + public void WithServerSideApplyForceConflictsSetsBoth() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithServerSideApply(forceConflicts: true); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.True(resource.ServerSide); + Assert.True(resource.ForceConflicts); + } + + [Fact] + public void WithFieldManagerSetsFieldManager() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithFieldManager("my-tool"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("my-tool", resource.FieldManager); + } + + [Fact] + public void WithApplyTimeoutSetsApplyTimeout() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithApplyTimeout(TimeSpan.FromSeconds(30)); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(TimeSpan.FromSeconds(30), resource.ApplyTimeout); + } + + [Fact] + public void WithClusterReadyTimeoutSetsClusterReadyTimeout() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithClusterReadyTimeout(TimeSpan.FromSeconds(90)); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(TimeSpan.FromSeconds(90), resource.ClusterReadyTimeout); + } + + [Fact] + public void WithClusterReadyTimeoutWiresValueIntoKubectlManagerCreation() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithClusterReadyTimeout(TimeSpan.FromSeconds(90)); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var manager = KindManifestResourceBuilderExtensions.CreateKubectlManager(new FakeProcessRunner(), resource); + + Assert.Equal(TimeSpan.FromSeconds(90), manager.ClusterInfoMaxWaitForTesting); + } + + [Fact] + public void WithClusterReadyTimeoutRejectsZero() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithClusterReadyTimeout(TimeSpan.Zero)); + } + + [Fact] + public void WithClusterReadyTimeoutRejectsNegative() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithClusterReadyTimeout(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void WithClusterReadyTimeoutRoundsSubSecondUpToOneSecond() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithClusterReadyTimeout(TimeSpan.FromMilliseconds(500)); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(TimeSpan.FromSeconds(1), resource.ClusterReadyTimeout); + } + + [Fact] + public void WithClusterReadyTimeoutRejectsMoreThanOneHour() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithClusterReadyTimeout(TimeSpan.MaxValue)); + } + + [Fact] + public void WithApplyTimeoutRejectsZero() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithApplyTimeout(TimeSpan.Zero)); + } + + [Fact] + public void WithApplyTimeoutRejectsNegative() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithApplyTimeout(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void WithApplyTimeoutRoundsSubSecondUpToOneSecond() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithApplyTimeout(TimeSpan.FromMilliseconds(500)); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(TimeSpan.FromSeconds(1), resource.ApplyTimeout); + } + + [Fact] + public void WithApplyTimeoutRejectsMoreThanOneHour() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithApplyTimeout(TimeSpan.MaxValue)); + } + + [Fact] + public void WithCrdWaitTimeoutSetsCrdWaitTimeout() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithCrdWaitTimeout(TimeSpan.FromSeconds(45)); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(TimeSpan.FromSeconds(45), resource.CrdWaitTimeout); + } + + [Fact] + public void WithCrdWaitTimeoutRejectsZero() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithCrdWaitTimeout(TimeSpan.Zero)); + } + + [Fact] + public void WithCrdWaitTimeoutRejectsNegative() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithCrdWaitTimeout(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void WithCrdWaitTimeoutRoundsSubSecondUpToOneSecond() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithCrdWaitTimeout(TimeSpan.FromMilliseconds(500)); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(TimeSpan.FromSeconds(1), resource.CrdWaitTimeout); + } + + [Fact] + public void WithCrdWaitTimeoutRejectsMoreThanOneHour() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + + Assert.Throws(() => + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithCrdWaitTimeout(TimeSpan.MaxValue)); + } + + [Fact] + public void WithCrdWaitBehaviorSetsCrdWaitBehavior() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")) + .WithCrdWaitBehavior(CrdWaitBehavior.BestEffort); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(CrdWaitBehavior.BestEffort, resource.CrdWaitBehavior); + } + + [Fact] + public void DefaultRecursiveIsFalse() + { + var cluster = new KindClusterResource("cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster); + + Assert.False(resource.Recursive); + } + + [Fact] + public void DefaultServerSideIsFalse() + { + var cluster = new KindClusterResource("cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster); + + Assert.False(resource.ServerSide); + Assert.False(resource.ForceConflicts); + } + + [Fact] + public void DefaultFieldManagerIsNull() + { + var cluster = new KindClusterResource("cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster); + + Assert.Null(resource.FieldManager); + } + + [Fact] + public void DefaultApplyTimeoutIsFiveMinutes() + { + var cluster = new KindClusterResource("cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster); + + Assert.Equal(TimeSpan.FromMinutes(5), resource.ApplyTimeout); + } + + [Fact] + public void DefaultClusterReadyTimeoutIsSixtySeconds() + { + var cluster = new KindClusterResource("cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster); + + Assert.Equal(TimeSpan.FromSeconds(60), resource.ClusterReadyTimeout); + } + + [Fact] + public void DefaultCrdWaitSettingsFailAfterFiveMinutes() + { + var cluster = new KindClusterResource("cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster); + + Assert.Equal(TimeSpan.FromMinutes(5), resource.CrdWaitTimeout); + Assert.Equal(CrdWaitBehavior.Fail, resource.CrdWaitBehavior); + } + + [Fact] + public void DefaultNamespaceIsNull() + { + var cluster = new KindClusterResource("cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster); + + Assert.Null(resource.Namespace); + } + + [Fact] + public void ManifestResourceIsIResourceWithParent() + { + var cluster = new KindClusterResource("cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster); + + Assert.IsAssignableFrom>(resource); + Assert.Same(cluster, ((IResourceWithParent)resource).Parent); + } + + // ── KubectlManager argument-shape tests (no CLI invocation) ────────────────── + + [Fact] + public void CreateApplyArguments_MinimalManifest_ContainsApplyAndKubeconfig() + { + var cluster = new KindClusterResource("test-cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster); + + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.Equal("apply", args[0]); + Assert.Contains("-f", args); + Assert.Contains("./crds.yaml", args); + Assert.Contains(args, a => a.StartsWith("--kubeconfig=", StringComparison.Ordinal)); + } + + [Fact] + public void CreateApplyArguments_WithNamespace_IncludesNamespaceFlag() + { + var cluster = new KindClusterResource("test-cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster) + { + Namespace = "kube-system", + }; + + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.Contains("--namespace", args); + Assert.Contains("kube-system", args); + } + + [Fact] + public void CreateApplyArguments_WithRecursive_IncludesRecursiveFlag() + { + var cluster = new KindClusterResource("test-cluster"); + var resource = new K8sManifestResource("all", "./manifests", cluster) + { + Recursive = true, + }; + + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.Contains("--recursive", args); + } + + [Fact] + public void CreateApplyArguments_KustomizeMode_Uses_MinusK() + { + var cluster = new KindClusterResource("test-cluster"); + var directory = CreateTestDirectory(); + + try + { + File.WriteAllText(Path.Combine(directory, "kustomization.yaml"), "resources: []"); + var resource = new K8sManifestResource("kustom", directory, cluster); + + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.Contains("-k", args); + Assert.Contains(directory, args); + Assert.DoesNotContain("-f", args); + Assert.True(resource.IsKustomize); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Theory] + [InlineData("Kustomization")] + [InlineData("Kustomization.yml")] + [InlineData("KUSTOMIZATION.YAML")] + public void CreateApplyArguments_KustomizeMode_DetectsCaseInsensitiveVariants(string fileName) + { + var cluster = new KindClusterResource("test-cluster"); + var directory = CreateTestDirectory(); + + try + { + File.WriteAllText(Path.Combine(directory, fileName), "resources: []"); + var resource = new K8sManifestResource("kustom", directory, cluster); + + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.Contains("-k", args); + Assert.True(resource.IsKustomize); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void CreateApplyArguments_DetectsKustomizeAtApplyTime() + { + var directory = CreateTestDirectory(); + + try + { + var resource = AddManifestAndGetResource(directory); + Assert.False(resource.IsKustomize); + + File.WriteAllText(Path.Combine(directory, "kustomization.yaml"), "resources: []"); + + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.Contains("-k", args); + Assert.True(resource.IsKustomize); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void CreateApplyArguments_InlineContent_Uses_MinusStdinDash() + { + var cluster = new KindClusterResource("test-cluster"); + var resource = new K8sManifestResource("inline", K8sManifestResource.InlineManifestPath, cluster) + { + InlineContent = "apiVersion: v1", + }; + + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.Contains("-f", args); + Assert.Contains("-", args); + Assert.DoesNotContain(K8sManifestResource.InlineManifestPath, args); + } + + [Fact] + public void CreateApplyArguments_InlineContent_SkipsKustomizeDetection() + { + var cluster = new KindClusterResource("test-cluster"); + var resource = new K8sManifestResource("inline", K8sManifestResource.InlineManifestPath, cluster) + { + InlineContent = "apiVersion: v1", + IsKustomize = true, + }; + + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.Contains("-f", args); + Assert.Contains("-", args); + Assert.DoesNotContain("-k", args); + } + + [Fact] + public void WithRecursive_OnKustomize_Warns_And_Ignores() + { + var cluster = new KindClusterResource("test-cluster"); + var directory = CreateTestDirectory(); + + try + { + File.WriteAllText(Path.Combine(directory, "kustomization.yaml"), "resources: []"); + var resource = new K8sManifestResource("kustom", directory, cluster) + { + Recursive = true, + }; + + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.DoesNotContain("--recursive", args); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void CreateApplyArguments_WithServerSide_IncludesServerSideFlag() + { + var cluster = new KindClusterResource("test-cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster) + { + ServerSide = true, + }; + + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.Contains("--server-side", args); + Assert.DoesNotContain("--force-conflicts", args); + } + + [Fact] + public void CreateApplyArguments_ServerSideWithForceConflicts_IncludesBoth() + { + var cluster = new KindClusterResource("test-cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster) + { + ServerSide = true, + ForceConflicts = true, + }; + + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.Contains("--server-side", args); + Assert.Contains("--force-conflicts", args); + } + + [Fact] + public void CreateApplyArguments_ForceConflictsWithoutServerSide_OmitsForceConflicts() + { + // --force-conflicts only means anything with --server-side; without server-side we + // should not emit it, otherwise kubectl rejects the command. + var cluster = new KindClusterResource("test-cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster) + { + ServerSide = false, + ForceConflicts = true, + }; + + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.DoesNotContain("--force-conflicts", args); + } + + [Fact] + public void CreateApplyArguments_WithFieldManager_IncludesFieldManagerFlag() + { + var cluster = new KindClusterResource("test-cluster"); + var resource = new K8sManifestResource("crds", "./crds.yaml", cluster) + { + FieldManager = "my-tool", + }; + + var args = KubectlManager.CreateApplyArguments(resource); + + Assert.Contains("--field-manager", args); + Assert.Contains("my-tool", args); + } + + [Fact] + public void CreateWaitArguments_ProducesExpectedShape() + { + var args = KubectlManager.CreateWaitArguments( + ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com", "customresourcedefinition.apiextensions.k8s.io/gadgets.example.com"], + "C:\\kube\\config.yaml", + TimeSpan.FromMinutes(5)); + + Assert.Equal("wait", args[0]); + Assert.Equal("--for=condition=Established", args[1]); + Assert.Contains("customresourcedefinition.apiextensions.k8s.io/widgets.example.com", args); + Assert.Contains("customresourcedefinition.apiextensions.k8s.io/gadgets.example.com", args); + Assert.Contains("--timeout=300s", args); + Assert.Contains("--kubeconfig=C:\\kube\\config.yaml", args); + } + + [Fact] + public void CreateWaitArguments_RoundsSubSecondTimeoutUpToOneSecond() + { + var args = KubectlManager.CreateWaitArguments( + ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"], + "C:\\kube\\config.yaml", + TimeSpan.FromMilliseconds(500)); + + Assert.Contains("--timeout=1s", args); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void CreateKubectlArguments_RejectWhitespaceKubeconfigPath(string kubeconfigPath) + { + Assert.Throws(() => + KubectlManager.CreateWaitArguments( + ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"], + kubeconfigPath, + TimeSpan.FromSeconds(1))); + Assert.Throws(() => KubectlManager.CreateClusterInfoArguments(kubeconfigPath)); + Assert.Throws(() => KubectlManager.CreateGetCrdsArguments(kubeconfigPath)); + } + + [Fact] + public async Task ApplyAsync_WaitsForAppliedCrdsBestEffort() + { + var processRunner = new FakeProcessRunner(); + processRunner.Results.Enqueue(new(0, "cluster is running", "")); + processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com created", "")); + processRunner.Results.Enqueue(new(1, "", "timed out waiting for the condition")); + using var loggerFactory = LoggerFactory.Create(_ => { }); + var manager = new KubectlManager(processRunner); + var resource = new K8sManifestResource("crds", "./crds.yaml", new KindClusterResource("test-cluster")) + { + CrdWaitBehavior = CrdWaitBehavior.BestEffort, + }; + + await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None); + + Assert.Equal(3, processRunner.Commands.Count); + Assert.Contains("cluster-info", processRunner.Commands[0].Arguments); + Assert.Contains("apply -f ./crds.yaml", processRunner.Commands[1].Arguments); + Assert.Contains("wait --for=condition=Established customresourcedefinition.apiextensions.k8s.io/widgets.example.com", processRunner.Commands[2].Arguments); + } + + [Fact] + public async Task ApplyAsync_FailsWhenCrdWaitFailsByDefault() + { + var processRunner = new FakeProcessRunner(); + processRunner.Results.Enqueue(new(0, "cluster is running", "")); + processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com created", "")); + processRunner.Results.Enqueue(new(1, "", "timed out waiting for the condition")); + using var loggerFactory = LoggerFactory.Create(_ => { }); + var manager = new KubectlManager(processRunner); + var resource = new K8sManifestResource("crds", "./crds.yaml", new KindClusterResource("test-cluster")); + + var ex = await Assert.ThrowsAsync( + () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None)); + + Assert.Contains("Established", ex.Message); + Assert.Equal(3, processRunner.Commands.Count); + } + + [Fact] + public async Task ApplyAsync_UsesConfiguredCrdWaitTimeout() + { + var processRunner = new FakeProcessRunner(); + processRunner.Results.Enqueue(new(0, "cluster is running", "")); + processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com created", "")); + processRunner.Results.Enqueue(new(0, "", "")); + using var loggerFactory = LoggerFactory.Create(_ => { }); + var manager = new KubectlManager(processRunner); + var resource = new K8sManifestResource("crds", "./crds.yaml", new KindClusterResource("test-cluster")) + { + CrdWaitTimeout = TimeSpan.FromSeconds(42), + }; + + await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None); + + Assert.Contains("--timeout=42s", processRunner.Commands[2].Arguments); + } + + [Fact] + public async Task ApplyAsync_RetriesClusterInfoBeforeApply() + { + var processRunner = new FakeProcessRunner(); + processRunner.Results.Enqueue(new(1, "", "not ready")); + processRunner.Results.Enqueue(new(1, "", "still not ready")); + processRunner.Results.Enqueue(new(0, "cluster is running", "")); + processRunner.Results.Enqueue(new(0, "namespace/default unchanged", "")); + using var loggerFactory = LoggerFactory.Create(_ => { }); + var manager = new KubectlManager(processRunner, static (_, _) => Task.CompletedTask); + var resource = new K8sManifestResource("manifest", "./manifest.yaml", new KindClusterResource("test-cluster")); + + await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None); + + Assert.Equal(4, processRunner.Commands.Count); + Assert.All(processRunner.Commands.Take(3), command => Assert.Contains("cluster-info", command.Arguments)); + Assert.Contains("apply -f ./manifest.yaml", processRunner.Commands[3].Arguments); + } + + [Fact] + public async Task ApplyAsync_ClusterInfoSlowFailuresRespectWallClockBudget() + { + var processRunner = new FakeProcessRunner + { + NextResult = new(1, "", "not ready"), + Delay = TimeSpan.FromMilliseconds(40), + }; + using var loggerFactory = LoggerFactory.Create(_ => { }); + var manager = new KubectlManager( + processRunner, + static (_, _) => Task.CompletedTask, + clusterInfoMaxWait: TimeSpan.FromMilliseconds(100), + clusterInfoProbeTimeout: TimeSpan.FromSeconds(1)); + var resource = new K8sManifestResource("manifest", "./manifest.yaml", new KindClusterResource("test-cluster")); + var started = DateTimeOffset.UtcNow; + + var ex = await Assert.ThrowsAsync( + () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None)); + + var elapsed = DateTimeOffset.UtcNow - started; + Assert.Contains("Timed out waiting for cluster", ex.Message); + Assert.True(elapsed < TimeSpan.FromSeconds(1), $"Elapsed {elapsed} exceeded tolerance."); + Assert.All(processRunner.Commands, command => Assert.Contains("cluster-info", command.Arguments)); + } + + [Fact] + public async Task ApplyAsync_CancelsApplyAfterConfiguredTimeout() + { + var processRunner = new FakeProcessRunner + { + DelayAsync = static (_, cancellationToken) => + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)); + return tcs.Task; + } + }; + processRunner.Results.Enqueue(new(0, "cluster is running", "")); + processRunner.Results.Enqueue(new(0, "namespace/default unchanged", "")); + processRunner.Delays.Enqueue(TimeSpan.Zero); + processRunner.Delays.Enqueue(TimeSpan.FromSeconds(5)); + using var loggerFactory = LoggerFactory.Create(_ => { }); + var manager = new KubectlManager(processRunner); + var resource = new K8sManifestResource("manifest", "./manifest.yaml", new KindClusterResource("test-cluster")) + { + ApplyTimeout = TimeSpan.FromMilliseconds(10), + }; + + await Assert.ThrowsAsync( + () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None)); + } + + [Fact] + public async Task ApplyAsync_WithRecursiveFilePath_ThrowsBeforeKubectlApply() + { + var directory = CreateTestDirectory(); + + try + { + var manifestPath = Path.Combine(directory, "manifest.yaml"); + await File.WriteAllTextAsync(manifestPath, "apiVersion: v1"); + + var processRunner = new FakeProcessRunner(); + processRunner.Results.Enqueue(new(0, "cluster is running", "")); + using var loggerFactory = LoggerFactory.Create(_ => { }); + var manager = new KubectlManager(processRunner); + var resource = new K8sManifestResource("manifest", manifestPath, new KindClusterResource("test-cluster")) + { + Recursive = true, + }; + + var exception = await Assert.ThrowsAsync( + () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None)); + + Assert.Contains("existing directory", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Single(processRunner.Commands); + Assert.Contains("cluster-info", processRunner.Commands[0].Arguments); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task ApplyAsync_IncludesStdoutWhenApplyFailsWithoutStderr() + { + var processRunner = new FakeProcessRunner(); + processRunner.Results.Enqueue(new(0, "cluster is running", "")); + processRunner.Results.Enqueue(new(1, "resource mapping not found", "")); + using var loggerFactory = LoggerFactory.Create(_ => { }); + var manager = new KubectlManager(processRunner); + var resource = new K8sManifestResource("manifest", "./manifest.yaml", new KindClusterResource("test-cluster")); + + var ex = await Assert.ThrowsAsync( + () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None)); + + Assert.Contains("resource mapping not found", ex.Message); + } + + [Fact] + public async Task ApplyAsync_ThrowsHelpfulErrorWhenKubectlIsMissing() + { + using var loggerFactory = LoggerFactory.Create(_ => { }); + var manager = new KubectlManager(new ThrowingProcessRunner(new Win32Exception("kubectl not found"))); + var resource = new K8sManifestResource("manifest", "./manifest.yaml", new KindClusterResource("test-cluster")); + + var ex = await Assert.ThrowsAsync( + () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None)); + + Assert.Contains("kubectl CLI not found", ex.Message); + } + + [Fact] + public async Task ApplyAsync_InlineContent_PassesStandardInput() + { + const string content = "apiVersion: v1\nkind: Namespace"; + var processRunner = new FakeProcessRunner(); + processRunner.Results.Enqueue(new(0, "cluster is running", "")); + processRunner.Results.Enqueue(new(0, "", "")); + using var loggerFactory = LoggerFactory.Create(_ => { }); + var manager = new KubectlManager(processRunner); + var resource = new K8sManifestResource("inline", K8sManifestResource.InlineManifestPath, new KindClusterResource("test-cluster")) + { + InlineContent = content, + }; + + await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None); + + var command = processRunner.Commands.Last(); + Assert.Contains("apply -f -", command.Arguments); + Assert.Equal(content, command.StandardInput); + } + + [Fact] + public void CreateApplyArguments_ThrowsOnNullResource() + { + Assert.Throws(() => KubectlManager.CreateApplyArguments(null!)); + } + + private static K8sManifestResource AddManifestAndGetResource(string path) + { + using var builder = TestDistributedApplicationBuilder.Create(); + var cluster = builder.AddKindCluster("test-cluster"); + cluster.AddManifest("kustom", path); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + return Assert.Single(appModel.Resources.OfType()); + } + + private static string CreateTestDirectory() + { + var directory = Path.Combine(AppContext.BaseDirectory, "kind-manifest-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + return directory; + } + + private sealed class ThrowingProcessRunner(Exception exception) : IProcessRunner + { + public Task RunAsync( + ILogger logger, + string fileName, + IReadOnlyList arguments, + string? workingDirectory = null, + IReadOnlyDictionary? environmentVariables = null, + string? standardInput = null, + CancellationToken cancellationToken = default) => Task.FromException(exception); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPublicApiTests.cs index b66fe1147..f93ea0612 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPublicApiTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPublicApiTests.cs @@ -3,6 +3,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; using CommunityToolkit.Aspire.Hosting.Kind; namespace CommunityToolkit.Aspire.Hosting.Kind.Tests; @@ -23,7 +24,7 @@ public void AddKindClusterShouldThrowWhenBuilderIsNull() [Fact] public void AddKindClusterShouldThrowWhenNameIsNull() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); string name = null!; var action = () => builder.AddKindCluster(name); @@ -54,6 +55,28 @@ public void WithWorkerNodesShouldThrowWhenBuilderIsNull() Assert.Equal(nameof(builder), exception.ParamName); } + [Fact] + public void WithNodeImageShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithNodeImage("kindest/node:v1.32.2"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithNodeMountShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithNodeMount(@"C:\host", "/container"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + [Fact] public void WithClusterLifetimeShouldThrowWhenBuilderIsNull() { @@ -102,7 +125,7 @@ public void WithKindConfigShouldThrowWhenBuilderIsNull() [Fact] public void WithKindConfigShouldThrowWhenConfigureIsNull() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var cluster = builder.AddKindCluster("test"); Action configure = null!; diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs index 1f9c626d3..2c7857ab6 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/WithKindTests.cs @@ -45,20 +45,27 @@ public void WithKindParentLinksToKubernetesEnvironment() public void FluentMethodsWorkAfterWithKind() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var hostPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "kind-data")); builder.AddKubernetesEnvironment("k8s") .WithKind() - .WithKubernetesVersion("v1.32.2") - .WithWorkerNodes(2); + .WithWorkerNodes(2) + .WithNodeImage("myacr.azurecr.io/kindest/node:v1.32.2") + .WithNodeMount(hostPath, "/kind-data", readOnly: true); using var app = builder.Build(); var model = app.Services.GetRequiredService(); var kindEnv = Assert.Single(model.Resources.OfType()); Assert.True(kindEnv.TryGetLastAnnotation(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(hostPath, mount.HostPath); + Assert.Equal("/kind-data", mount.ContainerPath); + Assert.True(mount.ReadOnly); } [Fact] @@ -139,4 +146,4 @@ public void WithKindIsInvisibleInRunMode() Assert.Empty(model.Resources.OfType()); Assert.Empty(model.Resources.OfType()); } -} +} \ No newline at end of file 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