File: RadiusStarterDeploymentTests.cs
Web Access
Project: src\tests\Aspire.Deployment.EndToEnd.Tests\Aspire.Deployment.EndToEnd.Tests.csproj (Aspire.Deployment.EndToEnd.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Deployment.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Deployment.EndToEnd.Tests;
 
/// <summary>
/// End-to-end test that deploys the Aspire starter template to a live Radius environment
/// running on Azure Kubernetes Service (AKS).
/// </summary>
/// <remarks>
/// Unlike the Radius unit/snapshot tests (which only prove the Bicep serializer output), this
/// test exercises the full <c>aspire publish</c><c>rad deploy app.bicep</c> path against a
/// real cluster and verifies the deployed application actually runs. It provisions an AKS
/// cluster + ACR, installs the Radius control plane onto the cluster, deploys the starter app,
/// and asserts the workloads become ready and serve HTTP traffic.
///
/// The Radius publisher does not build or push container images for project resources yet
/// (tracked at https://github.com/microsoft/aspire/issues/16844), so the test builds and pushes
/// the starter's images to ACR itself and attaches the resulting references with
/// <c>WithContainerImage</c> before publishing.
/// </remarks>
public sealed class RadiusStarterDeploymentTests(ITestOutputHelper output)
{
    // AKS provisioning (~10-15 min) + Radius control-plane install + two image builds + recipe
    // deployment push the total well past the pure-AKS test's budget, so allow up to 55 minutes.
    private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(55);
 
    [Fact]
    [ActiveIssue("https://github.com/microsoft/aspire/issues/19172")]
    public async Task DeployStarterTemplateToRadiusOnAks()
    {
        using var cts = new CancellationTokenSource(s_testTimeout);
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            cts.Token, TestContext.Current.CancellationToken);
        var cancellationToken = linkedCts.Token;
 
        await DeployStarterTemplateToRadiusOnAksCore(cancellationToken);
    }
 
    private async Task DeployStarterTemplateToRadiusOnAksCore(CancellationToken cancellationToken)
    {
        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.");
            }
        }
 
        using var workspace = TemporaryWorkspace.Create(output);
        var startTime = DateTime.UtcNow;
 
        var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("radius");
        var clusterName = $"radius-{DeploymentE2ETestHelpers.GetRunId()}-{DeploymentE2ETestHelpers.GetRunAttempt()}";
        // ACR names must be alphanumeric only, 5-50 chars, globally unique.
        var acrName = $"acrrad{DeploymentE2ETestHelpers.GetRunId()}{DeploymentE2ETestHelpers.GetRunAttempt()}".ToLowerInvariant();
        acrName = new string(acrName.Where(char.IsLetterOrDigit).Take(50).ToArray());
        if (acrName.Length < 5)
        {
            acrName = $"acrrad{Guid.NewGuid():N}"[..24];
        }
 
        var acrLoginServer = $"{acrName}.azurecr.io";
        var apiServiceImage = $"{acrLoginServer}/apiservice:latest";
        var webFrontendImage = $"{acrLoginServer}/webfrontend:latest";
 
        const string projectName = "RadiusStarter";
        // Use a unique namespace so reruns / parallel jobs never collide in the shared "default"
        // namespace and so label-based verification below is unambiguous.
        var appNamespace = $"radius-{DeploymentE2ETestHelpers.GetRunId()}-{DeploymentE2ETestHelpers.GetRunAttempt()}".ToLowerInvariant();
 
        output.WriteLine($"Test: {nameof(DeployStarterTemplateToRadiusOnAks)}");
        output.WriteLine($"Resource Group: {resourceGroupName}");
        output.WriteLine($"AKS Cluster: {clusterName}");
        output.WriteLine($"ACR Name: {acrName}");
        output.WriteLine($"App namespace: {appNamespace}");
        output.WriteLine($"Subscription: {subscriptionId[..8]}...");
        output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}");
 
        try
        {
            using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
            var pendingRun = terminal.RunAsync(cancellationToken);
 
            var counter = new SequenceCounter();
            var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
 
            // ===== PHASE 1: Provision AKS + ACR =====
 
            output.WriteLine("Step 1: Preparing environment...");
            await auto.PrepareEnvironmentAsync(workspace, counter);
 
            var wsRoot = workspace.WorkspaceRoot.FullName;
 
            // Step 1a: Install the Radius (`rad`) CLI into the workspace. `aspire deploy` against a
            // Radius environment shells out to `rad deploy`, and this test drives `rad install
            // kubernetes` / `rad workspace create` / `rad app graph` directly, so the CLI must be on
            // PATH. Installing it here (rather than in the deployment-tests.yml workflow) keeps the
            // Radius prerequisite self-contained in the test that needs it: the ~dozens of other
            // deployment scenarios that share the workflow neither pay the download cost nor risk
            // failing on an install-endpoint outage, and the test can be run locally without any
            // workflow-specific setup.
            //
            // Install into workspace-local dirs so nothing leaks into the developer's real
            // ~/.rad, and prepend the CLI directory to PATH so the Step 1b `command -v rad` (and every
            // later `rad`) resolves to this binary. The Radius installer invokes `rad bicep download`,
            // and even `rad version --cli` initializes config, so run both with HOME scoped to the
            // installer subshell; Radius-managed tools and config then stay under the workspace home.
            // Export PATH outside the subshell because the CLI location must persist, while HOME must
            // not. Later az/kubectl/docker/aspire commands keep the real HOME and use their own
            // KUBECONFIG/DOCKER_CONFIG isolation where needed. radiusVersion pins the CLI version;
            // `rad install kubernetes` (Step 9) then installs the matching control plane, keeping the run
            // deterministic across Radius releases. Keep this aligned with
            // RadiusBicepExtension.Version (major.minor 0.59) so the installed control plane matches
            // the Bicep types the publisher emits. install.sh is fetched pinned to the immutable
            // commit SHA behind the v0.59.0 release tag (radiusInstallScriptSha) rather than a branch
            // or tag ref, either of which can be retargeted, so the executed installer content cannot
            // drift out from under this pin (supply-chain hardening). The download is retried a few
            // times to tolerate transient GitHub CDN failures on scheduled runs. install.sh's needsSudo
            // checks the install dir first and skips sudo when that directory already exists and is
            // writable; it only falls back to the parent dir when the install dir is absent. Pre-creating
            // the user-owned {wsRoot}/radbin makes it see a writable target and skip sudo entirely.
            const string radiusVersion = "0.59.0";
 
            // Immutable commit SHA that the v0.59.0 tag pointed to in radius-project/radius. Update this
            // together with radiusVersion (and RadiusBicepExtension.Version) when bumping the Radius
            // release, re-resolving the tag to its commit SHA.
            const string radiusInstallScriptSha = "2bf2c25fcdde20d4cba1371618829bbbe1f9a997";
            output.WriteLine("Step 1a: Installing the Radius (rad) CLI into the workspace...");
            await auto.TypeAsync(
                // `set -o pipefail` so a failed `curl` propagates through the pipe instead of being
                // masked by `install.sh`'s exit code; the interactive terminal shell does not enable
                // pipefail by default. Scoped to this compound command via a subshell so it never leaks
                // into later steps' commands. The install is retried up to three times (deleting any
                // partial binary between attempts) to ride out transient raw.githubusercontent.com or
                // release-asset download failures, mirroring the CLI E2E installer's retry loop.
                $"( set -o pipefail && " +
                $"mkdir -p \"{wsRoot}/radbin\" \"{wsRoot}/home\" && " +
                $"export HOME=\"{wsRoot}/home\" && " +
                $"{{ for i in 1 2 3; do " +
                $"curl -fsSL \"https://raw.githubusercontent.com/radius-project/radius/{radiusInstallScriptSha}/deploy/install.sh\" | " +
                $"/bin/bash -s -- --version \"{radiusVersion}\" --install-dir \"{wsRoot}/radbin\" && " +
                $"test -x \"{wsRoot}/radbin/rad\" && break; " +
                $"echo \"Retry $i: rad download failed, retrying in 5s...\"; " +
                $"rm -f \"{wsRoot}/radbin/rad\"; sleep 5; done; " +
                $"test -x \"{wsRoot}/radbin/rad\"; }} && " +
                $"\"{wsRoot}/radbin/rad\" version --cli ) && " +
                $"export PATH=\"{wsRoot}/radbin:$PATH\"");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
 
            // Step 1b: Isolate kubeconfig, the Radius (`rad`) config, and Docker credentials to
            // throwaway files under the TemporaryWorkspace so this test never mutates the developer's
            // real ~/.kube/config, ~/.rad/config.yaml, or ~/.docker/config.json (test-isolation
            // requirement, .github/instructions/test-review-guidelines).
            //
            // - KUBECONFIG: az/kubectl honor it, so `az aks get-credentials` writes here and every
            //   kube-touching command uses this file. Removed with the workspace on dispose. Note
            //   several `rad` subcommands do NOT honor KUBECONFIG (they read the default
            //   ~/.kube/config), which is handled by the rad shim's workspace-local HOME below.
            // - rad config + HOME: `rad` only accepts a `--config` flag (no config env var), and
            //   `aspire deploy` spawns `rad deploy` internally (FileName="rad", UseShellExecute=false
            //   -> PATH lookup; see src/Aspire.Hosting.Radius/Publishing/RadiusDeploymentPipelineStep.cs).
            //   Several `rad` subcommands (e.g. `rad install kubernetes`'s Contour gateway config, and
            //   `rad workspace create kubernetes`) read the DEFAULT kubeconfig at $HOME/.kube/config
            //   directly and do NOT honor KUBECONFIG, unlike az/kubectl. So we shadow `rad` with a
            //   workspace-local shim earlier on PATH that (a) injects `--config <ws>/.rad/config.yaml`
            //   into every invocation (global flag, valid before any subcommand) and (b) exports
            //   HOME=<ws>/home, whose .kube/config is symlinked to our isolated $KUBECONFIG below.
            //   Setting HOME in the shim rather than on individual commands ensures EVERY `rad` -
            //   including the `rad deploy` that `aspire deploy` spawns, which we cannot env-prefix -
            //   reads the isolated kubeconfig, and it never touches the developer's real ~/.kube/config
            //   or ~/.rad. az/kubectl/docker/aspire keep the real HOME (they use KUBECONFIG/DOCKER_CONFIG
            //   env vars). REAL_RAD is resolved BEFORE prepending the shim dir, so the shim never
            //   recurses. The symlink target ($KUBECONFIG) is written by `az aks get-credentials`
            //   (Step 7) before the first `rad` runs (Step 9), so creating it here as a dangling link
            //   is fine. Everything lives under the TemporaryWorkspace and is removed on dispose, so
            //   there is nothing to back up or restore (avoiding a race with any concurrent `rad`).
            // - DOCKER_CONFIG: the documented override directory for Docker's config.json. `az acr login`
            //   shells out to `docker login`, and `dotnet publish /t:PublishContainer` reads the same
            //   config, so both authenticate against this throwaway dir instead of ~/.docker.
            output.WriteLine("Step 1b: Isolating kubeconfig, rad config, and docker credentials to the workspace...");
            await auto.TypeAsync(
                $"REAL_RAD=\"$(command -v rad)\" && " +
                $"mkdir -p \"{wsRoot}/bin\" \"{wsRoot}/.rad\" \"{wsRoot}/.kube\" \"{wsRoot}/.docker\" \"{wsRoot}/home/.kube\" && " +
                $"printf '#!/usr/bin/env bash\\nexport HOME=\"%s\"\\nexec \"%s\" --config \"%s\" \"$@\"\\n' \"{wsRoot}/home\" \"$REAL_RAD\" \"{wsRoot}/.rad/config.yaml\" > \"{wsRoot}/bin/rad\" && " +
                $"chmod +x \"{wsRoot}/bin/rad\" && " +
                $"ln -sf \"{wsRoot}/.kube/config\" \"{wsRoot}/home/.kube/config\" && " +
                $"export PATH=\"{wsRoot}/bin:$PATH\" && " +
                $"export KUBECONFIG=\"{wsRoot}/.kube/config\" && " +
                $"export DOCKER_CONFIG=\"{wsRoot}/.docker\"");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            output.WriteLine("Step 2: Registering required resource providers...");
            await auto.TypeAsync("az provider register --namespace Microsoft.ContainerService --wait && " +
                  "az provider register --namespace Microsoft.ContainerRegistry --wait");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
 
            output.WriteLine("Step 3: Creating resource group...");
            await auto.TypeAsync($"az group create --name {resourceGroupName} --location westus3 --output table");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
            output.WriteLine("Step 4: Creating Azure Container Registry...");
            await auto.TypeAsync($"az acr create --resource-group {resourceGroupName} --name {acrName} --sku Basic --output table");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
 
            // Log into ACR immediately (before AKS creation which takes 10-15 min). The OIDC
            // federated token expires after ~5 minutes, so authenticate while it's fresh. Docker
            // credentials are written to the isolated $DOCKER_CONFIG dir set up in Step 1b, not the
            // developer's ~/.docker/config.json.
            output.WriteLine("Step 4b: Logging into Azure Container Registry (early, before token expires)...");
            await auto.TypeAsync($"az acr login --name {acrName}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
            output.WriteLine("Step 5: Creating AKS cluster (this may take 10-15 minutes)...");
            await auto.TypeAsync($"az aks create " +
                  $"--resource-group {resourceGroupName} " +
                  $"--name {clusterName} " +
                  $"--node-count 1 " +
                  $"--node-vm-size Standard_D2as_v5 " +
                  // The test never SSHes into the nodes, so configure no SSH key at all. This avoids
                  // `--generate-ssh-keys`, which would write ~/.ssh/id_rsa[.pub] on a local run and
                  // mutate the developer's SSH state (the test isolates kube/rad/docker config in
                  // Step 1b; --no-ssh-key means there is simply nothing to isolate here).
                  $"--no-ssh-key " +
                  $"--attach-acr {acrName} " +
                  $"--enable-managed-identity " +
                  $"--output table");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(20));
 
            output.WriteLine("Step 6: Verifying AKS-ACR integration...");
            await auto.TypeAsync($"az aks update --resource-group {resourceGroupName} --name {clusterName} --attach-acr {acrName}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
 
            output.WriteLine("Step 7: Configuring kubectl credentials...");
            // Writes to the isolated $KUBECONFIG (Step 1b), not the developer's ~/.kube/config.
            await auto.TypeAsync($"az aks get-credentials --resource-group {resourceGroupName} --name {clusterName} --overwrite-existing");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            output.WriteLine("Step 8: Verifying kubectl connectivity...");
            await auto.TypeAsync("kubectl get nodes");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            // ===== PHASE 2: Install the Radius control plane on the cluster =====
 
            // Install the Radius control plane. The `rad` CLI version is pinned by the Step 1a
            // install, and `rad install kubernetes` installs the matching control plane onto the
            // current kube context. This (and every other `rad`) runs through the Step 1b shim, so its
            // Contour gateway config - which reads the default-path kubeconfig ($HOME/.kube/config) and
            // ignores KUBECONFIG - resolves to our isolated config via the shim's workspace-local HOME,
            // and config writes land in the workspace-local <ws>/.rad/config.yaml.
            output.WriteLine("Step 9: Installing the Radius control plane onto the cluster...");
            await auto.TypeAsync("rad install kubernetes");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(10));
 
            // Configure a deterministic default workspace bound to this cluster. `rad install
            // kubernetes` already ensures the `default` group and environment exist; `rad deploy`
            // (invoked by `aspire deploy`) passes no --workspace/--group/--environment, so it
            // resolves the default workspace scope. Create the workspace explicitly instead of
            // relying on the interactive `rad init`. This (and every other `rad`) goes through the
            // Step 1b shim, so it writes the workspace-local config and sets itself current there.
            // No `--force`: the isolated config starts empty, so the name can never collide with a
            // pre-existing workspace (and there is no user workspace to clobber).
            output.WriteLine("Step 10: Creating Radius workspace (default scope)...");
            await auto.TypeAsync($"rad workspace create kubernetes radius-e2e --context $(kubectl config current-context) --group default --environment default");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            output.WriteLine("Step 11: Verifying Radius environment...");
            await auto.TypeAsync("rad version && rad env show default --group default");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
            // ===== PHASE 3: Create the Aspire starter project and target Radius =====
 
            await auto.InstallCurrentBuildAspireCliAsync(counter, output, "Step 12");
 
            // Redis is intentionally enabled: it exercises the Radius container recipe and the
            // connection-string wiring from webfrontend to a Radius-deployed cache, which is part
            // of the realistic starter scenario. (Redis is a ContainerResource with its own image,
            // so it is not the subject of the WithContainerImage project-image workaround.)
            output.WriteLine("Step 13: Creating Aspire starter project (with Redis cache)...");
            await auto.AspireNewAsync(projectName, counter, useRedisCache: true);
 
            output.WriteLine("Step 14: Creating application namespace...");
            await auto.TypeAsync($"kubectl create namespace {appNamespace} --dry-run=client -o yaml | kubectl apply -f -");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            output.WriteLine("Step 15: Adding the Aspire.Hosting.Radius package...");
            await auto.TypeAsync($"cd {projectName}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            await auto.TypeAsync("aspire add Aspire.Hosting.Radius");
            await auto.EnterAsync();
            await auto.WaitForAspireAddCompletionAsync(counter);
 
            // Edit AppHost.cs: target the Radius environment and attach the ACR image references
            // the publisher requires for project resources.
            var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
            var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost");
            var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs");
            var webProgramFilePath = Path.Combine(projectDir, $"{projectName}.Web", "Program.cs");
 
            output.WriteLine($"Step 16: Modifying AppHost.cs at: {appHostFilePath}");
            var content = File.ReadAllText(appHostFilePath);
 
            // Attach the pushed ACR image to each project resource. The Radius publisher fails
            // publish for project resources without an image annotation (no <name>:latest
            // fallback) - see WithContainerImage / issue 16844. Inserting WithContainerImage
            // immediately after the resource name keeps the existing fluent chain valid.
            content = content.Replace(
                "(\"apiservice\")",
                $"(\"apiservice\")\n    .WithContainerImage(\"{apiServiceImage}\")");
            content = content.Replace(
                "(\"webfrontend\")",
                $"(\"webfrontend\")\n    .WithContainerImage(\"{webFrontendImage}\")");
 
            // Register the Radius compute environment before Build().
            content = content.Replace(
                "builder.Build().Run();",
                $"builder.AddRadiusEnvironment(\"radius\").WithNamespace(\"{appNamespace}\");\n\nbuilder.Build().Run();");
 
            // WithContainerImage is Experimental (ASPIRERADIUS057) and the pipeline APIs used by
            // compute environments are Experimental (ASPIREPIPELINES001); suppress both. Add each
            // pragma independently so one already being present (e.g. a future template that emits
            // ASPIREPIPELINES001 itself) never skips adding the other and fails the warning-as-error build.
            if (!content.Contains("#pragma warning disable ASPIREPIPELINES001"))
            {
                content = "#pragma warning disable ASPIREPIPELINES001\n" + content;
            }
            if (!content.Contains("#pragma warning disable ASPIRERADIUS057"))
            {
                content = "#pragma warning disable ASPIRERADIUS057\n" + content;
            }
 
            File.WriteAllText(appHostFilePath, content);
            output.WriteLine("Modified AppHost.cs with AddRadiusEnvironment + WithContainerImage");
 
            output.WriteLine($"Step 16b: Adding Radius diagnostics endpoints to Web Program.cs at: {webProgramFilePath}");
            var webProgram = File.ReadAllText(webProgramFilePath);
            if (!webProgram.Contains("using Microsoft.AspNetCore.OutputCaching;"))
            {
                webProgram = "using Microsoft.AspNetCore.OutputCaching;\n" + webProgram;
            }
 
            webProgram = webProgram.Replace(
                "app.MapRazorComponents<App>()",
                """
                app.MapGet("/radius-diagnostics/apiservice", async (WeatherApiClient weatherApi, CancellationToken cancellationToken) =>
                {
                    var forecasts = await weatherApi.GetWeatherAsync(cancellationToken: cancellationToken);
                    return Results.Ok(new { count = forecasts.Length });
                });
 
                app.MapGet("/radius-diagnostics/cache", async (IOutputCacheStore cacheStore, CancellationToken cancellationToken) =>
                {
                    var key = $"radius-diagnostics:{Guid.NewGuid():N}";
                    var payload = new byte[] { 1, 2, 3, 4 };
 
                    await cacheStore.SetAsync(key, payload, tags: null, validFor: TimeSpan.FromMinutes(1), cancellationToken);
                    var cached = await cacheStore.GetAsync(key, cancellationToken);
 
                    return cached is { Length: 4 } ? Results.Ok("OK") : Results.Problem("Redis output cache round-trip failed.");
                });
 
                app.MapRazorComponents<App>()
                """);
 
            File.WriteAllText(webProgramFilePath, webProgram);
            output.WriteLine("Modified Web Program.cs with direct apiservice and Redis output-cache diagnostics");
 
            // ===== PHASE 4: Build and push the container images to ACR =====
 
            // Re-login to ACR: the initial login (Step 4b) may have expired during the 10-15 min
            // AKS provisioning because OIDC federated tokens have a short (~5 min) lifetime.
            output.WriteLine("Step 17: Refreshing ACR login...");
            await auto.TypeAsync($"az acr login --name {acrName}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
            output.WriteLine("Step 18: Building and pushing container images to ACR...");
            await auto.TypeAsync($"dotnet publish {projectName}.Web/{projectName}.Web.csproj " +
                  $"/t:PublishContainer " +
                  $"/p:ContainerRegistry={acrLoginServer} " +
                  $"/p:ContainerImageName=webfrontend " +
                  $"/p:ContainerImageTag=latest");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
 
            await auto.TypeAsync($"dotnet publish {projectName}.ApiService/{projectName}.ApiService.csproj " +
                  $"/t:PublishContainer " +
                  $"/p:ContainerRegistry={acrLoginServer} " +
                  $"/p:ContainerImageName=apiservice " +
                  $"/p:ContainerImageTag=latest");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
 
            // ===== PHASE 5: Publish, verify Bicep, then deploy via rad =====
 
            output.WriteLine("Step 19: Navigating to AppHost directory...");
            await auto.TypeAsync($"cd {projectName}.AppHost");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // Publish first to inspect the generated app.bicep. Fail fast if the ACR image
            // references did not make it into the Bicep (the most likely wiring regression).
            output.WriteLine("Step 20: Running aspire publish and verifying app.bicep image references...");
            await auto.TypeAsync("aspire publish --output-path ../out");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
 
            // Split the marker token in the source command (BICEP_IMAGES''_OK evaluates to
            // BICEP_IMAGES_OK) so the searched string appears only in the command's *output* when
            // both greps match, not in the echoed command line itself. The grep -q chain's exit
            // code is still the hard gate (a failed grep trips the ERR prompt below); this marker
            // makes a successful match an explicit positive signal in the recording.
            await auto.TypeAsync($"grep -q '{acrLoginServer}/apiservice' ../out/app.bicep && " +
                  $"grep -q '{acrLoginServer}/webfrontend' ../out/app.bicep && echo BICEP_IMAGES''_OK");
            await auto.EnterAsync();
            await auto.WaitUntilAsync(
                s => new CellPatternSearcher().Find("BICEP_IMAGES_OK").Search(s).Count > 0,
                timeout: TimeSpan.FromSeconds(30),
                description: "app.bicep contains ACR image references");
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            output.WriteLine("Step 21: Verifying AKS can pull from ACR before deploying...");
            await auto.TypeAsync($"az aks check-acr --resource-group {resourceGroupName} --name {clusterName} --acr {acrLoginServer}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
 
            output.WriteLine("Step 22: Deploying to Radius via aspire deploy (rad deploy app.bicep)...");
            await auto.TypeAsync("aspire deploy");
            await auto.EnterAsync();
            // Wait on this command's sequence-numbered prompt (not WaitForPipelineSuccessAsync).
            // WaitForPipelineSuccessAsync scans the whole viewport for "Pipeline succeeded", but the
            // earlier `aspire publish` (Step 20) already printed that exact marker and it is still
            // on screen, so the pipeline wait could match the stale publish marker and return
            // immediately -- collapsing the real deploy budget down to the following prompt wait.
            // The counter-scoped prompt wait is bound to this deploy command alone, carries the full
            // deploy budget, and already fails fast on a non-zero deploy via the `[n ERR:]`
            // prompt. (Unlike the sibling ACA/AKS tests, deploy here only runs `rad deploy` against
            // the already-provisioned AKS cluster, so the pipeline helper's transient-Azure-capacity
            // Assert.Skip -- which targets AKS *provisioning* -- does not apply.)
            // 17m is a generous upper bound for a single `rad deploy` of the starter app against the
            // already-provisioned AKS cluster -- Radius recipe execution, container image pulls, and
            // pod readiness -- plus margin for the terminal prompt to settle.
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(17));
 
            // ===== PHASE 6: Verify the deployed application =====
 
            // The Radius application name is fixed to "app" by the publisher. Show the deployed
            // graph and the container resources; both must succeed.
            //
            // --preview forces the Radius.Core graph implementation. Without it, the pinned 0.59
            // `rad app graph` routes to the legacy Applications.Core graph API, which the legacy
            // `app` that Radius creates for Redis satisfies on its own -- so the command could
            // succeed without ever proving the Radius.Core UDT application that owns the project
            // containers actually deployed. See `rad app graph --help`:
            //   --preview   Use the Radius.Core preview implementation
            output.WriteLine("Step 23: Verifying Radius resources...");
            await auto.TypeAsync("rad app graph -a app --preview");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            // An empty Radius.Core application still exits 0, and the harness only gates on the
            // shell exit code -- not on graph contents. Capture the preview graph once and assert
            // it names both project containers so a missing UDT container fails fast: grep exits
            // non-zero on a miss, and `G=$(...)` propagates a failed `rad app graph`, either of
            // which trips the `[n ERR:]` prompt that WaitForSuccessPromptAsync fails on.
            await auto.TypeAsync("G=$(rad app graph -a app --preview) && echo \"$G\" && echo \"$G\" | grep -q apiservice && echo \"$G\" | grep -q webfrontend");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            await auto.TypeAsync("rad resource list Radius.Compute/containers -a app");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
            // Radius labels every workload with radapp.io/application; wait for all app pods ready.
            output.WriteLine("Step 24: Waiting for application pods to be ready...");
            await auto.TypeAsync($"kubectl wait --for=condition=ready pod -n {appNamespace} -l radapp.io/application=app --timeout=300s");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(6));
 
            // Print the deployed workloads (with labels) for diagnostics. Radius labels every
            // container Deployment/pod with radapp.io/resource=<name>, so this makes a label-schema
            // change in a future control plane immediately visible in the recording.
            output.WriteLine("Step 25: Listing deployed pods and services...");
            await auto.TypeAsync($"kubectl get pods,svc -n {appNamespace} -l radapp.io/application=app --show-labels");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            // Port-forward directly to the Deployment. Even though the Radius container recipe
            // creates a ClusterIP Service for each container that declares ports (asserted in
            // Step 29), port-forwarding to the Deployment reaches a ready pod directly without
            // depending on Service endpoint readiness. 8080 is the container port Aspire assigns to
            // published containers (ASPNETCORE_HTTP_PORTS=8080).
            output.WriteLine("Step 26: Verifying apiservice endpoint via port-forward...");
            await auto.TypeAsync($"kubectl port-forward -n {appNamespace} deployment/apiservice 18080:8080 &");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10));
            await auto.TypeAsync("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18080/weatherforecast -o /dev/null -w '%{http_code}' && echo ' OK' && break; done");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
            // Verify the webfrontend container serves HTTP by probing its home page, then verify
            // the Redis output-cache connection and the direct webfrontend -> apiservice call
            // through the diagnostic endpoints added above.
            //
            // The Radius resource-types-contrib Kubernetes container recipe creates a ClusterIP
            // Service for each container that declares ports. Its service config uses:
            //   name: '${normalizedName}-${svc.containerName}'
            //   port: port.value.containerPort
            //   targetPort: port.value.containerPort
            // where normalizedName is context.resource.name and svc.containerName is the key under
            // properties.containers. Aspire emits a single container entry keyed by the resource
            // name, so apiservice's Service is `apiservice-apiservice` listening on the container
            // port (8080). Aspire's Radius service discovery emits the matching address
            //   http://apiservice-apiservice.<namespace>.svc.cluster.local:8080
            // (see RadiusEnvironmentResource.GetHostAddressExpression / RadiusServiceDiscovery), so
            // the webfrontend -> apiservice call and the /weather page resolve in-cluster.
            // Source:
            // https://github.com/radius-project/resource-types-contrib/blob/main/Compute/containers/recipes/kubernetes/bicep/kubernetes-containers.bicep
            output.WriteLine("Step 27: Verifying webfrontend home page via port-forward...");
            await auto.TypeAsync($"kubectl port-forward -n {appNamespace} deployment/webfrontend 18081:8080 &");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10));
            await auto.TypeAsync("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/ -o /dev/null -w '%{http_code}' && echo ' OK' && break; done");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
            output.WriteLine("Step 28: Verifying webfrontend -> Redis output-cache connectivity...");
            await auto.TypeAsync("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/radius-diagnostics/cache -o /dev/null -w '%{http_code}' && echo ' OK' && break; done");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
            // Assert the recipe-created Service explicitly before asserting the app path, so a
            // service-discovery/DNS regression is distinguishable from an application failure. Verify
            // the full recipe contract (type == ClusterIP, port == targetPort == containerPort == 8080)
            // so a change to any of those — not just the Service port — is caught here.
            output.WriteLine("Step 29: Verifying the recipe created a ClusterIP Service apiservice-apiservice with port==targetPort==8080...");
            await auto.TypeAsync($"test \"$(kubectl get svc -n {appNamespace} apiservice-apiservice -o jsonpath='{{.spec.type}}/{{.spec.ports[0].port}}/{{.spec.ports[0].targetPort}}')\" = 'ClusterIP/8080/8080'");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            output.WriteLine("Step 30: Verifying direct webfrontend -> apiservice connectivity...");
            await auto.TypeAsync("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/radius-diagnostics/apiservice -o /dev/null -w '%{http_code}' && echo ' OK' && break; done");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
            output.WriteLine("Step 31: Verifying the /weather page (webfrontend -> apiservice via Redis output cache)...");
            await auto.TypeAsync("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/weather -o /dev/null -w '%{http_code}' && echo ' OK' && break; done");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
            output.WriteLine("Step 32: Cleaning up port-forwards...");
            await auto.TypeAsync("kill %1 %2 2>/dev/null; true");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10));
 
            output.WriteLine("Step 33: Exiting terminal...");
            await auto.TypeAsync("exit");
            await auto.EnterAsync();
 
            await pendingRun;
 
            var duration = DateTime.UtcNow - startTime;
            output.WriteLine($"Full Radius deployment completed in {duration}");
 
            DeploymentReporter.ReportDeploymentSuccess(
                nameof(DeployStarterTemplateToRadiusOnAks),
                resourceGroupName,
                new Dictionary<string, string>
                {
                    ["cluster"] = clusterName,
                    ["acr"] = acrName,
                    ["namespace"] = appNamespace,
                    ["project"] = projectName
                },
                duration);
 
            output.WriteLine("✅ Test passed - Aspire starter deployed to Radius on AKS!");
        }
        catch (Exception ex)
        {
            var duration = DateTime.UtcNow - startTime;
            output.WriteLine($"❌ Test failed after {duration}: {ex.Message}");
 
            DeploymentReporter.ReportDeploymentFailure(
                nameof(DeployStarterTemplateToRadiusOnAks),
                resourceGroupName,
                ex.Message,
                ex.StackTrace);
 
            throw;
        }
        finally
        {
            // Deleting the resource group removes AKS, ACR, and all Radius control-plane state,
            // so no separate `rad` cleanup is required. The isolated kubeconfig lived under the
            // TemporaryWorkspace (Step 1b/8b), so disposing the workspace is all the kube cleanup
            // needed — the developer's real ~/.kube/config was never touched.
            output.WriteLine($"Cleaning up resource group: {resourceGroupName}");
            await CleanupResourceGroupAsync(resourceGroupName);
        }
    }
 
    private async Task CleanupResourceGroupAsync(string resourceGroupName)
    {
        try
        {
            using var process = new System.Diagnostics.Process
            {
                StartInfo = new System.Diagnostics.ProcessStartInfo
                {
                    FileName = "az",
                    Arguments = $"group delete --name {resourceGroupName} --yes --no-wait",
                    RedirectStandardOutput = true,
                    RedirectStandardError = true,
                    UseShellExecute = false
                }
            };
 
            process.Start();
            using var waitCts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
            try
            {
                await process.WaitForExitAsync(waitCts.Token);
            }
            catch (OperationCanceledException) when (waitCts.IsCancellationRequested)
            {
                // WaitForExitAsync only stops awaiting on cancellation; it does NOT terminate the
                // spawned `az` process. Disposing the Process object wouldn't either. Kill the whole
                // tree -- `az` is a Python wrapper that forks child processes -- so a hung cleanup
                // (e.g. an interactive auth-refresh prompt) can't outlive the test run. Then wait
                // briefly for the OS to reap the tree so we don't report before it has actually exited.
                var terminated = false;
                try
                {
                    process.Kill(entireProcessTree: true);
                    using var killCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
                    await process.WaitForExitAsync(killCts.Token);
                    terminated = true;
                }
                catch (Exception killEx)
                {
                    // The process may have exited between the timeout and the kill (race), or the
                    // kill/second wait may itself fail; report based on whether the wait completed.
                    output.WriteLine($"Failed to terminate timed-out cleanup process: {killEx.Message}");
                }
 
                var detail = terminated
                    ? "Timed out after 2 minutes; process tree terminated"
                    : "Timed out after 2 minutes; process tree may not be fully terminated";
                output.WriteLine($"Resource group deletion timed out ({resourceGroupName}): {detail}");
                DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, detail);
                return;
            }
 
            if (process.ExitCode == 0)
            {
                output.WriteLine($"Resource group deletion initiated: {resourceGroupName}");
                DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Deletion initiated");
            }
            else
            {
                var error = await process.StandardError.ReadToEndAsync();
                output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}");
                DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, $"Exit code {process.ExitCode}: {error}");
            }
        }
        catch (Exception ex)
        {
            output.WriteLine($"Failed to cleanup resource group: {ex.Message}");
            DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, ex.Message);
        }
    }
}