| File: AksBlazorRedisDeploymentTests.cs | Web Access |
| Project: src\tests\Aspire.Deployment.EndToEnd.Tests\Aspire.Deployment.EndToEnd.Tests.csproj (Aspire.Deployment.EndToEnd.Tests) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Deployment.EndToEnd.Tests.Helpers; using Hex1b.Automation; using Xunit; namespace Aspire.Deployment.EndToEnd.Tests; /// <summary> /// End-to-end tests for deploying a Blazor+Redis Aspire application to AKS using the /// interactive <c>aspire deploy</c> pipeline flow with <c>AddAzureKubernetesEnvironment</c>. /// AKS cluster, ACR, and resource group are all provisioned automatically by the pipeline. /// </summary> public sealed class AksBlazorRedisDeploymentTests(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 DeployBlazorWithRedisToAksInteractive() { using var cts = new CancellationTokenSource(s_testTimeout); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( cts.Token, TestContext.Current.CancellationToken); var cancellationToken = linkedCts.Token; await DeployBlazorWithRedisToAksInteractiveCore(cancellationToken); } private async Task DeployBlazorWithRedisToAksInteractiveCore(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 startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary<string, string>(); var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aksblazor"); var projectName = "AksBlazorRedis"; output.WriteLine($"Test: {nameof(DeployBlazorWithRedisToAksInteractive)}"); output.WriteLine($"Project Name: {projectName}"); output.WriteLine($"Resource Group: {resourceGroupName}"); output.WriteLine($"Subscription: {subscriptionId[..8]}..."); output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); try { using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); // Step 1: Prepare environment output.WriteLine("Step 1: Preparing environment..."); await auto.PrepareEnvironmentAsync(workspace, counter); // Step 2: Set up CLI environment await auto.InstallCurrentBuildAspireCliAsync(counter, output); // Step 3: Create Blazor starter project with Redis enabled (interactive prompts) output.WriteLine("Step 3: Creating Blazor starter project with Redis..."); await auto.AspireNewAsync(projectName, counter, useRedisCache: true); // Step 4: Navigate to project directory output.WriteLine("Step 4: Navigating to project directory..."); await auto.TypeAsync($"cd {projectName}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); // Step 5: Add Aspire.Hosting.Azure.Kubernetes package output.WriteLine("Step 5: Adding Azure Kubernetes hosting package..."); await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Kubernetes"); await auto.EnterAsync(); // aspire add may show a version selection prompt await auto.WaitForAspireAddCompletionAsync(counter); // Step 6: Modify AppHost.cs to add Azure Kubernetes Environment var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost"); var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}"); var content = File.ReadAllText(appHostFilePath); // Insert the Azure Kubernetes Environment before builder.Build().Run(); // Use DASv5 VM SKUs for both system and user pools. var buildRunPattern = "builder.Build().Run();"; var replacement = """ // Add Azure Kubernetes Environment for deployment with DASv5 SKUs var aks = builder.AddAzureKubernetesEnvironment("aks") .WithSystemNodePool("Standard_D2as_v5"); aks.AddNodePool("workload", "Standard_D2as_v5", 1, 3); builder.Build().Run(); """; content = content.Replace(buildRunPattern, replacement); File.WriteAllText(appHostFilePath, content); output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}"); // Step 7: Navigate to AppHost project directory output.WriteLine("Step 7: Navigating to AppHost directory..."); await auto.TypeAsync($"cd {projectName}.AppHost"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); // Step 8: Set environment variables for deployment // - Unset ASPIRE_PLAYGROUND to avoid conflicts // - Unset the job-level Azure__Location=westus3 the CI workflow injects: on Linux it coexists // with AZURE__LOCATION (case-sensitive env) and .NET config may bind the inherited westus3 instead // - Set Azure location to West US 3 (DASv5 SKU availability) // - Set AZURE__RESOURCEGROUP to use our unique resource group name await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && unset Azure__Location && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); // Step 9: Deploy to AKS using aspire deploy // Use --clear-cache to ensure fresh deployment without cached location from previous runs output.WriteLine("Step 9: Starting AKS deployment..."); await auto.TypeAsync("aspire deploy --clear-cache"); await auto.EnterAsync(); // Wait for pipeline to complete successfully await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(30)); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); // Step 10: Get AKS credentials for the provisioned cluster output.WriteLine("Step 10: Getting AKS credentials..."); await auto.TypeAsync($"AKS_NAME=$(az aks list -g {resourceGroupName} --query '[0].name' -o tsv) && " + $"az aks get-credentials -g {resourceGroupName} -n $AKS_NAME --overwrite-existing"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60)); // Step 11: Wait for all pods to be ready output.WriteLine("Step 11: Waiting for pods to be ready..."); await auto.TypeAsync("kubectl wait --for=condition=ready pod --all --all-namespaces --timeout=300s 2>/dev/null || true"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(6)); // Show pod and service status for debugging await auto.TypeAsync("kubectl get pods --all-namespaces && kubectl get svc --all-namespaces"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); // Step 12: Discover the namespace where webfrontend is deployed output.WriteLine("Step 12: Discovering deployment namespace..."); await auto.TypeAsync("NS=$(kubectl get svc --all-namespaces -o jsonpath='{range .items[?(@.metadata.name==\"webfrontend-service\")]}{.metadata.namespace}{end}') && echo \"Namespace: $NS\""); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); // Step 13: Verify webfrontend via port-forward // Redirect port-forward output to /dev/null to prevent "Handling connection for..." // and "Forwarding from..." messages from interfering with prompt detection. output.WriteLine("Step 13: Verifying webfrontend /weather endpoint..."); await auto.TypeAsync("kubectl port-forward svc/webfrontend-service 18081:8080 -n $NS > /dev/null 2>&1 &"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10)); // Wait for port-forward to establish await auto.TypeAsync("sleep 3"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10)); // Verify root page — fail explicitly if all retries exhausted await auto.TypeAsync("OK=0; for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/ -o /dev/null -w '%{http_code}' && echo ' OK' && OK=1 && break; done; [ \"$OK\" = \"1\" ] || { echo 'FAIL: root page unreachable after 10 retries'; exit 1; }"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60)); // Verify /weather page (exercises webfrontend -> apiservice -> Redis pipeline) // The /weather page uses Blazor SSR streaming rendering which keeps the HTTP connection open. // We use -m 5 (max-time) to avoid curl hanging, and capture the status code in a variable // because --max-time causes curl to exit non-zero (code 28) even on HTTP 200. await auto.TypeAsync("OK=0; for i in $(seq 1 10); do sleep 3; S=$(curl -so /dev/null -w '%{http_code}' -m 5 http://localhost:18081/weather); [ \"$S\" = \"200\" ] && echo \"$S OK\" && OK=1 && break; done; [ \"$OK\" = \"1\" ] || { echo 'FAIL: /weather unreachable after 10 retries'; exit 1; }"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120)); // Clean up port-forward await auto.TypeAsync("kill %1 2>/dev/null; true"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10)); // Step 14: Clean up using aspire destroy output.WriteLine("Step 14: Destroying deployment..."); await auto.AspireDestroyAsync(counter); // Step 15: Exit terminal await auto.TypeAsync("exit"); await auto.EnterAsync(); await pendingRun; var duration = DateTime.UtcNow - startTime; output.WriteLine($"Deployment completed in {duration}"); // Report success DeploymentReporter.ReportDeploymentSuccess( nameof(DeployBlazorWithRedisToAksInteractive), resourceGroupName, deploymentUrls, duration); output.WriteLine("✅ Test passed!"); } catch (Exception ex) { var duration = DateTime.UtcNow - startTime; output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); DeploymentReporter.ReportDeploymentFailure( nameof(DeployBlazorWithRedisToAksInteractive), resourceGroupName, ex.Message, ex.StackTrace); throw; } finally { // Clean up the resource group created by the pipeline output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); TriggerCleanupResourceGroup(resourceGroupName); } } /// <summary> /// Triggers cleanup of a specific resource group. /// This is fire-and-forget — the hourly cleanup workflow handles any missed resources. /// </summary> private void TriggerCleanupResourceGroup(string resourceGroupName) { using 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}"); DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); } catch (Exception ex) { output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, ex.Message); } } }