File: RadiusDeployTests.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.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.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end coverage for deploying an AppHost that targets a Radius compute
/// environment (see <c>Aspire.Hosting.Radius</c>) all the way to running
/// workloads — <b>without any Azure</b>. Where <see cref="RadiusPublishTests"/>
/// stops at generating <c>app.bicep</c>, this test drives the full CLI path
/// (<c>aspire publish</c><c>aspire deploy</c><c>rad deploy app.bicep</c>)
/// against a local KinD cluster with the Radius control plane installed, then
/// asserts the container is actually scheduled and serving HTTP.
///
/// This gives per-PR, local coverage of the Radius deploy flow alongside the
/// live Azure/AKS test (<c>Aspire.Deployment.EndToEnd.Tests</c>), which runs on
/// demand (<c>workflow_dispatch</c>) and nightly (the <c>deployment-tests.yml</c>
/// schedule), not on every PR.
///
/// A public image (<c>mcr.microsoft.com/dotnet/samples:aspnetapp</c>) is used
/// so the KinD node pulls it directly from MCR. That intentionally avoids the
/// build-and-push-to-localhost:5001 machinery the Kubernetes deploy tests need:
/// no image build, no registry round-trip, and no reliance on the mounted host
/// Docker daemon for image movement — the single biggest reliability win for a
/// per-PR test. The KinD cluster is still created via
/// <see cref="KubernetesDeployTestHelpers.CreateKindClusterWithRegistryAsync"/>
/// (the registry sits idle) because that helper also performs the critical
/// internal-kubeconfig networking fix that lets the helper container reach the
/// cluster's API server.
/// </summary>
public sealed class RadiusDeployTests(ITestOutputHelper output)
{
    private const string ProjectName = "AspireRadiusDeployTest";
 
    // A stable, digest-pinned public image. The `dotnet/samples` images are explicitly documented
    // as unstable and can break at any time (dotnet/dotnet-docker#7191), so this test uses the same
    // image + digest the deployment E2E suite standardized on (see
    // tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs). Pinning by SHA256
    // makes the pulled content immutable, so the KinD node pulls the exact bytes once from MCR.
    private const string ContainerImage = "mcr.microsoft.com/azuredocs/aci-helloworld";
    private const string ContainerImageTag = "latest";
    private const string ContainerImageDigest = "456a1150aa41340a14c7be1342deda2cde9e6e7df9fde6b8a69de0ae04f92fad";
    private const int ContainerPort = 80;
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task DeployRadiusContainerToKind()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        using var workspace = TemporaryWorkspace.Create(output);
 
        var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName();
 
        // The Radius app namespace must be a valid RFC 1123 label (WithNamespace
        // enforces this) and must pre-exist before deploy: the Radius.Core
        // environment controller hard-fails if the target namespace is missing
        // (the UDT environment model, unlike the legacy Applications.Core model,
        // deliberately does not auto-create it).
        var radiusNamespace = $"radius-{clusterName[..16]}";
 
        output.WriteLine($"Cluster name: {clusterName}");
        output.WriteLine($"Radius namespace: {radiusNamespace}");
 
        // mountDockerSocket: true is required so KinD (and the Radius control-plane
        // images it pulls) run against the host Docker daemon from inside the
        // helper container.
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
        await auto.VerifyPullRequestCliVersionAsync(counter);
 
        try
        {
            // =================================================================
            // Phase 1: Cluster + Radius control plane
            // =================================================================
            await auto.InstallKindAndHelmAsync(counter);
            await auto.CreateKindClusterWithRegistryAsync(counter, clusterName);
            await auto.InstallRadCliAsync(counter);
            await auto.InstallRadiusControlPlaneAsync(counter, clusterName);
 
            // =================================================================
            // Phase 2: Scaffold the AppHost
            // =================================================================
 
            // Empty AppHost template (not Starter): the Radius publisher fails on
            // ProjectResources with no attached image, so we add exactly one
            // container. This mirrors RadiusPublishTests.
            await auto.AspireNewCSharpEmptyAppHostAsync(ProjectName, counter);
 
            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, TimeSpan.FromSeconds(180));
 
            // Insert the Radius wiring before `builder.Build().Run();`. AddRadiusEnvironment,
            // WithNamespace, AddContainer, and WithHttpEndpoint are all non-[Experimental],
            // so no ASPIRERADIUS*/ASPIREPIPELINES* suppression is needed. WithHttpEndpoint's
            // targetPort drives the container port the Radius publisher emits on the native
            // Radius.Compute/containers workload. Radius does not synthesize a Kubernetes Service
            // for that workload, so Phase 5 reaches it by port-forwarding straight to the
            // Deployment rather than through a Service.
            var appHostFilePath = Path.Combine(
                workspace.WorkspaceRoot.FullName,
                ProjectName,
                "apphost.cs");
            var content = File.ReadAllText(appHostFilePath);
            const string buildRunPattern = "builder.Build().Run();";
            Assert.Contains(buildRunPattern, content);
            var radiusWiring = $$"""
                builder.AddRadiusEnvironment("radius").WithNamespace("{{radiusNamespace}}");
                builder.AddContainer("web", "{{ContainerImage}}", "{{ContainerImageTag}}")
                    .WithImageSHA256("{{ContainerImageDigest}}")
                    .WithHttpEndpoint(targetPort: {{ContainerPort}});
                """;
            content = content.Replace(buildRunPattern, radiusWiring + Environment.NewLine + Environment.NewLine + buildRunPattern);
            File.WriteAllText(appHostFilePath, content);
 
