From 43cbdd14b972456a2bd9b3ebd752ba4b6fb1dd2e Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 5 Feb 2026 13:44:41 +1100 Subject: [PATCH 01/21] Add AKS starter deployment E2E test (Phase 1) This adds a new end-to-end deployment test that validates Azure Kubernetes Service (AKS) infrastructure creation: - Creates resource group, ACR, and AKS cluster - Configures kubectl credentials - Verifies cluster connectivity - Cleans up resources after test Phase 1 focuses on infrastructure only - Aspire deployment will be added in subsequent phases. --- .../AksStarterDeploymentTests.cs | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs new file mode 100644 index 00000000000..e620c6f566d --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -0,0 +1,220 @@ +// 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.Tests.Utils; +using Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Aspire applications to Azure Kubernetes Service (AKS). +/// +public sealed class AksStarterDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 45 minutes to allow for AKS provisioning (~10-15 min) plus deployment. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(45); + + [Fact] + public async Task DeployStarterTemplateToAks() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployStarterTemplateToAksCore(cancellationToken); + } + + private async Task DeployStarterTemplateToAksCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateToAks)); + var startTime = DateTime.UtcNow; + + // Generate unique names for Azure resources + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aks"); + var clusterName = $"aks-{DeploymentE2ETestHelpers.GetRunId()}-{DeploymentE2ETestHelpers.GetRunAttempt()}"; + // ACR names must be alphanumeric only, 5-50 chars, globally unique + var acrName = $"acr{DeploymentE2ETestHelpers.GetRunId()}{DeploymentE2ETestHelpers.GetRunAttempt()}".ToLowerInvariant(); + // Ensure ACR name is valid (alphanumeric, 5-50 chars) + acrName = new string(acrName.Where(char.IsLetterOrDigit).Take(50).ToArray()); + if (acrName.Length < 5) + { + acrName = $"acrtest{Guid.NewGuid():N}"[..24]; + } + + output.WriteLine($"Test: {nameof(DeployStarterTemplateToAks)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"AKS Cluster: {clusterName}"); + output.WriteLine($"ACR Name: {acrName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Create resource group + output.WriteLine("Step 2: Creating resource group..."); + sequenceBuilder + .Type($"az group create --name {resourceGroupName} --location westus3 --output table") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 3: Create Azure Container Registry + output.WriteLine("Step 3: Creating Azure Container Registry..."); + sequenceBuilder + .Type($"az acr create --resource-group {resourceGroupName} --name {acrName} --sku Basic --output table") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 4: Create AKS cluster with ACR attached + // Using minimal configuration: 1 node, Standard_B2s (smallest viable) + output.WriteLine("Step 4: Creating AKS cluster (this may take 10-15 minutes)..."); + sequenceBuilder + .Type($"az aks create " + + $"--resource-group {resourceGroupName} " + + $"--name {clusterName} " + + $"--node-count 1 " + + $"--node-vm-size Standard_B2s " + + $"--generate-ssh-keys " + + $"--attach-acr {acrName} " + + $"--enable-managed-identity " + + $"--output table") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(20)); + + // Step 5: Configure kubectl credentials + output.WriteLine("Step 5: Configuring kubectl credentials..."); + sequenceBuilder + .Type($"az aks get-credentials --resource-group {resourceGroupName} --name {clusterName} --overwrite-existing") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 6: Verify kubectl connectivity + output.WriteLine("Step 6: Verifying kubectl connectivity..."); + sequenceBuilder + .Type("kubectl get nodes") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 7: Verify cluster is healthy + output.WriteLine("Step 7: Verifying cluster health..."); + sequenceBuilder + .Type("kubectl cluster-info") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 8: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"AKS cluster creation and verification completed in {duration}"); + + // Report success + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployStarterTemplateToAks), + resourceGroupName, + new Dictionary + { + ["cluster"] = clusterName, + ["acr"] = acrName + }, + duration); + + output.WriteLine("✅ Phase 1 Test passed - AKS cluster created and verified!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployStarterTemplateToAks), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + // Clean up the resource group we created (includes AKS cluster and ACR) + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + } + + /// + /// Triggers cleanup of a specific resource group. + /// This is fire-and-forget - the hourly cleanup workflow handles any missed resources. + /// + private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output) + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +} From 6ef79de349941ff97f39a9e6dff722ebaec22e11 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 5 Feb 2026 14:20:10 +1100 Subject: [PATCH 02/21] Fix AKS test: register required resource providers Add step to register Microsoft.ContainerService and Microsoft.ContainerRegistry resource providers before attempting to create AKS resources. This fixes the MissingSubscriptionRegistration error when the subscription hasn't been configured for AKS usage. --- .../AksStarterDeploymentTests.cs | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index e620c6f566d..7268fcfa221 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -90,23 +90,32 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation output.WriteLine("Step 1: Preparing environment..."); sequenceBuilder.PrepareEnvironment(workspace, counter); - // Step 2: Create resource group - output.WriteLine("Step 2: Creating resource group..."); + // Step 2: Register required resource providers + // AKS requires Microsoft.ContainerService and Microsoft.ContainerRegistry + output.WriteLine("Step 2: Registering required resource providers..."); + sequenceBuilder + .Type("az provider register --namespace Microsoft.ContainerService --wait && " + + "az provider register --namespace Microsoft.ContainerRegistry --wait") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 3: Create resource group + output.WriteLine("Step 3: Creating resource group..."); sequenceBuilder .Type($"az group create --name {resourceGroupName} --location westus3 --output table") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); - // Step 3: Create Azure Container Registry - output.WriteLine("Step 3: Creating Azure Container Registry..."); + // Step 4: Create Azure Container Registry + output.WriteLine("Step 4: Creating Azure Container Registry..."); sequenceBuilder .Type($"az acr create --resource-group {resourceGroupName} --name {acrName} --sku Basic --output table") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); - // Step 4: Create AKS cluster with ACR attached + // Step 5: Create AKS cluster with ACR attached // Using minimal configuration: 1 node, Standard_B2s (smallest viable) - output.WriteLine("Step 4: Creating AKS cluster (this may take 10-15 minutes)..."); + output.WriteLine("Step 5: Creating AKS cluster (this may take 10-15 minutes)..."); sequenceBuilder .Type($"az aks create " + $"--resource-group {resourceGroupName} " + @@ -120,28 +129,28 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(20)); - // Step 5: Configure kubectl credentials - output.WriteLine("Step 5: Configuring kubectl credentials..."); + // Step 6: Configure kubectl credentials + output.WriteLine("Step 6: Configuring kubectl credentials..."); sequenceBuilder .Type($"az aks get-credentials --resource-group {resourceGroupName} --name {clusterName} --overwrite-existing") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 6: Verify kubectl connectivity - output.WriteLine("Step 6: Verifying kubectl connectivity..."); + // Step 7: Verify kubectl connectivity + output.WriteLine("Step 7: Verifying kubectl connectivity..."); sequenceBuilder .Type("kubectl get nodes") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 7: Verify cluster is healthy - output.WriteLine("Step 7: Verifying cluster health..."); + // Step 8: Verify cluster is healthy + output.WriteLine("Step 8: Verifying cluster health..."); sequenceBuilder .Type("kubectl cluster-info") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 8: Exit terminal + // Step 9: Exit terminal sequenceBuilder .Type("exit") .Enter(); From 7b754c1be97c2e71d8db33939e4612693a7958a7 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 5 Feb 2026 14:51:54 +1100 Subject: [PATCH 03/21] Fix AKS test: use Standard_B2s_v2 VM size The subscription in westus3 doesn't have access to Standard_B2s, only the v2 series VMs. Changed to Standard_B2s_v2 which is available. --- .../AksStarterDeploymentTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index 7268fcfa221..05dc6cf1e75 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -114,14 +114,14 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); // Step 5: Create AKS cluster with ACR attached - // Using minimal configuration: 1 node, Standard_B2s (smallest viable) + // Using minimal configuration: 1 node, Standard_B2s_v2 (smallest viable in westus3) output.WriteLine("Step 5: Creating AKS cluster (this may take 10-15 minutes)..."); sequenceBuilder .Type($"az aks create " + $"--resource-group {resourceGroupName} " + $"--name {clusterName} " + $"--node-count 1 " + - $"--node-vm-size Standard_B2s " + + $"--node-vm-size Standard_B2s_v2 " + $"--generate-ssh-keys " + $"--attach-acr {acrName} " + $"--enable-managed-identity " + From 886b180e7e9531d209d8ddc2718841b65ffca6cd Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 5 Feb 2026 15:28:02 +1100 Subject: [PATCH 04/21] Fix AKS test: use Standard_D2s_v3 VM size The subscription has zero quota for B-series VMs in westus3. Changed to Standard_D2s_v3 which is a widely-available D-series VM with typical quota. --- .../AksStarterDeploymentTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index 05dc6cf1e75..2c70e19887f 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -114,14 +114,14 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); // Step 5: Create AKS cluster with ACR attached - // Using minimal configuration: 1 node, Standard_B2s_v2 (smallest viable in westus3) + // Using minimal configuration: 1 node, Standard_D2s_v3 (widely available with quota) output.WriteLine("Step 5: Creating AKS cluster (this may take 10-15 minutes)..."); sequenceBuilder .Type($"az aks create " + $"--resource-group {resourceGroupName} " + $"--name {clusterName} " + $"--node-count 1 " + - $"--node-vm-size Standard_B2s_v2 " + + $"--node-vm-size Standard_D2s_v3 " + $"--generate-ssh-keys " + $"--attach-acr {acrName} " + $"--enable-managed-identity " + From 4318b512676b3bc49c606a5b24e00e1647cf85ed Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 5 Feb 2026 16:08:11 +1100 Subject: [PATCH 05/21] Add Phase 2 & 3: Aspire project creation, Helm chart generation, and AKS deployment Phase 2 additions: - Create Aspire starter project using 'aspire new' - Add Aspire.Hosting.Kubernetes package via 'aspire add' - Modify AppHost.cs to call AddKubernetesEnvironment() with ACR config - Login to ACR for Docker image push - Run 'aspire publish' to generate Helm charts and push images Phase 3 additions: - Deploy Helm chart to AKS using 'helm install' - Verify pods are running with kubectl - Verify deployments are healthy This completes the full end-to-end flow: AKS cluster creation -> Aspire project creation -> Helm chart generation -> Deployment to Kubernetes --- .../AksStarterDeploymentTests.cs | 164 +++++++++++++++++- 1 file changed, 160 insertions(+), 4 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index 2c70e19887f..736a5e12913 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -86,6 +86,32 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation var counter = new SequenceCounter(); var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + // Pattern searchers for aspire new interactive prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find("Do you want to create a test project?"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Project name for the Aspire application + var projectName = "AksStarter"; + // Step 1: Prepare environment output.WriteLine("Step 1: Preparing environment..."); sequenceBuilder.PrepareEnvironment(workspace, counter); @@ -150,7 +176,136 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 9: Exit terminal + // ===== PHASE 2: Create Aspire Project and Generate Helm Charts ===== + + // Step 9: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 9: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 10: Create starter project using aspire new with interactive prompts + output.WriteLine("Step 10: Creating Aspire starter project..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first template (Starter App ASP.NET Core/Blazor) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(projectName) + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default output path + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for localhost URLs (default) + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Enter() // Select "No" for Redis Cache + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for test project (default) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 11: Navigate to project directory + output.WriteLine("Step 11: Navigating to project directory..."); + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 12: Add Aspire.Hosting.Kubernetes package + output.WriteLine("Step 12: Adding Kubernetes hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Kubernetes") + .Enter(); + + // In CI, aspire add shows a version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 13: Modify AppHost.cs to add Kubernetes environment + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + + output.WriteLine($"Modifying AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + // Insert the Kubernetes environment before builder.Build().Run(); + var buildRunPattern = "builder.Build().Run();"; + var replacement = $""" +// Add Kubernetes environment for deployment +builder.AddKubernetesEnvironment("k8s") + .WithProperties(props => props.ContainerRegistry = "{acrName}.azurecr.io"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine("Modified AppHost.cs with AddKubernetesEnvironment"); + }); + + // Step 14: Navigate to AppHost project directory + output.WriteLine("Step 14: Navigating to AppHost directory..."); + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 15: Login to ACR for Docker push + output.WriteLine("Step 15: Logging into Azure Container Registry..."); + sequenceBuilder + .Type($"az acr login --name {acrName}") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 16: Run aspire publish to generate Helm charts and push images + output.WriteLine("Step 16: Running aspire publish to generate Helm charts..."); + sequenceBuilder + .Type($"aspire publish --output-path ../charts") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(10)); + + // Step 17: Verify Helm chart was generated + output.WriteLine("Step 17: Verifying Helm chart generation..."); + sequenceBuilder + .Type("ls -la ../charts && cat ../charts/Chart.yaml") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // ===== PHASE 3: Deploy to AKS and Verify ===== + + // Step 18: Deploy Helm chart to AKS + output.WriteLine("Step 18: Deploying Helm chart to AKS..."); + sequenceBuilder + .Type("helm install aksstarter ../charts --namespace default --wait --timeout 10m") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12)); + + // Step 19: Verify pods are running + output.WriteLine("Step 19: Verifying pods are running..."); + sequenceBuilder + .Type("kubectl get pods -n default") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 20: Verify deployments are healthy + output.WriteLine("Step 20: Verifying deployments..."); + sequenceBuilder + .Type("kubectl get deployments -n default") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 21: Exit terminal sequenceBuilder .Type("exit") .Enter(); @@ -160,7 +315,7 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation await pendingRun; var duration = DateTime.UtcNow - startTime; - output.WriteLine($"AKS cluster creation and verification completed in {duration}"); + output.WriteLine($"Full AKS deployment completed in {duration}"); // Report success DeploymentReporter.ReportDeploymentSuccess( @@ -169,11 +324,12 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation new Dictionary { ["cluster"] = clusterName, - ["acr"] = acrName + ["acr"] = acrName, + ["project"] = projectName }, duration); - output.WriteLine("✅ Phase 1 Test passed - AKS cluster created and verified!"); + output.WriteLine("✅ Test passed - Aspire app deployed to AKS via Helm!"); } catch (Exception ex) { From d38acb6651ae7e58dc31d0692e3f118e0d0f7898 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 5 Feb 2026 16:43:55 +1100 Subject: [PATCH 06/21] Fix Kubernetes deployment: Add container build/push step Changes: - Remove invalid ContainerRegistry property from AddKubernetesEnvironment - Add pragma warning disable for experimental ASPIREPIPELINES001 - Add container build step using dotnet publish /t:PublishContainer - Push container images to ACR before Helm deployment - Override Helm image values with ACR image references The Kubernetes publisher generates Helm charts but doesn't build containers. We need to build and push containers separately using dotnet publish. --- .../AksStarterDeploymentTests.cs | 66 ++++++++++++++----- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index 736a5e12913..a6c3fae033e 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -240,15 +240,21 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation // Insert the Kubernetes environment before builder.Build().Run(); var buildRunPattern = "builder.Build().Run();"; - var replacement = $""" + var replacement = """ // Add Kubernetes environment for deployment -builder.AddKubernetesEnvironment("k8s") - .WithProperties(props => props.ContainerRegistry = "{acrName}.azurecr.io"); +builder.AddKubernetesEnvironment("k8s"); builder.Build().Run(); """; content = content.Replace(buildRunPattern, replacement); + + // Add required pragma to suppress experimental warning + if (!content.Contains("#pragma warning disable ASPIREPIPELINES001")) + { + content = "#pragma warning disable ASPIREPIPELINES001\n" + content; + } + File.WriteAllText(appHostFilePath, content); output.WriteLine("Modified AppHost.cs with AddKubernetesEnvironment"); @@ -268,15 +274,43 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); - // Step 16: Run aspire publish to generate Helm charts and push images - output.WriteLine("Step 16: Running aspire publish to generate Helm charts..."); + // Step 16: Build and push container images to ACR + // The starter template creates webfrontend and apiservice projects + output.WriteLine("Step 16: Building and pushing container images to ACR..."); + sequenceBuilder + .Type($"cd .. && " + + $"dotnet publish {projectName}.Web/{projectName}.Web.csproj " + + $"/t:PublishContainer " + + $"/p:ContainerRegistry={acrName}.azurecr.io " + + $"/p:ContainerImageName=webfrontend " + + $"/p:ContainerImageTag=latest") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + sequenceBuilder + .Type($"dotnet publish {projectName}.ApiService/{projectName}.ApiService.csproj " + + $"/t:PublishContainer " + + $"/p:ContainerRegistry={acrName}.azurecr.io " + + $"/p:ContainerImageName=apiservice " + + $"/p:ContainerImageTag=latest") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Navigate back to AppHost directory + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 17: Run aspire publish to generate Helm charts + output.WriteLine("Step 17: Running aspire publish to generate Helm charts..."); sequenceBuilder .Type($"aspire publish --output-path ../charts") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(10)); - // Step 17: Verify Helm chart was generated - output.WriteLine("Step 17: Verifying Helm chart generation..."); + // Step 18: Verify Helm chart was generated + output.WriteLine("Step 18: Verifying Helm chart generation..."); sequenceBuilder .Type("ls -la ../charts && cat ../charts/Chart.yaml") .Enter() @@ -284,28 +318,30 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation // ===== PHASE 3: Deploy to AKS and Verify ===== - // Step 18: Deploy Helm chart to AKS - output.WriteLine("Step 18: Deploying Helm chart to AKS..."); + // Step 19: Deploy Helm chart to AKS with ACR image overrides + output.WriteLine("Step 19: Deploying Helm chart to AKS..."); sequenceBuilder - .Type("helm install aksstarter ../charts --namespace default --wait --timeout 10m") + .Type($"helm install aksstarter ../charts --namespace default --wait --timeout 10m " + + $"--set webfrontend.image={acrName}.azurecr.io/webfrontend:latest " + + $"--set apiservice.image={acrName}.azurecr.io/apiservice:latest") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12)); - // Step 19: Verify pods are running - output.WriteLine("Step 19: Verifying pods are running..."); + // Step 20: Verify pods are running + output.WriteLine("Step 20: Verifying pods are running..."); sequenceBuilder .Type("kubectl get pods -n default") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 20: Verify deployments are healthy - output.WriteLine("Step 20: Verifying deployments..."); + // Step 21: Verify deployments are healthy + output.WriteLine("Step 21: Verifying deployments..."); sequenceBuilder .Type("kubectl get deployments -n default") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 21: Exit terminal + // Step 22: Exit terminal sequenceBuilder .Type("exit") .Enter(); From f46b53e3c0bfd97403a935bfa1e58148a1e1edc5 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 5 Feb 2026 17:14:28 +1100 Subject: [PATCH 07/21] Fix duplicate Service ports in Kubernetes publisher When multiple endpoints resolve to the same port number, the Service manifest generator was creating duplicate port entries, which Kubernetes rejects as invalid. This fix deduplicates ports by (port, protocol) before adding them to the Service spec. Fixes the error: Service 'xxx-service' is invalid: spec.ports[1]: Duplicate value --- .../Extensions/ResourceExtensions.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs index c7f265e46be..8bd566df9b8 100644 --- a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs @@ -140,8 +140,16 @@ internal static StatefulSet ToStatefulSet(this IResource resource, KubernetesRes }, }; + // Deduplicate ports by port number and protocol to avoid invalid Service specs + var addedPorts = new HashSet<(string Port, string Protocol)>(); foreach (var (_, mapping) in context.EndpointMappings) { + var portKey = (mapping.Port.ToScalar(), mapping.Protocol); + if (!addedPorts.Add(portKey)) + { + continue; // Skip duplicate port/protocol combinations + } + service.Spec.Ports.Add( new() { From f175bca2a57664c34fdabbf42853ce06a51dbde8 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 5 Feb 2026 17:43:43 +1100 Subject: [PATCH 08/21] Add explicit AKS-ACR attachment verification step Added Step 6 to explicitly run 'az aks update --attach-acr' after AKS cluster creation to ensure the AcrPull role assignment has properly propagated. This addresses potential image pull permission issues where AKS cannot pull images from the attached ACR. Also renumbered all subsequent steps to maintain proper ordering. --- .../AksStarterDeploymentTests.cs | 71 ++++++++++--------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index a6c3fae033e..b4a3a014a4d 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -155,22 +155,29 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(20)); - // Step 6: Configure kubectl credentials - output.WriteLine("Step 6: Configuring kubectl credentials..."); + // Step 6: Ensure AKS can pull from ACR (update attachment to ensure role propagation) + output.WriteLine("Step 6: Verifying AKS-ACR integration..."); + sequenceBuilder + .Type($"az aks update --resource-group {resourceGroupName} --name {clusterName} --attach-acr {acrName}") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 7: Configure kubectl credentials + output.WriteLine("Step 7: Configuring kubectl credentials..."); sequenceBuilder .Type($"az aks get-credentials --resource-group {resourceGroupName} --name {clusterName} --overwrite-existing") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 7: Verify kubectl connectivity - output.WriteLine("Step 7: Verifying kubectl connectivity..."); + // Step 8: Verify kubectl connectivity + output.WriteLine("Step 8: Verifying kubectl connectivity..."); sequenceBuilder .Type("kubectl get nodes") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 8: Verify cluster is healthy - output.WriteLine("Step 8: Verifying cluster health..."); + // Step 9: Verify cluster is healthy + output.WriteLine("Step 9: Verifying cluster health..."); sequenceBuilder .Type("kubectl cluster-info") .Enter() @@ -178,15 +185,15 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation // ===== PHASE 2: Create Aspire Project and Generate Helm Charts ===== - // Step 9: Set up CLI environment (in CI) + // Step 10: Set up CLI environment (in CI) if (DeploymentE2ETestHelpers.IsRunningInCI) { - output.WriteLine("Step 9: Using pre-installed Aspire CLI from local build..."); + output.WriteLine("Step 10: Using pre-installed Aspire CLI from local build..."); sequenceBuilder.SourceAspireCliEnvironment(counter); } - // Step 10: Create starter project using aspire new with interactive prompts - output.WriteLine("Step 10: Creating Aspire starter project..."); + // Step 11: Create starter project using aspire new with interactive prompts + output.WriteLine("Step 11: Creating Aspire starter project..."); sequenceBuilder.Type("aspire new") .Enter() .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) @@ -205,15 +212,15 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() // Select "No" for test project (default) .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); - // Step 11: Navigate to project directory - output.WriteLine("Step 11: Navigating to project directory..."); + // Step 12: Navigate to project directory + output.WriteLine("Step 12: Navigating to project directory..."); sequenceBuilder .Type($"cd {projectName}") .Enter() .WaitForSuccessPrompt(counter); - // Step 12: Add Aspire.Hosting.Kubernetes package - output.WriteLine("Step 12: Adding Kubernetes hosting package..."); + // Step 13: Add Aspire.Hosting.Kubernetes package + output.WriteLine("Step 13: Adding Kubernetes hosting package..."); sequenceBuilder.Type("aspire add Aspire.Hosting.Kubernetes") .Enter(); @@ -227,7 +234,7 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); - // Step 13: Modify AppHost.cs to add Kubernetes environment + // Step 14: Modify AppHost.cs to add Kubernetes environment sequenceBuilder.ExecuteCallback(() => { var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); @@ -260,23 +267,23 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation output.WriteLine("Modified AppHost.cs with AddKubernetesEnvironment"); }); - // Step 14: Navigate to AppHost project directory - output.WriteLine("Step 14: Navigating to AppHost directory..."); + // Step 15: Navigate to AppHost project directory + output.WriteLine("Step 15: Navigating to AppHost directory..."); sequenceBuilder .Type($"cd {projectName}.AppHost") .Enter() .WaitForSuccessPrompt(counter); - // Step 15: Login to ACR for Docker push - output.WriteLine("Step 15: Logging into Azure Container Registry..."); + // Step 16: Login to ACR for Docker push + output.WriteLine("Step 16: Logging into Azure Container Registry..."); sequenceBuilder .Type($"az acr login --name {acrName}") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); - // Step 16: Build and push container images to ACR + // Step 17: Build and push container images to ACR // The starter template creates webfrontend and apiservice projects - output.WriteLine("Step 16: Building and pushing container images to ACR..."); + output.WriteLine("Step 17: Building and pushing container images to ACR..."); sequenceBuilder .Type($"cd .. && " + $"dotnet publish {projectName}.Web/{projectName}.Web.csproj " + @@ -302,15 +309,15 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() .WaitForSuccessPrompt(counter); - // Step 17: Run aspire publish to generate Helm charts - output.WriteLine("Step 17: Running aspire publish to generate Helm charts..."); + // Step 18: Run aspire publish to generate Helm charts + output.WriteLine("Step 18: Running aspire publish to generate Helm charts..."); sequenceBuilder .Type($"aspire publish --output-path ../charts") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(10)); - // Step 18: Verify Helm chart was generated - output.WriteLine("Step 18: Verifying Helm chart generation..."); + // Step 19: Verify Helm chart was generated + output.WriteLine("Step 19: Verifying Helm chart generation..."); sequenceBuilder .Type("ls -la ../charts && cat ../charts/Chart.yaml") .Enter() @@ -318,8 +325,8 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation // ===== PHASE 3: Deploy to AKS and Verify ===== - // Step 19: Deploy Helm chart to AKS with ACR image overrides - output.WriteLine("Step 19: Deploying Helm chart to AKS..."); + // Step 20: Deploy Helm chart to AKS with ACR image overrides + output.WriteLine("Step 20: Deploying Helm chart to AKS..."); sequenceBuilder .Type($"helm install aksstarter ../charts --namespace default --wait --timeout 10m " + $"--set webfrontend.image={acrName}.azurecr.io/webfrontend:latest " + @@ -327,21 +334,21 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12)); - // Step 20: Verify pods are running - output.WriteLine("Step 20: Verifying pods are running..."); + // Step 21: Verify pods are running + output.WriteLine("Step 21: Verifying pods are running..."); sequenceBuilder .Type("kubectl get pods -n default") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 21: Verify deployments are healthy - output.WriteLine("Step 21: Verifying deployments..."); + // Step 22: Verify deployments are healthy + output.WriteLine("Step 22: Verifying deployments..."); sequenceBuilder .Type("kubectl get deployments -n default") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 22: Exit terminal + // Step 23: Exit terminal sequenceBuilder .Type("exit") .Enter(); From 3ab65aa9f897e133b8407cd3b6c5203eeb32c4c9 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 11:00:52 +1100 Subject: [PATCH 09/21] Fix AKS image pull: correct Helm value paths and add ACR check --- .../AksStarterDeploymentTests.cs | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index b4a3a014a4d..467e89b9d59 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -325,30 +325,38 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation // ===== PHASE 3: Deploy to AKS and Verify ===== - // Step 20: Deploy Helm chart to AKS with ACR image overrides - output.WriteLine("Step 20: Deploying Helm chart to AKS..."); + // Step 20: Verify ACR role assignment has propagated before deploying + output.WriteLine("Step 20: Verifying AKS can pull from ACR..."); + sequenceBuilder + .Type($"az aks check-acr --resource-group {resourceGroupName} --name {clusterName} --acr {acrName}.azurecr.io") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 21: Deploy Helm chart to AKS with ACR image overrides + // Image values use the path: parameters.._image + output.WriteLine("Step 21: Deploying Helm chart to AKS..."); sequenceBuilder .Type($"helm install aksstarter ../charts --namespace default --wait --timeout 10m " + - $"--set webfrontend.image={acrName}.azurecr.io/webfrontend:latest " + - $"--set apiservice.image={acrName}.azurecr.io/apiservice:latest") + $"--set parameters.webfrontend.webfrontend_image={acrName}.azurecr.io/webfrontend:latest " + + $"--set parameters.apiservice.apiservice_image={acrName}.azurecr.io/apiservice:latest") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12)); - // Step 21: Verify pods are running - output.WriteLine("Step 21: Verifying pods are running..."); + // Step 22: Verify pods are running + output.WriteLine("Step 22: Verifying pods are running..."); sequenceBuilder .Type("kubectl get pods -n default") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 22: Verify deployments are healthy - output.WriteLine("Step 22: Verifying deployments..."); + // Step 23: Verify deployments are healthy + output.WriteLine("Step 23: Verifying deployments..."); sequenceBuilder .Type("kubectl get deployments -n default") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 23: Exit terminal + // Step 24: Exit terminal sequenceBuilder .Type("exit") .Enter(); From da42bb78a4a366ea27ecd6c45e661d46c7a88840 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 11:52:58 +1100 Subject: [PATCH 10/21] Fix duplicate Service/container ports: compare underlying values not Helm expressions --- .../Extensions/ResourceExtensions.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs index 8bd566df9b8..6f77f2c0f70 100644 --- a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs @@ -140,11 +140,15 @@ internal static StatefulSet ToStatefulSet(this IResource resource, KubernetesRes }, }; - // Deduplicate ports by port number and protocol to avoid invalid Service specs + // Deduplicate ports by underlying value and protocol to avoid invalid Service specs. + // We compare using the underlying HelmValue.Value (e.g., 8080) rather than ToScalar() + // because different endpoints (http, https) may have distinct Helm expressions that + // resolve to the same port value. var addedPorts = new HashSet<(string Port, string Protocol)>(); foreach (var (_, mapping) in context.EndpointMappings) { - var portKey = (mapping.Port.ToScalar(), mapping.Protocol); + var portValue = mapping.Port.ValueString ?? mapping.Port.ToScalar(); + var portKey = (portValue, mapping.Protocol); if (!addedPorts.Add(portKey)) { continue; // Skip duplicate port/protocol combinations @@ -276,8 +280,16 @@ private static ContainerV1 WithContainerPorts(this ContainerV1 container, Kubern return container; } + var addedPorts = new HashSet<(string Port, string Protocol)>(); foreach (var (_, mapping) in context.EndpointMappings) { + var portValue = mapping.Port.ValueString ?? mapping.Port.ToScalar(); + var portKey = (portValue, mapping.Protocol); + if (!addedPorts.Add(portKey)) + { + continue; + } + container.Ports.Add( new() { From 1057b42d159a21174069673d72bb13ed69b2a5cc Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 12:20:54 +1100 Subject: [PATCH 11/21] Re-enable AppService deployment tests --- .../AppServicePythonDeploymentTests.cs | 2 +- .../AppServiceReactDeploymentTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs index 009b5cb5b4a..e4887306758 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs @@ -18,7 +18,7 @@ public sealed class AppServicePythonDeploymentTests(ITestOutputHelper output) // Full deployments can take up to 30 minutes if Azure infrastructure is backed up. private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); - [Fact(Skip = "App Service provisioning takes longer than 30 minutes, causing timeouts. Skipped until infrastructure issues are resolved.")] + [Fact] public async Task DeployPythonFastApiTemplateToAzureAppService() { using var cts = new CancellationTokenSource(s_testTimeout); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs index c74eb0ee30b..cd0509f3b69 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs @@ -18,7 +18,7 @@ public sealed class AppServiceReactDeploymentTests(ITestOutputHelper output) // Full deployments can take up to 30 minutes if Azure infrastructure is backed up. private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); - [Fact(Skip = "App Service provisioning takes longer than 30 minutes, causing timeouts. Skipped until infrastructure issues are resolved.")] + [Fact] public async Task DeployReactTemplateToAzureAppService() { using var cts = new CancellationTokenSource(s_testTimeout); From 45adcbb0185857e77594908ee6ec14b1b153821b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 12:54:58 +1100 Subject: [PATCH 12/21] Add endpoint verification via kubectl port-forward to AKS test --- .../AksStarterDeploymentTests.cs | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index 467e89b9d59..f4b9219ebc6 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -356,7 +356,34 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 24: Exit terminal + // Step 24: Verify apiservice is serving traffic via port-forward + output.WriteLine("Step 24: Verifying apiservice health endpoint..."); + sequenceBuilder + .Type("kubectl port-forward svc/apiservice-service 18080:8080 &") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)) + .Type("sleep 3 && curl -sf http://localhost:18080/health && echo ' OK'") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 25: Verify webfrontend is serving traffic via port-forward + output.WriteLine("Step 25: Verifying webfrontend health endpoint..."); + sequenceBuilder + .Type("kubectl port-forward svc/webfrontend-service 18081:8080 &") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)) + .Type("sleep 3 && curl -sf http://localhost:18081/health && echo ' OK'") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 26: Clean up port-forwards + output.WriteLine("Step 26: Cleaning up port-forwards..."); + sequenceBuilder + .Type("kill %1 %2 2>/dev/null; true") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)); + + // Step 27: Exit terminal sequenceBuilder .Type("exit") .Enter(); From c2aa4d7437724a13a38238a359b397aff969aa13 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 13:25:03 +1100 Subject: [PATCH 13/21] Wait for pods to be ready before port-forward verification --- .../AksStarterDeploymentTests.cs | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index f4b9219ebc6..506182cbe67 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -342,22 +342,29 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12)); - // Step 22: Verify pods are running - output.WriteLine("Step 22: Verifying pods are running..."); + // Step 22: Wait for pods to be ready + output.WriteLine("Step 22: Waiting for pods to be ready..."); + sequenceBuilder + .Type("kubectl wait --for=condition=ready pod --all -n default --timeout=120s") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 23: Verify pods are running + output.WriteLine("Step 23: Verifying pods are running..."); sequenceBuilder .Type("kubectl get pods -n default") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 23: Verify deployments are healthy - output.WriteLine("Step 23: Verifying deployments..."); + // Step 24: Verify deployments are healthy + output.WriteLine("Step 24: Verifying deployments..."); sequenceBuilder .Type("kubectl get deployments -n default") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 24: Verify apiservice is serving traffic via port-forward - output.WriteLine("Step 24: Verifying apiservice health endpoint..."); + // Step 25: Verify apiservice is serving traffic via port-forward + output.WriteLine("Step 25: Verifying apiservice health endpoint..."); sequenceBuilder .Type("kubectl port-forward svc/apiservice-service 18080:8080 &") .Enter() @@ -366,8 +373,8 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 25: Verify webfrontend is serving traffic via port-forward - output.WriteLine("Step 25: Verifying webfrontend health endpoint..."); + // Step 26: Verify webfrontend is serving traffic via port-forward + output.WriteLine("Step 26: Verifying webfrontend health endpoint..."); sequenceBuilder .Type("kubectl port-forward svc/webfrontend-service 18081:8080 &") .Enter() @@ -376,14 +383,14 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); - // Step 26: Clean up port-forwards - output.WriteLine("Step 26: Cleaning up port-forwards..."); + // Step 27: Clean up port-forwards + output.WriteLine("Step 27: Cleaning up port-forwards..."); sequenceBuilder .Type("kill %1 %2 2>/dev/null; true") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)); - // Step 27: Exit terminal + // Step 28: Exit terminal sequenceBuilder .Type("exit") .Enter(); From 41dd194cc46af3a4e14a3b3dfbe57ede8b1edd05 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 13:51:58 +1100 Subject: [PATCH 14/21] Use retry loop for health endpoint verification and log HTTP status codes --- .../AksStarterDeploymentTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index 506182cbe67..1c096c1352e 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -369,9 +369,10 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Type("kubectl port-forward svc/apiservice-service 18080:8080 &") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)) - .Type("sleep 3 && curl -sf http://localhost:18080/health && echo ' OK'") + // Use retry loop: app may need a few seconds to start accepting connections + .Type("for i in $(seq 1 10); do sleep 3 && curl -so /dev/null -w '%{http_code}' http://localhost:18080/health && break; done && echo ' OK'") .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); // Step 26: Verify webfrontend is serving traffic via port-forward output.WriteLine("Step 26: Verifying webfrontend health endpoint..."); @@ -379,9 +380,9 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Type("kubectl port-forward svc/webfrontend-service 18081:8080 &") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)) - .Type("sleep 3 && curl -sf http://localhost:18081/health && echo ' OK'") + .Type("for i in $(seq 1 10); do sleep 3 && curl -so /dev/null -w '%{http_code}' http://localhost:18081/health && break; done && echo ' OK'") .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); // Step 27: Clean up port-forwards output.WriteLine("Step 27: Cleaning up port-forwards..."); From d1d6551a897772f721d3cacfc611e8ec15828758 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 13:54:16 +1100 Subject: [PATCH 15/21] Use real app endpoints: /weatherforecast and / instead of /health --- .../AksStarterDeploymentTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index 1c096c1352e..e239919162b 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -364,23 +364,23 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); // Step 25: Verify apiservice is serving traffic via port-forward - output.WriteLine("Step 25: Verifying apiservice health endpoint..."); + // Use /weatherforecast (the actual API endpoint) since /health is only available in Development + output.WriteLine("Step 25: Verifying apiservice endpoint..."); sequenceBuilder .Type("kubectl port-forward svc/apiservice-service 18080:8080 &") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)) - // Use retry loop: app may need a few seconds to start accepting connections - .Type("for i in $(seq 1 10); do sleep 3 && curl -so /dev/null -w '%{http_code}' http://localhost:18080/health && break; done && echo ' OK'") + .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18080/weatherforecast -o /dev/null -w '%{http_code}' && echo ' OK' && break; done") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); // Step 26: Verify webfrontend is serving traffic via port-forward - output.WriteLine("Step 26: Verifying webfrontend health endpoint..."); + output.WriteLine("Step 26: Verifying webfrontend endpoint..."); sequenceBuilder .Type("kubectl port-forward svc/webfrontend-service 18081:8080 &") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)) - .Type("for i in $(seq 1 10); do sleep 3 && curl -so /dev/null -w '%{http_code}' http://localhost:18081/health && break; done && echo ' OK'") + .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/ -o /dev/null -w '%{http_code}' && echo ' OK' && break; done") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); From 1934af2b49494ed15e30a710de9451c78e4665aa Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 14:35:45 +1100 Subject: [PATCH 16/21] Improve comments explaining duplicate port dedup rationale --- .../Extensions/ResourceExtensions.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs index 6f77f2c0f70..34895776bdc 100644 --- a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs @@ -141,9 +141,16 @@ internal static StatefulSet ToStatefulSet(this IResource resource, KubernetesRes }; // Deduplicate ports by underlying value and protocol to avoid invalid Service specs. - // We compare using the underlying HelmValue.Value (e.g., 8080) rather than ToScalar() - // because different endpoints (http, https) may have distinct Helm expressions that - // resolve to the same port value. + // When a ProjectResource has both http and https endpoints with no explicit port, + // GenerateDefaultProjectEndpointMapping assigns port 8080 to both, creating separate + // EndpointMappings with distinct Helm expressions (port_http, port_https) that resolve + // to the same value. We compare using HelmValue.ValueString (e.g., "8080") rather than + // ToScalar() (which returns the Helm template expression) to catch these duplicates. + // + // Note: The Docker Compose publisher has the same underlying issue but avoids it + // because it uses HashSet on the raw port string in AddPorts(), which + // naturally deduplicates identical port values. Ideally the deduplication would happen + // upstream in ProcessEndpoints, but for now each publisher handles it independently. var addedPorts = new HashSet<(string Port, string Protocol)>(); foreach (var (_, mapping) in context.EndpointMappings) { @@ -280,6 +287,7 @@ private static ContainerV1 WithContainerPorts(this ContainerV1 container, Kubern return container; } + // Deduplicate container ports for the same reason as ToService() above. var addedPorts = new HashSet<(string Port, string Protocol)>(); foreach (var (_, mapping) in context.EndpointMappings) { From a9bbfb9a763f42bfed8e261189d91914f8376544 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 14:52:24 +1100 Subject: [PATCH 17/21] Refactor cleanup to async pattern matching other deployment tests --- .../AksStarterDeploymentTests.cs | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index e239919162b..8ab3ed07098 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -433,39 +433,46 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation finally { // Clean up the resource group we created (includes AKS cluster and ACR) - output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); - TriggerCleanupResourceGroup(resourceGroupName, output); - DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); } } - /// - /// Triggers cleanup of a specific resource group. - /// This is fire-and-forget - the hourly cleanup workflow handles any missed resources. - /// - private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output) + private async Task CleanupResourceGroupAsync(string resourceGroupName) { - var process = new System.Diagnostics.Process + try { - StartInfo = new System.Diagnostics.ProcessStartInfo + var process = new System.Diagnostics.Process { - FileName = "az", - Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - } - }; + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; - try - { process.Start(); - output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Deletion initiated"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, $"Exit code {process.ExitCode}: {error}"); + } } catch (Exception ex) { - output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, ex.Message); } } } From f3aed68c1d74fbf1f9c516157d8e4e79540f7c40 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 15:16:31 +1100 Subject: [PATCH 18/21] Fix duplicate K8s ports: skip DefaultHttpsEndpoint in ProcessEndpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kubernetes publisher was generating duplicate Service/container ports (both 8080/TCP) for ProjectResources with default http+https endpoints. The root cause is that GenerateDefaultProjectEndpointMapping assigns the same default port 8080 to every endpoint with None target port. The proper fix mirrors the core framework's SetBothPortsEnvVariables() behavior: skip the DefaultHttpsEndpoint (which the container won't listen on — TLS termination happens at ingress/service mesh). The https endpoint still gets an EndpointMapping (for service discovery) but reuses the http endpoint's HelmValue, so no duplicate K8s port is generated. Added Aspire.Hosting.Kubernetes to InternalsVisibleTo to access ProjectResource.DefaultHttpsEndpoint. The downstream dedup in ToService() and WithContainerPorts() remains as defense-in-depth. Fixes https://github.com/dotnet/aspire/issues/14029 --- .../Extensions/ResourceExtensions.cs | 19 +++++++---------- .../KubernetesResource.cs | 21 ++++++++++++++++++- src/Aspire.Hosting/Aspire.Hosting.csproj | 1 + .../ServiceA/deployment.verified.yaml | 3 --- .../templates/ServiceA/service.verified.yaml | 4 ---- .../env1/values.verified.yaml | 1 - .../ServiceB/deployment.verified.yaml | 3 --- .../templates/ServiceB/service.verified.yaml | 4 ---- .../env2/values.verified.yaml | 1 - ...netesWithProjectResources#01.verified.yaml | 1 - ...netesWithProjectResources#02.verified.yaml | 3 --- ...netesWithProjectResources#03.verified.yaml | 4 ---- ...netesWithProjectResources#06.verified.yaml | 4 ++-- 13 files changed, 30 insertions(+), 39 deletions(-) diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs index 34895776bdc..cb3f7ec6c36 100644 --- a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs @@ -140,17 +140,12 @@ internal static StatefulSet ToStatefulSet(this IResource resource, KubernetesRes }, }; - // Deduplicate ports by underlying value and protocol to avoid invalid Service specs. - // When a ProjectResource has both http and https endpoints with no explicit port, - // GenerateDefaultProjectEndpointMapping assigns port 8080 to both, creating separate - // EndpointMappings with distinct Helm expressions (port_http, port_https) that resolve - // to the same value. We compare using HelmValue.ValueString (e.g., "8080") rather than - // ToScalar() (which returns the Helm template expression) to catch these duplicates. - // - // Note: The Docker Compose publisher has the same underlying issue but avoids it - // because it uses HashSet on the raw port string in AddPorts(), which - // naturally deduplicates identical port values. Ideally the deduplication would happen - // upstream in ProcessEndpoints, but for now each publisher handles it independently. + // Defense-in-depth: deduplicate ports by underlying value and protocol. + // The primary fix is in ProcessEndpoints() which skips the DefaultHttpsEndpoint + // (matching the core framework's SetBothPortsEnvVariables behavior). This dedup + // remains as a safety net for edge cases where multiple endpoints might still + // resolve to the same port value. + // See: https://github.com/dotnet/aspire/issues/14029 var addedPorts = new HashSet<(string Port, string Protocol)>(); foreach (var (_, mapping) in context.EndpointMappings) { @@ -287,7 +282,7 @@ private static ContainerV1 WithContainerPorts(this ContainerV1 container, Kubern return container; } - // Deduplicate container ports for the same reason as ToService() above. + // Defense-in-depth: deduplicate container ports (same rationale as ToService() above). var addedPorts = new HashSet<(string Port, string Protocol)>(); foreach (var (_, mapping) in context.EndpointMappings) { diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index b2c7e7d2c16..e0f959a150e 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -171,7 +171,26 @@ private void ProcessEndpoints() if (resolved.TargetPort.Value is null) { - // Default endpoint for ProjectResource - deployment tool assigns port + // Default endpoint for ProjectResource - deployment tool assigns port. + // Skip the default https endpoint — the container won't listen on HTTPS. + // In Kubernetes, TLS termination is handled by ingress or service mesh. + // We still create an EndpointMapping (needed for service discovery env vars) + // but reuse the http endpoint's HelmValue so no duplicate K8s port is generated. + // This matches the core framework's SetBothPortsEnvVariables() behavior, + // which skips DefaultHttpsEndpoint when setting HTTPS_PORTS. + // See: https://github.com/dotnet/aspire/issues/14029 + if (resource is ProjectResource projectResource && + endpoint == projectResource.DefaultHttpsEndpoint) + { + // Find the existing http endpoint's HelmValue to share it + var httpMapping = EndpointMappings.Values.FirstOrDefault(m => m.Scheme == "http"); + if (httpMapping is not null) + { + EndpointMappings[endpoint.Name] = new(endpoint.UriScheme, GetKubernetesProtocolName(endpoint.Protocol), resource.Name.ToServiceName(), httpMapping.Port, endpoint.Name); + continue; + } + } + GenerateDefaultProjectEndpointMapping(endpoint); continue; } diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 85c736e02de..d112cec4833 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -118,6 +118,7 @@ + diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/deployment.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/deployment.verified.yaml index 60785ea08d1..11ce95488f1 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/deployment.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/deployment.verified.yaml @@ -25,9 +25,6 @@ spec: - name: "http" protocol: "TCP" containerPort: {{ .Values.parameters.ServiceA.port_http | int }} - - name: "https" - protocol: "TCP" - containerPort: {{ .Values.parameters.ServiceA.port_https | int }} imagePullPolicy: "IfNotPresent" selector: matchLabels: diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/service.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/service.verified.yaml index 65a8c769cf8..d636769d76b 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/service.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/service.verified.yaml @@ -18,7 +18,3 @@ spec: protocol: "TCP" port: {{ .Values.parameters.ServiceA.port_http | int }} targetPort: {{ .Values.parameters.ServiceA.port_http | int }} - - name: "https" - protocol: "TCP" - port: {{ .Values.parameters.ServiceA.port_https | int }} - targetPort: {{ .Values.parameters.ServiceA.port_https | int }} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/values.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/values.verified.yaml index 3d4081e7f04..b5c5767ba91 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/values.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/values.verified.yaml @@ -1,7 +1,6 @@ parameters: ServiceA: port_http: 8080 - port_https: 8080 ServiceA_image: "ServiceA:latest" secrets: {} config: diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/deployment.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/deployment.verified.yaml index 74f6939fea0..9cdc1d4bebd 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/deployment.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/deployment.verified.yaml @@ -25,9 +25,6 @@ spec: - name: "http" protocol: "TCP" containerPort: {{ .Values.parameters.ServiceB.port_http | int }} - - name: "https" - protocol: "TCP" - containerPort: {{ .Values.parameters.ServiceB.port_https | int }} imagePullPolicy: "IfNotPresent" selector: matchLabels: diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/service.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/service.verified.yaml index ee61637e165..6276ba51bf2 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/service.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/service.verified.yaml @@ -18,7 +18,3 @@ spec: protocol: "TCP" port: {{ .Values.parameters.ServiceB.port_http | int }} targetPort: {{ .Values.parameters.ServiceB.port_http | int }} - - name: "https" - protocol: "TCP" - port: {{ .Values.parameters.ServiceB.port_https | int }} - targetPort: {{ .Values.parameters.ServiceB.port_https | int }} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/values.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/values.verified.yaml index 22a6a3e458b..c3b216b559a 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/values.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/values.verified.yaml @@ -1,7 +1,6 @@ parameters: ServiceB: port_http: 8080 - port_https: 8080 ServiceB_image: "ServiceB:latest" secrets: {} config: diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml index 68821ce999a..6875575cfda 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml @@ -1,7 +1,6 @@ parameters: project1: port_http: 8080 - port_https: 8080 project1_image: "project1:latest" secrets: {} config: diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#02.verified.yaml index cce4d052cb0..fc971617486 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#02.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#02.verified.yaml @@ -25,9 +25,6 @@ spec: - name: "http" protocol: "TCP" containerPort: {{ .Values.parameters.project1.port_http | int }} - - name: "https" - protocol: "TCP" - containerPort: {{ .Values.parameters.project1.port_https | int }} - name: "custom1" protocol: "TCP" containerPort: 8000 diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#03.verified.yaml index 5af5e5fc7c7..c8d84f6ffe5 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#03.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#03.verified.yaml @@ -18,10 +18,6 @@ spec: protocol: "TCP" port: {{ .Values.parameters.project1.port_http | int }} targetPort: {{ .Values.parameters.project1.port_http | int }} - - name: "https" - protocol: "TCP" - port: {{ .Values.parameters.project1.port_https | int }} - targetPort: {{ .Values.parameters.project1.port_https | int }} - name: "custom1" protocol: "TCP" port: 8000 diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml index 21e3c097b67..421858fefae 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml @@ -10,8 +10,8 @@ metadata: data: PROJECT1_HTTP: "http://project1-service:{{ .Values.parameters.project1.port_http }}" services__project1__http__0: "http://project1-service:{{ .Values.parameters.project1.port_http }}" - PROJECT1_HTTPS: "https://project1-service:{{ .Values.parameters.project1.port_https }}" - services__project1__https__0: "https://project1-service:{{ .Values.parameters.project1.port_https }}" + PROJECT1_HTTPS: "https://project1-service:{{ .Values.parameters.project1.port_http }}" + services__project1__https__0: "https://project1-service:{{ .Values.parameters.project1.port_http }}" PROJECT1_CUSTOM1: "{{ .Values.config.api.PROJECT1_CUSTOM1 }}" services__project1__custom1__0: "{{ .Values.config.api.services__project1__custom1__0 }}" PROJECT1_CUSTOM2: "{{ .Values.config.api.PROJECT1_CUSTOM2 }}" From 5fa81a7b07f2a5deada96ea80e66e0b3bf8e697d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 15:57:13 +1100 Subject: [PATCH 19/21] Add AKS + Redis E2E deployment test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates the Aspire starter template with Redis cache enabled deploys correctly to AKS. Exercises the full pipeline: webfrontend → apiservice → Redis by hitting the /weather page (SSR, uses Redis output caching). Key differences from the base AKS test: - Selects 'Yes' for Redis Cache in aspire new prompts - Redis uses public container image (no ACR push needed) - Verifies /weather page content (confirms Redis integration works) --- .../AksStarterWithRedisDeploymentTests.cs | 496 ++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs new file mode 100644 index 00000000000..5238ab713a1 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs @@ -0,0 +1,496 @@ +// 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.Tests.Utils; +using Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Aspire starter template with Redis to AKS. +/// This validates that the starter template with Redis cache works out-of-the-box on Kubernetes. +/// +public sealed class AksStarterWithRedisDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 45 minutes to allow for AKS provisioning (~10-15 min) plus deployment. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(45); + + [Fact] + public async Task DeployStarterTemplateWithRedisToAks() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployStarterTemplateWithRedisToAksCore(cancellationToken); + } + + private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithRedisToAks)); + var startTime = DateTime.UtcNow; + + // Generate unique names for Azure resources + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aksredis"); + var clusterName = $"aks-{DeploymentE2ETestHelpers.GetRunId()}-{DeploymentE2ETestHelpers.GetRunAttempt()}"; + // ACR names must be alphanumeric only, 5-50 chars, globally unique + var acrName = $"acr{DeploymentE2ETestHelpers.GetRunId()}{DeploymentE2ETestHelpers.GetRunAttempt()}".ToLowerInvariant(); + acrName = new string(acrName.Where(char.IsLetterOrDigit).Take(50).ToArray()); + if (acrName.Length < 5) + { + acrName = $"acrtest{Guid.NewGuid():N}"[..24]; + } + + output.WriteLine($"Test: {nameof(DeployStarterTemplateWithRedisToAks)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"AKS Cluster: {clusterName}"); + output.WriteLine($"ACR Name: {acrName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Pattern searchers for aspire new interactive prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find("Do you want to create a test project?"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + var projectName = "AksRedis"; + + // ===== PHASE 1: Provision AKS Infrastructure ===== + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Register required resource providers + output.WriteLine("Step 2: Registering required resource providers..."); + sequenceBuilder + .Type("az provider register --namespace Microsoft.ContainerService --wait && " + + "az provider register --namespace Microsoft.ContainerRegistry --wait") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 3: Create resource group + output.WriteLine("Step 3: Creating resource group..."); + sequenceBuilder + .Type($"az group create --name {resourceGroupName} --location westus3 --output table") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 4: Create Azure Container Registry + output.WriteLine("Step 4: Creating Azure Container Registry..."); + sequenceBuilder + .Type($"az acr create --resource-group {resourceGroupName} --name {acrName} --sku Basic --output table") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 5: Create AKS cluster with ACR attached + output.WriteLine("Step 5: Creating AKS cluster (this may take 10-15 minutes)..."); + sequenceBuilder + .Type($"az aks create " + + $"--resource-group {resourceGroupName} " + + $"--name {clusterName} " + + $"--node-count 1 " + + $"--node-vm-size Standard_D2s_v3 " + + $"--generate-ssh-keys " + + $"--attach-acr {acrName} " + + $"--enable-managed-identity " + + $"--output table") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(20)); + + // Step 6: Ensure AKS can pull from ACR + output.WriteLine("Step 6: Verifying AKS-ACR integration..."); + sequenceBuilder + .Type($"az aks update --resource-group {resourceGroupName} --name {clusterName} --attach-acr {acrName}") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 7: Configure kubectl credentials + output.WriteLine("Step 7: Configuring kubectl credentials..."); + sequenceBuilder + .Type($"az aks get-credentials --resource-group {resourceGroupName} --name {clusterName} --overwrite-existing") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 8: Verify kubectl connectivity + output.WriteLine("Step 8: Verifying kubectl connectivity..."); + sequenceBuilder + .Type("kubectl get nodes") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Verify cluster health + output.WriteLine("Step 9: Verifying cluster health..."); + sequenceBuilder + .Type("kubectl cluster-info") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // ===== PHASE 2: Create Aspire Project with Redis and Generate Helm Charts ===== + + // Step 10: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 10: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 11: Create starter project with Redis enabled + output.WriteLine("Step 11: Creating Aspire starter project with Redis..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first template (Starter App ASP.NET Core/Blazor) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(projectName) + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default output path + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for localhost URLs (default) + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "Yes" for Redis Cache (first/default option) + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for test project (default) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 12: Navigate to project directory + output.WriteLine("Step 12: Navigating to project directory..."); + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 13: Add Aspire.Hosting.Kubernetes package + output.WriteLine("Step 13: Adding Kubernetes hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Kubernetes") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 14: Modify AppHost.cs to add Kubernetes environment + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + + output.WriteLine($"Modifying AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + // Insert the Kubernetes environment before builder.Build().Run(); + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Kubernetes environment for deployment +builder.AddKubernetesEnvironment("k8s"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + + // Add required pragma to suppress experimental warning + if (!content.Contains("#pragma warning disable ASPIREPIPELINES001")) + { + content = "#pragma warning disable ASPIREPIPELINES001\n" + content; + } + + File.WriteAllText(appHostFilePath, content); + + output.WriteLine("Modified AppHost.cs with AddKubernetesEnvironment"); + }); + + // Step 15: Navigate to AppHost project directory + output.WriteLine("Step 15: Navigating to AppHost directory..."); + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 16: Login to ACR for Docker push + output.WriteLine("Step 16: Logging into Azure Container Registry..."); + sequenceBuilder + .Type($"az acr login --name {acrName}") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 17: Build and push container images to ACR + // Only project resources need to be built — Redis uses a public container image + output.WriteLine("Step 17: Building and pushing container images to ACR..."); + sequenceBuilder + .Type($"cd .. && " + + $"dotnet publish {projectName}.Web/{projectName}.Web.csproj " + + $"/t:PublishContainer " + + $"/p:ContainerRegistry={acrName}.azurecr.io " + + $"/p:ContainerImageName=webfrontend " + + $"/p:ContainerImageTag=latest") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + sequenceBuilder + .Type($"dotnet publish {projectName}.ApiService/{projectName}.ApiService.csproj " + + $"/t:PublishContainer " + + $"/p:ContainerRegistry={acrName}.azurecr.io " + + $"/p:ContainerImageName=apiservice " + + $"/p:ContainerImageTag=latest") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Navigate back to AppHost directory + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 18: Run aspire publish to generate Helm charts + output.WriteLine("Step 18: Running aspire publish to generate Helm charts..."); + sequenceBuilder + .Type($"aspire publish --output-path ../charts") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(10)); + + // Step 19: Verify Helm chart was generated + output.WriteLine("Step 19: Verifying Helm chart generation..."); + sequenceBuilder + .Type("ls -la ../charts && cat ../charts/Chart.yaml && cat ../charts/values.yaml") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // ===== PHASE 3: Deploy to AKS and Verify ===== + + // Step 20: Verify ACR role assignment has propagated + output.WriteLine("Step 20: Verifying AKS can pull from ACR..."); + sequenceBuilder + .Type($"az aks check-acr --resource-group {resourceGroupName} --name {clusterName} --acr {acrName}.azurecr.io") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 21: Deploy Helm chart to AKS with ACR image overrides + // Only project resources need image overrides — Redis uses the public image from the chart + output.WriteLine("Step 21: Deploying Helm chart to AKS..."); + sequenceBuilder + .Type($"helm install aksredis ../charts --namespace default --wait --timeout 10m " + + $"--set parameters.webfrontend.webfrontend_image={acrName}.azurecr.io/webfrontend:latest " + + $"--set parameters.apiservice.apiservice_image={acrName}.azurecr.io/apiservice:latest") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12)); + + // Step 22: Wait for all pods to be ready (including Redis) + output.WriteLine("Step 22: Waiting for pods to be ready..."); + sequenceBuilder + .Type("kubectl wait --for=condition=ready pod --all -n default --timeout=120s") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 23: Verify all pods are running + output.WriteLine("Step 23: Verifying pods are running..."); + sequenceBuilder + .Type("kubectl get pods -n default") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 24: Verify deployments are healthy + output.WriteLine("Step 24: Verifying deployments..."); + sequenceBuilder + .Type("kubectl get deployments -n default") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 25: Verify services (should include cache-service for Redis) + output.WriteLine("Step 25: Verifying services..."); + sequenceBuilder + .Type("kubectl get services -n default") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 26: Verify apiservice endpoint via port-forward + output.WriteLine("Step 26: Verifying apiservice /weatherforecast endpoint..."); + sequenceBuilder + .Type("kubectl port-forward svc/apiservice-service 18080:8080 &") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)) + .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18080/weatherforecast -o /dev/null -w '%{http_code}' && echo ' OK' && break; done") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 27: Verify webfrontend root page via port-forward + output.WriteLine("Step 27: Verifying webfrontend root page..."); + sequenceBuilder + .Type("kubectl port-forward svc/webfrontend-service 18081:8080 &") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)) + .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/ -o /dev/null -w '%{http_code}' && echo ' OK' && break; done") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 28: Verify webfrontend /weather page (exercises webfrontend → apiservice → Redis pipeline) + // The /weather page is server-side rendered and fetches data from the apiservice. + // Redis output caching is used, so this validates the full Redis integration. + output.WriteLine("Step 28: Verifying webfrontend /weather page (exercises Redis cache)..."); + sequenceBuilder + .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/weather -o /dev/null -w '%{http_code}' && echo ' OK' && break; done") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 29: Verify /weather page actually returns weather data + output.WriteLine("Step 29: Verifying weather page content..."); + sequenceBuilder + .Type("curl -sf http://localhost:18081/weather | grep -q 'Weather' && echo 'Weather page content verified'") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 30: Clean up port-forwards + output.WriteLine("Step 30: Cleaning up port-forwards..."); + sequenceBuilder + .Type("kill %1 %2 2>/dev/null; true") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)); + + // Step 31: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Full AKS + Redis deployment completed in {duration}"); + + // Report success + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployStarterTemplateWithRedisToAks), + resourceGroupName, + new Dictionary + { + ["cluster"] = clusterName, + ["acr"] = acrName, + ["project"] = projectName + }, + duration); + + output.WriteLine("✅ Test passed - Aspire app with Redis deployed to AKS via Helm!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployStarterTemplateWithRedisToAks), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Deletion initiated"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, $"Exit code {process.ExitCode}: {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, ex.Message); + } + } +} From 7d3be48a9ddc50d4e301064152a8976cdf2dcf73 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 16:19:07 +1100 Subject: [PATCH 20/21] Fix ACR name collision between parallel AKS tests Both AKS tests generated the same ACR name from RunId+RunAttempt. Use different prefixes (acrs/acrr) to ensure uniqueness. --- .../AksStarterDeploymentTests.cs | 2 +- .../AksStarterWithRedisDeploymentTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index 8ab3ed07098..3b8b1a58bed 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -57,7 +57,7 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aks"); var clusterName = $"aks-{DeploymentE2ETestHelpers.GetRunId()}-{DeploymentE2ETestHelpers.GetRunAttempt()}"; // ACR names must be alphanumeric only, 5-50 chars, globally unique - var acrName = $"acr{DeploymentE2ETestHelpers.GetRunId()}{DeploymentE2ETestHelpers.GetRunAttempt()}".ToLowerInvariant(); + var acrName = $"acrs{DeploymentE2ETestHelpers.GetRunId()}{DeploymentE2ETestHelpers.GetRunAttempt()}".ToLowerInvariant(); // Ensure ACR name is valid (alphanumeric, 5-50 chars) acrName = new string(acrName.Where(char.IsLetterOrDigit).Take(50).ToArray()); if (acrName.Length < 5) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs index 5238ab713a1..e197579354d 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs @@ -58,7 +58,7 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aksredis"); var clusterName = $"aks-{DeploymentE2ETestHelpers.GetRunId()}-{DeploymentE2ETestHelpers.GetRunAttempt()}"; // ACR names must be alphanumeric only, 5-50 chars, globally unique - var acrName = $"acr{DeploymentE2ETestHelpers.GetRunId()}{DeploymentE2ETestHelpers.GetRunAttempt()}".ToLowerInvariant(); + var acrName = $"acrr{DeploymentE2ETestHelpers.GetRunId()}{DeploymentE2ETestHelpers.GetRunAttempt()}".ToLowerInvariant(); acrName = new string(acrName.Where(char.IsLetterOrDigit).Take(50).ToArray()); if (acrName.Length < 5) { From dd62bbf947cf7ce20c58d44b0f43aec1daa91559 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 16:37:10 +1100 Subject: [PATCH 21/21] Fix Redis Helm deployment: provide missing cross-resource secret value Work around K8s publisher bug where cross-resource secret references create Helm value paths under the consuming resource instead of referencing the owning resource's secret. The webfrontend template expects secrets.webfrontend.cache_password but values.yaml only has secrets.cache.REDIS_PASSWORD. Provide the missing value via --set. --- .../AksStarterWithRedisDeploymentTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs index e197579354d..2da92fa996a 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs @@ -331,11 +331,15 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can // Step 21: Deploy Helm chart to AKS with ACR image overrides // Only project resources need image overrides — Redis uses the public image from the chart + // Note: secrets.webfrontend.cache_password is a workaround for a K8s publisher bug where + // cross-resource secret references create Helm value paths under the consuming resource + // instead of referencing the owning resource's secret path (secrets.cache.REDIS_PASSWORD). output.WriteLine("Step 21: Deploying Helm chart to AKS..."); sequenceBuilder .Type($"helm install aksredis ../charts --namespace default --wait --timeout 10m " + $"--set parameters.webfrontend.webfrontend_image={acrName}.azurecr.io/webfrontend:latest " + - $"--set parameters.apiservice.apiservice_image={acrName}.azurecr.io/apiservice:latest") + $"--set parameters.apiservice.apiservice_image={acrName}.azurecr.io/apiservice:latest " + + $"--set secrets.webfrontend.cache_password=\"\"") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12));