diff --git a/tests/Aspire.Cli.EndToEndTests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs similarity index 81% rename from tests/Aspire.Cli.EndToEndTests/KubernetesPublishTests.cs rename to tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs index 88a58ff4669..42b57f0ecb4 100644 --- a/tests/Aspire.Cli.EndToEndTests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.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 Aspire.Cli.EndToEndTests.Helpers; +using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; using Hex1b; using Hex1b.Automation; using Xunit; -namespace Aspire.Cli.EndToEndTests; +namespace Aspire.Cli.EndToEnd.Tests; /// /// End-to-end tests for Aspire CLI publishing to Kubernetes/Helm. @@ -18,9 +18,13 @@ namespace Aspire.Cli.EndToEndTests; public sealed class KubernetesPublishTests(ITestOutputHelper output) { private const string ProjectName = "AspireKubernetesPublishTest"; - private const string KindVersion = "v0.31.0"; - private const string HelmVersion = "v3.17.3"; - private const string ClusterName = "aspire-e2e-test"; + private const string ClusterNamePrefix = "aspire-e2e"; + + private static string KindVersion => Environment.GetEnvironmentVariable("KIND_VERSION") ?? "v0.31.0"; + private static string HelmVersion => Environment.GetEnvironmentVariable("HELM_VERSION") ?? "v3.17.3"; + + private static string GenerateUniqueClusterName() => + $"{ClusterNamePrefix}-{Guid.NewGuid():N}"[..32]; // KinD cluster names max 32 chars [Fact] public async Task CreateAndPublishToKubernetes() @@ -31,9 +35,15 @@ public async Task CreateAndPublishToKubernetes() var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndPublishToKubernetes)); + var clusterName = GenerateUniqueClusterName(); + + output.WriteLine($"Using KinD version: {KindVersion}"); + output.WriteLine($"Using Helm version: {HelmVersion}"); + output.WriteLine($"Using cluster name: {clusterName}"); var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); @@ -116,12 +126,17 @@ public async Task CreateAndPublishToKubernetes() // Phase 2: Create KinD cluster // ===================================================================== - sequenceBuilder.Type($"kind create cluster --name={ClusterName} --wait=120s") + // Delete any existing cluster with the same name to ensure a clean state + sequenceBuilder.Type($"kind delete cluster --name={clusterName} || true") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + sequenceBuilder.Type($"kind create cluster --name={clusterName} --wait=120s") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); // Verify cluster is ready - sequenceBuilder.Type($"kubectl cluster-info --context kind-{ClusterName}") + sequenceBuilder.Type($"kubectl cluster-info --context kind-{clusterName}") .Enter() .WaitForSuccessPrompt(counter); @@ -162,18 +177,12 @@ public async Task CreateAndPublishToKubernetes() // Step 3: Add Aspire.Hosting.Kubernetes package using aspire add // Pass the package name directly as an argument to avoid interactive selection + // The version selection prompt always appears for 'aspire add' sequenceBuilder.Type("aspire add Aspire.Hosting.Kubernetes") - .Enter(); - - // In CI, aspire add shows a version selection prompt (unlike aspire new which auto-selects when channel is set) - if (isCI) - { - sequenceBuilder - .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) - .Enter(); // select first version (PR build) - } - - sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + .Enter() + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // select first version + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); // Step 4: Modify AppHost's main file to add Kubernetes environment // We'll use a callback to modify the file during sequence execution @@ -262,11 +271,11 @@ public async Task CreateAndPublishToKubernetes() // Load the built images into the KinD cluster // KinD runs containers inside Docker, so we need to load images into the cluster's nodes - sequenceBuilder.Type($"kind load docker-image apiservice:latest --name={ClusterName}") + sequenceBuilder.Type($"kind load docker-image apiservice:latest --name={clusterName}") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); - sequenceBuilder.Type($"kind load docker-image webfrontend:latest --name={ClusterName}") + sequenceBuilder.Type($"kind load docker-image webfrontend:latest --name={clusterName}") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); @@ -292,7 +301,8 @@ public async Task CreateAndPublishToKubernetes() // Install the Helm chart using the real container images built by Aspire // The images are already loaded into KinD, so we use the default values.yaml // which references apiservice:latest and webfrontend:latest - // Override ports to ensure unique values and avoid any duplicate port issues + // Override ports to ensure unique values per service - the Helm chart may have + // duplicate port defaults that cause "port already allocated" errors during deployment sequenceBuilder.Type("helm install aspire-app helm-output " + "--set parameters.apiservice.port_http=8080 " + "--set parameters.apiservice.port_https=8443 " + @@ -312,6 +322,11 @@ public async Task CreateAndPublishToKubernetes() .Enter() .WaitForSuccessPrompt(counter); + // Wait for all pods to be ready (not just created) + sequenceBuilder.Type("kubectl wait --for=condition=Ready pod --all --timeout=120s") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + // Check all Kubernetes resources were created sequenceBuilder.Type("kubectl get all") .Enter() @@ -332,7 +347,7 @@ public async Task CreateAndPublishToKubernetes() .WaitForSuccessPrompt(counter); // Delete the KinD cluster - sequenceBuilder.Type($"kind delete cluster --name={ClusterName}") + sequenceBuilder.Type($"kind delete cluster --name={clusterName}") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); @@ -341,7 +356,31 @@ public async Task CreateAndPublishToKubernetes() var sequence = sequenceBuilder.Build(); - await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + try + { + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + } + finally + { + // Best-effort cleanup: ensure cluster is deleted even if test fails + // This runs outside the terminal sequence to guarantee execution + try + { + using var cleanupProcess = new System.Diagnostics.Process(); + cleanupProcess.StartInfo.FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "kind"); + cleanupProcess.StartInfo.Arguments = $"delete cluster --name={clusterName}"; + cleanupProcess.StartInfo.RedirectStandardOutput = true; + cleanupProcess.StartInfo.RedirectStandardError = true; + cleanupProcess.StartInfo.UseShellExecute = false; + cleanupProcess.Start(); + await cleanupProcess.WaitForExitAsync(TestContext.Current.CancellationToken); + output.WriteLine($"Cleanup: KinD cluster '{clusterName}' deleted (exit code: {cleanupProcess.ExitCode})"); + } + catch (Exception ex) + { + output.WriteLine($"Cleanup: Failed to delete KinD cluster '{clusterName}': {ex.Message}"); + } + } await pendingRun; }