            // ASPIRE_PLAYGROUND=true takes precedence over --non-interactive and makes
            // Spectre.Console attempt concurrent dynamic displays (see KubernetesPublishTests).
            await auto.TypeAsync("unset ASPIRE_PLAYGROUND");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // =================================================================
            // Phase 3: Publish and assert the generated Bicep shape
            // =================================================================
            await auto.TypeAsync("aspire publish -o radius-output --non-interactive");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
 
            var appBicepPath = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName, "radius-output", "app.bicep");
            Assert.True(File.Exists(appBicepPath), $"Expected generated Bicep at '{appBicepPath}'.");
            var appBicep = File.ReadAllText(appBicepPath);
            Assert.Contains("Radius.Core/environments", appBicep);
            Assert.Contains("Radius.Compute/containers", appBicep);
            Assert.Contains(ContainerImage, appBicep);
 
            // =================================================================
            // Phase 4: Create the app namespace, then deploy
            // =================================================================
            await auto.TypeAsync($"kubectl create namespace {radiusNamespace} --context kind-{clusterName}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
            // aspire deploy regenerates the artifacts and runs `rad deploy app.bicep`
            // against the radius-e2e workspace (pinned to this KinD cluster). A
            // container-only Radius app has no parameters to prompt for.
            //
            // Wait on this command's own sequence-numbered prompt with the full deploy
            // budget rather than WaitForPipelineSuccessAsync: the latter scans the whole
            // viewport and would match the stale "Pipeline succeeded" left by the earlier
            // `aspire publish`, returning before this deploy finishes. The prompt wait is
            // scoped to this command and still fails fast on a non-zero deploy via the ERR
            // prompt.
            await auto.TypeAsync("aspire deploy");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(15));
 
            // =================================================================
            // Phase 5: Verify the workload is scheduled and serving HTTP
            // =================================================================
 
            // Radius labels every workload it creates with radapp.io/application and
            // radapp.io/resource; wait on the app label so we don't depend on the
            // generated Deployment/pod name.
            await auto.TypeAsync($"kubectl wait --for=condition=Ready pod -n {radiusNamespace} -l radapp.io/application=app --timeout=180s");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(4));
 
            await auto.TypeAsync($"kubectl get pods,svc -n {radiusNamespace} -l radapp.io/application=app");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // Resolve the Deployment by the radapp.io/resource label and port-forward to it
            // directly. Radius does not synthesize a Kubernetes Service for a container workload
            // (the HTTP endpoint is modeled at the Radius layer, not as a k8s Service), so there
            // is no Service to target; only the Deployment/pods exist. Resolving by label avoids
            // depending on the generated Deployment name.
            await auto.TypeAsync($"RADIUS_DEPLOY=$(kubectl get deployment -n {radiusNamespace} -l radapp.io/resource=web -o jsonpath='{{.items[0].metadata.name}}') && echo \"Resolved deployment: $RADIUS_DEPLOY\"");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            await auto.TypeAsync($"kubectl port-forward -n {radiusNamespace} deployment/$RADIUS_DEPLOY 18080:{ContainerPort} &");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            await auto.TypeAsync("sleep 3");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // The aspnetapp sample serves HTTP 200 on `/`. Retry to absorb the brief
            // window while the port-forward and container finish coming up. The success
            // marker is split in the shell source (VERIFY''_OK evaluates to VERIFY_OK) so
            // the contiguous token appears only in curl's output on a 200, never in the
            // echoed command line — otherwise WaitUntilTextAsync would match the command
            // itself and return before curl succeeds. Mirrors BICEP_IMAGES''_OK in the
            // AKS deployment test.
            await auto.TypeAsync("for i in $(seq 1 20); do " +
                "code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:18080/ 2>/dev/null); " +
                "if [ \"$code\" = \"200\" ]; then echo VERIFY''_OK; break; fi; " +
                "echo \"Attempt $i: got http=$code, retrying...\"; sleep 5; done");
            await auto.EnterAsync();
            await auto.WaitUntilTextAsync("VERIFY_OK", timeout: TimeSpan.FromMinutes(3));
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
            await auto.TypeAsync("kill %1 2>/dev/null || true");
            await auto.EnterAsync();
            await auto.WaitForAnyPromptAsync(counter);
 
            await auto.CleanupKubernetesDeploymentAsync(counter, clusterName);
        }
        finally
        {
            await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output);
        }
    }
}