Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// End-to-end tests for Aspire CLI publishing to Kubernetes/Helm.
Expand All @@ -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()
Expand All @@ -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"]);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));

Expand All @@ -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 " +
Expand All @@ -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()
Expand All @@ -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));

Expand All @@ -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;
}
Expand Down
Loading