diff --git a/.github/workflows/deployment-cleanup.yml b/.github/workflows/deployment-cleanup.yml index edd0ae60274..08dc4f72429 100644 --- a/.github/workflows/deployment-cleanup.yml +++ b/.github/workflows/deployment-cleanup.yml @@ -1,9 +1,13 @@ # Cleanup workflow for deployment test resources # # Uses a mark-and-sweep approach: -# 1. Mark: Tag untagged rg-aspire-* RGs with 'deployment-test-first-seen' timestamp +# 1. Mark: Tag untagged deployment test RGs with 'deployment-test-first-seen' timestamp # 2. Sweep: Delete RGs where 'deployment-test-first-seen' is older than threshold # +# Targets resource groups with prefixes: +# - rg-aspire-* (legacy naming) +# - e2e-* (current naming) +# # This ensures RGs are only deleted after they've been seen for the full retention period. # name: Deployment Environment Cleanup @@ -82,11 +86,13 @@ jobs: NOW_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ) MAX_AGE_SECONDS=$((MAX_AGE_HOURS * 3600)) - # List all resource groups matching rg-aspire-* prefix - RG_LIST=$(az group list --query "[?starts_with(name, 'rg-aspire-')].name" -o tsv || echo "") + # List all resource groups matching deployment test prefixes + # Current naming: e2e-* (e.g., e2e-starter-12345678-1) + # Legacy naming: rg-aspire-* (may still exist from older tests) + RG_LIST=$(az group list --query "[?starts_with(name, 'e2e-') || starts_with(name, 'rg-aspire-')].name" -o tsv || echo "") if [ -z "$RG_LIST" ]; then - echo "No resource groups found matching 'rg-aspire-*' prefix." + echo "No resource groups found matching deployment test prefixes (e2e-* or rg-aspire-*)." echo "✅ No resource groups found." >> $GITHUB_STEP_SUMMARY echo "marked_count=0" >> $GITHUB_OUTPUT echo "deleted_count=0" >> $GITHUB_OUTPUT diff --git a/.github/workflows/deployment-test-command.yml b/.github/workflows/deployment-test-command.yml index 2e860d6a7a0..3f5f13be858 100644 --- a/.github/workflows/deployment-test-command.yml +++ b/.github/workflows/deployment-test-command.yml @@ -80,20 +80,6 @@ jobs: core.setOutput('head_sha', pr.head.sha); core.setOutput('head_ref', pr.head.ref); - - name: Acknowledge command - if: steps.check_membership.outputs.is_member == 'true' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - const prNumber = ${{ steps.pr.outputs.number }}; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `🚀 Starting deployment tests on PR #${prNumber}...\n\nThis will deploy to real Azure infrastructure. Results will be posted here when complete.\n\n[View workflow run](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/deployment-tests.yml)` - }); - - name: Trigger deployment tests if: steps.check_membership.outputs.is_member == 'true' uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 @@ -102,6 +88,7 @@ jobs: // Dispatch from the PR's head ref to test the PR's code changes. // Security: Org membership check is the security boundary - only trusted // dotnet org members can trigger this workflow. + // Note: The triggered workflow posts its own "starting" comment with the run URL. await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/deployment-tests.yml b/.github/workflows/deployment-tests.yml index e780e567389..ade7167cf17 100644 --- a/.github/workflows/deployment-tests.yml +++ b/.github/workflows/deployment-tests.yml @@ -31,6 +31,28 @@ concurrency: cancel-in-progress: true jobs: + # Post "starting" comment to PR when triggered via /deployment-test command + notify-start: + name: Notify PR + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'dotnet' && inputs.pr_number != '' }} + permissions: + pull-requests: write + steps: + - name: Post starting comment + env: + GH_TOKEN: ${{ github.token }} + run: | + PR_NUMBER="${{ inputs.pr_number }}" + RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + gh pr comment "${PR_NUMBER}" --repo "${{ github.repository }}" --body \ + "🚀 **Deployment tests starting** on PR #${PR_NUMBER}... + + This will deploy to real Azure infrastructure. Results will be posted here when complete. + + [View workflow run](${RUN_URL})" + # Enumerate test classes to build the matrix enumerate: name: Enumerate Tests @@ -214,7 +236,7 @@ jobs: AZURE_TENANT_ID: ${{ secrets.AZURE_DEPLOYMENT_TEST_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_DEPLOYMENT_TEST_CLIENT_ID }} Azure__SubscriptionId: ${{ secrets.AZURE_DEPLOYMENT_TEST_SUBSCRIPTION_ID }} - Azure__Location: eastus + Azure__Location: westus3 GH_TOKEN: ${{ github.token }} run: | ./dotnet.sh test tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj \ @@ -332,13 +354,58 @@ jobs: pull-requests: write actions: read steps: - - name: Download recording artifacts + - name: Get job results and download recording artifacts + id: get_results uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | const fs = require('fs'); const path = require('path'); + // Get all jobs for this workflow run to determine per-test results + const jobs = await github.paginate( + github.rest.actions.listJobsForWorkflowRun, + { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId, + per_page: 100 + } + ); + + console.log(`Total jobs found: ${jobs.length}`); + + // Filter for deploy-test matrix jobs (format: "Deploy (TestClassName)") + const deployJobs = jobs.filter(job => job.name.startsWith('Deploy (')); + + const passedTests = []; + const failedTests = []; + const cancelledTests = []; + + for (const job of deployJobs) { + // Extract test name from job name "Deploy (TestClassName)" + const match = job.name.match(/^Deploy \((.+)\)$/); + const testName = match ? match[1] : job.name; + + console.log(`Job "${job.name}" - conclusion: ${job.conclusion}, status: ${job.status}`); + + if (job.conclusion === 'success') { + passedTests.push(testName); + } else if (job.conclusion === 'failure') { + failedTests.push(testName); + } else if (job.conclusion === 'cancelled') { + cancelledTests.push(testName); + } + } + + console.log(`Passed: ${passedTests.length}, Failed: ${failedTests.length}, Cancelled: ${cancelledTests.length}`); + + // Output results for later steps + core.setOutput('passed_tests', JSON.stringify(passedTests)); + core.setOutput('failed_tests', JSON.stringify(failedTests)); + core.setOutput('cancelled_tests', JSON.stringify(cancelledTests)); + core.setOutput('total_tests', passedTests.length + failedTests.length + cancelledTests.length); + // List all artifacts for the current workflow run const allArtifacts = await github.paginate( github.rest.actions.listWorkflowRunArtifacts, @@ -402,6 +469,10 @@ jobs: - name: Upload recordings to asciinema and post comment env: GH_TOKEN: ${{ github.token }} + PASSED_TESTS: ${{ steps.get_results.outputs.passed_tests }} + FAILED_TESTS: ${{ steps.get_results.outputs.failed_tests }} + CANCELLED_TESTS: ${{ steps.get_results.outputs.cancelled_tests }} + TOTAL_TESTS: ${{ steps.get_results.outputs.total_tests }} shell: bash run: | PR_NUMBER="${{ inputs.pr_number }}" @@ -409,43 +480,61 @@ jobs: RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${RUN_ID}" TEST_RESULT="${{ needs.deploy-test.result }}" - # Determine status emoji and message - case "$TEST_RESULT" in - success) - EMOJI="✅" - STATUS="passed" - DETAILS="All deployment tests completed successfully." - ;; - failure) - EMOJI="❌" - STATUS="failed" - DETAILS="One or more deployment tests failed. Check the workflow run for details." - ;; - cancelled) - EMOJI="⚠️" - STATUS="cancelled" - DETAILS="The deployment tests were cancelled." - ;; - skipped) - EMOJI="⏭️" - STATUS="skipped" - DETAILS="The deployment tests were skipped (no tests to run or prerequisites not met)." - ;; - *) - EMOJI="❓" - STATUS="${TEST_RESULT:-unknown}" - DETAILS="The deployment test result could not be determined." - ;; - esac + # Parse the test results from JSON + PASSED_COUNT=$(echo "$PASSED_TESTS" | jq 'length') + FAILED_COUNT=$(echo "$FAILED_TESTS" | jq 'length') + CANCELLED_COUNT=$(echo "$CANCELLED_TESTS" | jq 'length') - # Build the comment body + # Determine overall status + if [ "$FAILED_COUNT" -gt 0 ]; then + EMOJI="❌" + STATUS="failed" + elif [ "$CANCELLED_COUNT" -gt 0 ] && [ "$PASSED_COUNT" -eq 0 ]; then + EMOJI="⚠️" + STATUS="cancelled" + elif [ "$PASSED_COUNT" -gt 0 ]; then + EMOJI="✅" + STATUS="passed" + else + EMOJI="❓" + STATUS="unknown" + fi + + # Build the comment header COMMENT_BODY="${EMOJI} **Deployment E2E Tests ${STATUS}** - - ${DETAILS} - + + **Summary:** ${PASSED_COUNT} passed, ${FAILED_COUNT} failed, ${CANCELLED_COUNT} cancelled + [View workflow run](${RUN_URL})" - # Check for recordings and upload them + # Add passed tests section if any + if [ "$PASSED_COUNT" -gt 0 ]; then + PASSED_LIST=$(echo "$PASSED_TESTS" | jq -r '.[]' | while read test; do echo "- ✅ ${test}"; done) + COMMENT_BODY="${COMMENT_BODY} + + ### Passed Tests + ${PASSED_LIST}" + fi + + # Add failed tests section if any + if [ "$FAILED_COUNT" -gt 0 ]; then + FAILED_LIST=$(echo "$FAILED_TESTS" | jq -r '.[]' | while read test; do echo "- ❌ ${test}"; done) + COMMENT_BODY="${COMMENT_BODY} + + ### Failed Tests + ${FAILED_LIST}" + fi + + # Add cancelled tests section if any + if [ "$CANCELLED_COUNT" -gt 0 ]; then + CANCELLED_LIST=$(echo "$CANCELLED_TESTS" | jq -r '.[]' | while read test; do echo "- ⚠️ ${test}"; done) + COMMENT_BODY="${COMMENT_BODY} + + ### Cancelled Tests + ${CANCELLED_LIST}" + fi + + # Check for recordings and upload them (only for failed tests) RECORDINGS_DIR="cast_files" if [ -d "$RECORDINGS_DIR" ] && compgen -G "$RECORDINGS_DIR"/*.cast > /dev/null; then @@ -453,9 +542,9 @@ jobs: pip install --quiet asciinema RECORDING_TABLE=" - + ### 🎬 Terminal Recordings - + | Test | Recording | |------|-----------|" diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs index a9fecb3c94f..6f7488c993f 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs @@ -14,9 +14,9 @@ namespace Aspire.Deployment.EndToEnd.Tests; /// public sealed class AcaStarterDeploymentTests(ITestOutputHelper output) { - // Timeout set to 15 minutes to allow for Azure provisioning. - // Full deployments can take 10-20+ minutes. Increase if needed. - private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(15); + // Timeout set to 40 minutes to allow for Azure provisioning. + // Full deployments can take up to 30 minutes if Azure infrastructure is backed up. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); [Fact] public async Task DeployStarterTemplateToAzureContainerApps() @@ -211,10 +211,11 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok .Type("aspire deploy --clear-cache") .Enter() // Wait for pipeline to complete successfully - .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(10)) + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); - // Step 10: Extract deployment URLs and verify endpoints + // Step 10: Extract deployment URLs and verify endpoints with retry + // Retry each endpoint for up to 3 minutes (18 attempts * 10 seconds) output.WriteLine("Step 8: Verifying deployed endpoints..."); sequenceBuilder .Type($"RG_NAME=\"{resourceGroupName}\" && " + @@ -225,13 +226,18 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok "if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " + "failed=0 && " + "for url in $urls; do " + - "echo -n \"Checking https://$url... \"; " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 10 2>/dev/null); " + - "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \"✅ $STATUS\"; else echo \"❌ $STATUS\"; failed=1; fi; " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + "done && " + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); // Step 11: Exit terminal sequenceBuilder diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs new file mode 100644 index 00000000000..009b5cb5b4a --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs @@ -0,0 +1,306 @@ +// 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 Python FastAPI Aspire applications to Azure App Service. +/// +public sealed class AppServicePythonDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 40 minutes to allow for Azure provisioning and Python environment setup. + // 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.")] + public async Task DeployPythonFastApiTemplateToAzureAppService() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployPythonFastApiTemplateToAzureAppServiceCore(cancellationToken); + } + + private async Task DeployPythonFastApiTemplateToAzureAppServiceCore(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(DeployPythonFastApiTemplateToAzureAppService)); + var startTime = DateTime.UtcNow; + var deploymentUrls = new Dictionary(); + // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("python-appsvc"); + // Project name can be simpler since resource group is explicitly set + var projectName = "PyAppSvc"; + + output.WriteLine($"Test: {nameof(DeployPythonFastApiTemplateToAzureAppService)}"); + output.WriteLine($"Project Name: {projectName}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + 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); + + // Pattern searchers for aspire new interactive prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + // Wait for the FastAPI/React template to be highlighted (after pressing Down twice) + // Use Find() instead of FindPattern() because parentheses and slashes are regex special characters + var waitingForPythonReactTemplateSelected = new CellPatternSearcher() + .Find("> Starter App (FastAPI/React)"); + + 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"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create Python FastAPI project using aspire new with interactive prompts + // Navigate down to select Starter App (FastAPI/React) which is the 3rd option + output.WriteLine("Step 3: Creating Python FastAPI project..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + // Navigate to Starter App (FastAPI/React) - it's the 3rd option (after ASP.NET and JS) + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .WaitUntil(s => waitingForPythonReactTemplateSelected.Search(s).Count > 0, TimeSpan.FromSeconds(5)) + .Enter() // Select Starter App (FastAPI/React) + .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)) + // For Redis prompt, default is "Yes" so we need to select "No" by pressing Down + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Enter() // Select "No" for Redis Cache + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 4: Navigate to project directory + output.WriteLine("Step 4: Navigating to project directory..."); + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Add Aspire.Hosting.Azure.AppService package (instead of AppContainers) + output.WriteLine("Step 5: Adding Azure App Service hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppService") + .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 6: Modify apphost.cs to add Azure App Service Environment + // Note: Python template uses single-file AppHost (apphost.cs in project root) + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + // Single-file AppHost is in the project root, not a subdirectory + var appHostFilePath = Path.Combine(projectDir, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + // Insert the Azure App Service Environment before builder.Build().Run(); + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure App Service Environment for deployment +builder.AddAzureAppServiceEnvironment("infra"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs at: {appHostFilePath}"); + }); + + // Step 7: Set environment for deployment + // - Unset ASPIRE_PLAYGROUND to avoid conflicts + // - Set Azure location to westus3 (same as other tests to use region with capacity) + // - Set AZURE__RESOURCEGROUP to use our unique resource group name + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 8: Deploy to Azure App Service using aspire deploy + output.WriteLine("Step 7: Starting Azure App Service deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + // Wait for pipeline to complete successfully (App Service can take longer) + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 9: Extract deployment URLs and verify endpoints with retry + // For App Service, we use az webapp list instead of az containerapp list + // Retry each endpoint for up to 3 minutes (18 attempts * 10 seconds) + output.WriteLine("Step 8: Verifying deployed endpoints..."); + sequenceBuilder + .Type($"RG_NAME=\"{resourceGroupName}\" && " + + "echo \"Resource group: $RG_NAME\" && " + + "if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " + + // Get App Service hostnames (defaultHostName for each web app) + "urls=$(az webapp list -g \"$RG_NAME\" --query \"[].defaultHostName\" -o tsv 2>/dev/null) && " + + "if [ -z \"$urls\" ]; then echo \"❌ No App Service endpoints found\"; exit 1; fi && " + + "failed=0 && " + + "for url in $urls; do " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 30 2>/dev/null); " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + + "done && " + + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 10: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + // Report success + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployPythonFastApiTemplateToAzureAppService), + 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(DeployPythonFastApiTemplateToAzureAppService), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + // Clean up the resource group we created + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + } + + 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}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs new file mode 100644 index 00000000000..c74eb0ee30b --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs @@ -0,0 +1,325 @@ +// 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 App Service. +/// +public sealed class AppServiceReactDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 40 minutes to allow for Azure App Service provisioning. + // 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.")] + public async Task DeployReactTemplateToAzureAppService() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployReactTemplateToAzureAppServiceCore(cancellationToken); + } + + private async Task DeployReactTemplateToAzureAppServiceCore(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(DeployReactTemplateToAzureAppService)); + var startTime = DateTime.UtcNow; + var deploymentUrls = new Dictionary(); + // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("appservice"); + // Project name can be simpler since resource group is explicitly set + var projectName = "ReactAppSvc"; + + output.WriteLine($"Test: {nameof(DeployReactTemplateToAzureAppService)}"); + output.WriteLine($"Project Name: {projectName}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + 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); + + // Pattern searchers for aspire new interactive prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + // Wait for the ASP.NET Core/React template to be highlighted (after pressing Down once) + // Use Find() instead of FindPattern() because parentheses and slashes are regex special characters + var waitingForReactTemplateSelected = new CellPatternSearcher() + .Find("> Starter App (ASP.NET Core/React)"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find($"Enter the output path: (./{projectName}): "); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + // Note: React template (aspire-ts-cs-starter) does NOT have the "test project" prompt + // unlike the Blazor starter template. It only has localhost URLs and Redis prompts. + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + // The workflow builds and installs the CLI to ~/.aspire/bin before running tests + // We just need to source it in the bash session + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + // Source the CLI environment (sets PATH and other env vars) + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create React + ASP.NET Core project using aspire new with interactive prompts + // Navigate down to select Starter App (ASP.NET Core/React) - it's the 2nd option + output.WriteLine("Step 3: Creating React + ASP.NET Core project..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + // Navigate to Starter App (ASP.NET Core/React) - it's the 2nd option (after Blazor) + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .WaitUntil(s => waitingForReactTemplateSelected.Search(s).Count > 0, TimeSpan.FromSeconds(5)) + .Enter() // Select Starter App (ASP.NET Core/React) + .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)) + // For Redis prompt, default is "Yes" so we need to select "No" by pressing Down + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Enter() // Select "No" for Redis Cache + // Note: React template does NOT have a test project prompt (unlike Blazor starter) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 4: Navigate to project directory + output.WriteLine("Step 4: Navigating to project directory..."); + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Add Aspire.Hosting.Azure.AppService package (instead of AppContainers) + output.WriteLine("Step 5: Adding Azure App Service hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppService") + .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 6: Modify AppHost.cs to add Azure App Service 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($"Looking for AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + // Insert the Azure App Service Environment before builder.Build().Run(); + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure App Service Environment for deployment +builder.AddAzureAppServiceEnvironment("infra"); + +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 6: Navigating to AppHost directory..."); + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 8: Set environment variables for deployment + // - Unset ASPIRE_PLAYGROUND to avoid conflicts + // - Set Azure location + // - Set AZURE__RESOURCEGROUP to use our unique resource group name + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 9: Deploy to Azure App Service using aspire deploy + // Use --clear-cache to ensure fresh deployment without cached location from previous runs + output.WriteLine("Step 7: Starting Azure App Service deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + // Wait for pipeline to complete successfully (App Service can take longer) + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Extract deployment URLs and verify endpoints with retry + // For App Service, we use az webapp list instead of az containerapp list + // Retry each endpoint for up to 3 minutes (18 attempts * 10 seconds) + output.WriteLine("Step 8: Verifying deployed endpoints..."); + sequenceBuilder + .Type($"RG_NAME=\"{resourceGroupName}\" && " + + "echo \"Resource group: $RG_NAME\" && " + + "if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " + + // Get App Service hostnames (defaultHostName for each web app) + "urls=$(az webapp list -g \"$RG_NAME\" --query \"[].defaultHostName\" -o tsv 2>/dev/null) && " + + "if [ -z \"$urls\" ]; then echo \"❌ No App Service endpoints found\"; exit 1; fi && " + + "failed=0 && " + + "for url in $urls; do " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 30 2>/dev/null); " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + + "done && " + + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 11: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + // Report success + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployReactTemplateToAzureAppService), + 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(DeployReactTemplateToAzureAppService), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + // Clean up the resource group we created + 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) + { + // Fire and forget - trigger deletion of the specific resource group created by this test + // The cleanup workflow will handle any that don't get deleted + 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}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs new file mode 100644 index 00000000000..c89e886df2f --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs @@ -0,0 +1,269 @@ +// 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 Azure App Configuration resources via Aspire. +/// Tests the Aspire.Hosting.Azure.AppConfiguration integration package. +/// +public sealed class AzureAppConfigDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureAppConfigResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureAppConfigResourceCore(cancellationToken); + } + + private async Task DeployAzureAppConfigResourceCore(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(DeployAzureAppConfigResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("appconfig"); + + output.WriteLine($"Test: {nameof(DeployAzureAppConfigResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + 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); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + // Integration selection prompt appears when multiple packages match the search term + var waitingForIntegrationSelectionPrompt = new CellPatternSearcher() + .Find("Select an integration to add:"); + + // Version selection prompt appears when selecting a package version in CI + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support) + // This command triggers TWO prompts in sequence: + // 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages) + // 2. Version selection prompt (in CI, to select package version) + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + // First, handle integration selection prompt + sequenceBuilder + .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first integration (azure-appcontainers) + // Then, handle version selection prompt + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.AppConfiguration package + // This command may only show version selection prompt (unique match) + output.WriteLine("Step 4b: Adding Azure App Configuration hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppConfiguration") + .Enter(); + + // In CI, aspire add shows version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure App Configuration resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container App Environment for managed identity support +_ = builder.AddAzureContainerAppEnvironment("env"); + +// Add Azure App Configuration resource for deployment testing +builder.AddAzureAppConfiguration("appconfig"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure App Configuration resource"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure App Configuration store was created + output.WriteLine("Step 8: Verifying Azure App Configuration store..."); + sequenceBuilder + .Type($"az appconfig list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureAppConfigResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureAppConfigResource), + resourceGroupName, + ex.Message); + + 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}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs new file mode 100644 index 00000000000..b27221ad519 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs @@ -0,0 +1,238 @@ +// 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 Azure Container Registry resources via Aspire. +/// Tests the Aspire.Hosting.Azure.ContainerRegistry integration package. +/// +public sealed class AzureContainerRegistryDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureContainerRegistryResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureContainerRegistryResourceCore(cancellationToken); + } + + private async Task DeployAzureContainerRegistryResourceCore(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(DeployAzureContainerRegistryResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("acr"); + + output.WriteLine($"Test: {nameof(DeployAzureContainerRegistryResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + 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); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4: Add Aspire.Hosting.Azure.ContainerRegistry package + output.WriteLine("Step 4: Adding Azure Container Registry hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerRegistry") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure Container Registry resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container Registry resource for deployment testing +builder.AddAzureContainerRegistry("acr"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure Container Registry resource"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure Container Registry was created + output.WriteLine("Step 8: Verifying Azure Container Registry..."); + sequenceBuilder + .Type($"az acr list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureContainerRegistryResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureContainerRegistryResource), + resourceGroupName, + ex.Message); + + 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}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs new file mode 100644 index 00000000000..f551407bf2f --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs @@ -0,0 +1,269 @@ +// 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 Azure Event Hubs resources via Aspire. +/// Tests the Aspire.Hosting.Azure.EventHubs integration package. +/// +public sealed class AzureEventHubsDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureEventHubsResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureEventHubsResourceCore(cancellationToken); + } + + private async Task DeployAzureEventHubsResourceCore(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(DeployAzureEventHubsResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("eventhubs"); + + output.WriteLine($"Test: {nameof(DeployAzureEventHubsResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + 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); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + // Integration selection prompt appears when multiple packages match the search term + var waitingForIntegrationSelectionPrompt = new CellPatternSearcher() + .Find("Select an integration to add:"); + + // Version selection prompt appears when selecting a package version in CI + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support) + // This command triggers TWO prompts in sequence: + // 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages) + // 2. Version selection prompt (in CI, to select package version) + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + // First, handle integration selection prompt + sequenceBuilder + .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first integration (azure-appcontainers) + // Then, handle version selection prompt + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.EventHubs package + // This command may only show version selection prompt (unique match) + output.WriteLine("Step 4b: Adding Azure Event Hubs hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.EventHubs") + .Enter(); + + // In CI, aspire add shows version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure Event Hubs resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container App Environment for managed identity support +_ = builder.AddAzureContainerAppEnvironment("env"); + +// Add Azure Event Hubs resource for deployment testing +builder.AddAzureEventHubs("eventhubs"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure Event Hubs resource"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure Event Hubs namespace was created + output.WriteLine("Step 8: Verifying Azure Event Hubs namespace..."); + sequenceBuilder + .Type($"az eventhubs namespace list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureEventHubsResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureEventHubsResource), + resourceGroupName, + ex.Message); + + 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}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs new file mode 100644 index 00000000000..035b280a7df --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs @@ -0,0 +1,269 @@ +// 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 Azure Key Vault resources via Aspire. +/// Tests the Aspire.Hosting.Azure.KeyVault integration package. +/// +public sealed class AzureKeyVaultDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureKeyVaultResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureKeyVaultResourceCore(cancellationToken); + } + + private async Task DeployAzureKeyVaultResourceCore(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(DeployAzureKeyVaultResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("keyvault"); + + output.WriteLine($"Test: {nameof(DeployAzureKeyVaultResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + 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); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + // Integration selection prompt appears when multiple packages match the search term + var waitingForIntegrationSelectionPrompt = new CellPatternSearcher() + .Find("Select an integration to add:"); + + // Version selection prompt appears when selecting a package version in CI + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support) + // This command triggers TWO prompts in sequence: + // 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages) + // 2. Version selection prompt (in CI, to select package version) + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + // First, handle integration selection prompt + sequenceBuilder + .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first integration (azure-appcontainers) + // Then, handle version selection prompt + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.KeyVault package + // This command may only show version selection prompt (unique match) + output.WriteLine("Step 4b: Adding Azure Key Vault hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.KeyVault") + .Enter(); + + // In CI, aspire add shows version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure Key Vault resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container App Environment for managed identity support +_ = builder.AddAzureContainerAppEnvironment("env"); + +// Add Azure Key Vault resource for deployment testing +builder.AddAzureKeyVault("keyvault"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure Key Vault resource"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure Key Vault was created + output.WriteLine("Step 8: Verifying Azure Key Vault..."); + sequenceBuilder + .Type($"az keyvault list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureKeyVaultResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureKeyVaultResource), + resourceGroupName, + ex.Message); + + 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}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs new file mode 100644 index 00000000000..c0a14e97dfb --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs @@ -0,0 +1,238 @@ +// 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 Azure Log Analytics Workspace resources via Aspire. +/// Tests the Aspire.Hosting.Azure.OperationalInsights integration package. +/// +public sealed class AzureLogAnalyticsDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureLogAnalyticsResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureLogAnalyticsResourceCore(cancellationToken); + } + + private async Task DeployAzureLogAnalyticsResourceCore(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(DeployAzureLogAnalyticsResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("logs"); + + output.WriteLine($"Test: {nameof(DeployAzureLogAnalyticsResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + 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); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4: Add Aspire.Hosting.Azure.OperationalInsights package + output.WriteLine("Step 4: Adding Azure Log Analytics hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.OperationalInsights") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure Log Analytics Workspace resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Log Analytics Workspace resource for deployment testing +builder.AddAzureLogAnalyticsWorkspace("logs"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure Log Analytics Workspace resource"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure Log Analytics Workspace was created + output.WriteLine("Step 8: Verifying Azure Log Analytics Workspace..."); + sequenceBuilder + .Type($"az monitor log-analytics workspace list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureLogAnalyticsResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureLogAnalyticsResource), + resourceGroupName, + ex.Message); + + 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}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs new file mode 100644 index 00000000000..c3083e8d9a4 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs @@ -0,0 +1,269 @@ +// 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 Azure Service Bus resources via Aspire. +/// Tests the Aspire.Hosting.Azure.ServiceBus integration package. +/// +public sealed class AzureServiceBusDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureServiceBusResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureServiceBusResourceCore(cancellationToken); + } + + private async Task DeployAzureServiceBusResourceCore(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(DeployAzureServiceBusResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("servicebus"); + + output.WriteLine($"Test: {nameof(DeployAzureServiceBusResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + 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); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + // Integration selection prompt appears when multiple packages match the search term + var waitingForIntegrationSelectionPrompt = new CellPatternSearcher() + .Find("Select an integration to add:"); + + // Version selection prompt appears when selecting a package version in CI + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support) + // This command triggers TWO prompts in sequence: + // 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages) + // 2. Version selection prompt (in CI, to select package version) + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + // First, handle integration selection prompt + sequenceBuilder + .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first integration (azure-appcontainers) + // Then, handle version selection prompt + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.ServiceBus package + // This command may only show version selection prompt (unique match) + output.WriteLine("Step 4b: Adding Azure Service Bus hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ServiceBus") + .Enter(); + + // In CI, aspire add shows version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure Service Bus resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container App Environment for managed identity support +_ = builder.AddAzureContainerAppEnvironment("env"); + +// Add Azure Service Bus resource for deployment testing +builder.AddAzureServiceBus("messaging"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure Service Bus resource"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure Service Bus namespace was created + output.WriteLine("Step 8: Verifying Azure Service Bus namespace..."); + sequenceBuilder + .Type($"az servicebus namespace list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureServiceBusResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureServiceBusResource), + resourceGroupName, + ex.Message); + + 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}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs new file mode 100644 index 00000000000..3c004591f74 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs @@ -0,0 +1,274 @@ +// 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 Azure Storage resources via Aspire. +/// Tests the Aspire.Hosting.Azure.Storage integration package. +/// +public sealed class AzureStorageDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureStorageResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureStorageResourceCore(cancellationToken); + } + + private async Task DeployAzureStorageResourceCore(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(DeployAzureStorageResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("storage"); + + output.WriteLine($"Test: {nameof(DeployAzureStorageResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + 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); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + // Integration selection prompt appears when multiple packages match the search term + var waitingForIntegrationSelectionPrompt = new CellPatternSearcher() + .Find("Select an integration to add:"); + + // Version selection prompt appears when selecting a package version in CI + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support) + // This command triggers TWO prompts in sequence: + // 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages) + // 2. Version selection prompt (in CI, to select package version) + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + // First, handle integration selection prompt + sequenceBuilder + .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first integration (azure-appcontainers) + // Then, handle version selection prompt + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.Storage package + // This command may only show version selection prompt (unique match) + output.WriteLine("Step 4b: Adding Azure Storage hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Storage") + .Enter(); + + // In CI, aspire add shows version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure Storage resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + // Insert Azure Storage with a container app environment (required for role assignments) + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container App Environment for managed identity support +_ = builder.AddAzureContainerAppEnvironment("env"); + +// Add Azure Storage resource for deployment testing +builder.AddAzureStorage("storage"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure Storage resource"); + output.WriteLine($"New content:\n{content}"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + // Wait for pipeline to complete successfully + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure Storage account was created + output.WriteLine("Step 8: Verifying Azure Storage account..."); + sequenceBuilder + .Type($"az storage account list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + // Report success + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureStorageResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureStorageResource), + resourceGroupName, + ex.Message); + + throw; + } + finally + { + // Always attempt to clean up the resource group + 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}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs index 29ae174bb83..71537a5f54e 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs @@ -14,8 +14,9 @@ namespace Aspire.Deployment.EndToEnd.Tests; /// public sealed class PythonFastApiDeploymentTests(ITestOutputHelper output) { - // Timeout set to 20 minutes to allow for Azure provisioning and Python environment setup. - private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(20); + // Timeout set to 40 minutes to allow for Azure provisioning and Python environment setup. + // Full deployments can take up to 30 minutes if Azure infrastructure is backed up. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); [Fact] public async Task DeployPythonFastApiTemplateToAzureContainerApps() @@ -205,10 +206,11 @@ private async Task DeployPythonFastApiTemplateToAzureContainerAppsCore(Cancellat .Type("aspire deploy --clear-cache") .Enter() // Wait for pipeline to complete successfully - .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(15)) + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); - // Step 10: Extract deployment URLs and verify endpoints + // Step 10: Extract deployment URLs and verify endpoints with retry + // Retry each endpoint for up to 3 minutes (18 attempts * 10 seconds) output.WriteLine("Step 8: Verifying deployed endpoints..."); sequenceBuilder .Type($"RG_NAME=\"{resourceGroupName}\" && " + @@ -219,13 +221,18 @@ private async Task DeployPythonFastApiTemplateToAzureContainerAppsCore(Cancellat "if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " + "failed=0 && " + "for url in $urls; do " + - "echo -n \"Checking https://$url... \"; " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 10 2>/dev/null); " + - "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \"✅ $STATUS\"; else echo \"❌ $STATUS\"; failed=1; fi; " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + "done && " + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); // Step 11: Exit terminal sequenceBuilder diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/README.md b/tests/Aspire.Deployment.EndToEnd.Tests/README.md index 189547f5757..022fc0f2bf2 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/README.md +++ b/tests/Aspire.Deployment.EndToEnd.Tests/README.md @@ -6,6 +6,53 @@ This project contains end-to-end tests that deploy Aspire applications to real A These tests use the [Hex1b](https://github.com/hex1b/hex1b) terminal automation library to drive the Aspire CLI, similar to the CLI E2E tests. The key difference is that these tests actually deploy to Azure and verify the deployed applications work correctly. +## Azure Subscription Quota Requirements + +The deployment tests require an Azure subscription with sufficient quota for the resources being deployed. Ensure the following quotas are available in the test region (currently `westus3`). + +### Container Apps + +| Resource | Quota Required | Current Setting | Notes | +|----------|---------------|-----------------|-------| +| Managed Environments | 150+ | 150 | Each test run creates a new environment. High quota allows concurrent runs and handles cleanup delays. | +| Container App Instances | Default | - | Standard quota is typically sufficient | + +### App Service + +| Resource | Quota Required | Current Setting | Notes | +|----------|---------------|-----------------|-------| +| PremiumV3 vCPUs | 10+ | TBD | App Service Plans use PremiumV3 tier (P0V3). Each deployment needs ~1 vCPU. | +| App Service Plans | 10+ | Default | Each deployment creates a new plan | + +### Container Registry + +| Resource | Quota Required | Notes | +|----------|---------------|-------| +| Azure Container Registry | Default | Standard quota is typically sufficient | + +### General + +| Resource | Quota Required | Notes | +|----------|---------------|-------| +| Resource Groups | 100+ | Each test creates a unique resource group (e.g., `e2e-starter-12345678-1`) | +| Role Assignments | Default | Tests may create role assignments for managed identities | + +### Requesting Quota Increases + +To request quota increases: + +1. Go to the [Azure Portal](https://portal.azure.com) +2. Navigate to **Subscriptions** → Select your subscription +3. Go to **Usage + quotas** +4. Filter by the resource type: + - `Microsoft.App` for Container Apps + - `Microsoft.Web` for App Service +5. Select the quota to increase and click **Request increase** + +Common quota increase requests: +- **Container Apps Managed Environments**: Request 150+ in westus3 +- **App Service PremiumV3 vCPUs**: Request 10+ in westus3 + ## Prerequisites ### For Local Development @@ -100,7 +147,17 @@ Aspire.Deployment.EndToEnd.Tests/ │ ├── DeploymentE2ETestHelpers.cs # Terminal automation helpers │ ├── DeploymentReporter.cs # GitHub step summary reporting │ └── SequenceCounter.cs # Prompt tracking -├── AcaStarterDeploymentTests.cs # Azure Container Apps tests +├── AcaStarterDeploymentTests.cs # Blazor to Azure Container Apps +├── AppServicePythonDeploymentTests.cs # Python FastAPI to Azure App Service +├── AppServiceReactDeploymentTests.cs # React + ASP.NET Core to Azure App Service +├── AzureAppConfigDeploymentTests.cs # Azure App Configuration resource +├── AzureContainerRegistryDeploymentTests.cs # Azure Container Registry resource +├── AzureEventHubsDeploymentTests.cs # Azure Event Hubs resource +├── AzureKeyVaultDeploymentTests.cs # Azure Key Vault resource +├── AzureLogAnalyticsDeploymentTests.cs # Azure Log Analytics resource +├── AzureServiceBusDeploymentTests.cs # Azure Service Bus resource +├── AzureStorageDeploymentTests.cs # Azure Storage resource +├── PythonFastApiDeploymentTests.cs # Python FastAPI to Azure Container Apps ├── xunit.runner.json # Test runner config └── README.md # This file ``` @@ -152,7 +209,14 @@ public sealed class MyDeploymentTests : IAsyncDisposable ### Deployment Timeouts -Deployments can take 15-30+ minutes. The per-test timeout is set to 15 minutes, and the test session timeout is 60 minutes. +Deployments can take 15-30+ minutes. Current timeout settings: + +| Step | Timeout | Description | +|------|---------|-------------| +| Overall test | 40 minutes | Maximum time for entire test execution | +| Pipeline deployment | 30 minutes | Time for `aspire deploy` to complete | +| Endpoint verification | 5 minutes | Time for endpoint check command with retries | +| Per-endpoint retry | ~3 minutes | 18 attempts × 10 seconds per endpoint | ### Resource Cleanup @@ -161,8 +225,14 @@ Tests attempt to clean up Azure resources after completion. The cleanup workflow To find orphaned resources: ```bash -# Resource groups created by aspire deploy +# Resource groups created by deployment tests (current naming) +az group list --query "[?starts_with(name, 'e2e-')]" -o table + +# Resource groups created by aspire deploy (legacy naming) az group list --query "[?starts_with(name, 'rg-aspire-')]" -o table + +# Delete all test resource groups (use with caution!) +az group list --query "[?starts_with(name, 'e2e-')].name" -o tsv | xargs -I {} az group delete --name {} --yes --no-wait ``` ### Viewing Recordings