File: RadiusDotnetProjectDeployTests.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>
/// Deploys two Project V2 .csproj workloads through Radius to KinD, including a
/// real Web-to-API service-discovery request. Radius's explicit-image contract
/// requires the images to be built and pushed before deployment.
/// </summary>
public sealed class RadiusDotnetProjectDeployTests(ITestOutputHelper output)
{
    private const string ProjectName = "RadiusDotnetDeployTest";
    private const string ImageTag = "project-v2";
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task DeployRadiusDotnetProjectsToKind()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
        var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName);
        var clusterName = KubernetesDeployTestHelpers.GenerateUniqueClusterName();
        var radiusNamespace = $"radius-{clusterName[..16]}";
        var response = $"PASSED: Radius Project V2 {clusterName}";
 
        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
        {
            await auto.InstallKindAndHelmAsync(counter);
            await auto.CreateKindClusterWithRegistryAsync(counter, clusterName);
            await auto.InstallRadCliAsync(counter);
            await auto.InstallRadiusControlPlaneAsync(counter, clusterName);
 
            var appHostCode = $$"""
                #pragma warning disable ASPIREDOTNETPROJECT001
                #pragma warning disable ASPIRERADIUS057
                using Aspire.Hosting;
 
                var builder = DistributedApplication.CreateBuilder(args);
                builder.AddRadiusEnvironment("radius").WithNamespace("{{radiusNamespace}}");
 
                var api = builder.AddDotnetProject("api", "../{{ProjectName}}.ApiService/{{ProjectName}}.ApiService.csproj")
                    .WithContainerImage("localhost:5001/{{clusterName}}/api:{{ImageTag}}");
                builder.AddDotnetProject("web", "../{{ProjectName}}.Web/{{ProjectName}}.Web.csproj")
                    .WithContainerImage("localhost:5001/{{clusterName}}/web:{{ImageTag}}")
                    .WithReference(api)
                    .WithExternalHttpEndpoints();
 
                builder.Build().Run();
                """;
 
            var apiProgramCode = $$"""
                var builder = WebApplication.CreateBuilder(args);
                builder.AddServiceDefaults();
                var app = builder.Build();
                app.MapDefaultEndpoints();
                app.MapGet("/test-deployment", () => Results.Text("{{response}}"));
                app.Run();
                """;
 
            await auto.ScaffoldK8sDeployProjectAsync(
                counter, ProjectName, projectDir,
                appHostHostingPackages: ["Aspire.Hosting.Radius", "Aspire.Hosting.Dotnet"],
                apiClientPackages: [],
                appHostCode: appHostCode,
                apiProgramCode: apiProgramCode,
                output: output);
            LocalDeploymentTestHelpers.WriteReferencingWebProgram(projectDir, ProjectName, "api");
 
            // Radius deliberately does not build/push project images. Publish the actual
            // generated projects to archives, load them into the host Docker daemon, then
            // push through its localhost:5001 registry mapping. A direct SDK registry push
            // would instead target the helper container's unrelated loopback interface.
            await auto.RunCommandAsync("mkdir -p images; unset ASPIRE_PLAYGROUND", counter);
            foreach (var (resourceName, projectSuffix) in new[] { ("api", "ApiService"), ("web", "Web") })
            {
                var image = $"{clusterName}/{resourceName}:{ImageTag}";
                await auto.RunCommandAsync(
                    $"dotnet publish {ProjectName}.{projectSuffix}/{ProjectName}.{projectSuffix}.csproj " +
                    "--configuration Release --os linux /t:PublishContainer " +
                    $"-p:ContainerRepository={clusterName}/{resourceName} -p:ContainerImageTag={ImageTag} " +
                    $"-p:ContainerArchiveOutputPath=\"$PWD/images/{resourceName}.tar\"",
                    counter, TimeSpan.FromMinutes(5));
                await auto.RunCommandAsync(
                    $"docker load -i images/{resourceName}.tar && " +
                    $"docker tag {image} localhost:5001/{image} && docker push localhost:5001/{image}",
                    counter, TimeSpan.FromMinutes(3));
            }
 
            // Radius.Core requires a pre-existing application namespace.
            await auto.RunCommandAsync($"kubectl create namespace {radiusNamespace} --context kind-{clusterName}", counter);
            await auto.RunCommandAsync(
                "aspire deploy -o radius-output --non-interactive", counter, TimeSpan.FromMinutes(15));
 
            // Pin the entire deployed application graph to our API and Web, not the existing
            // public-container Radius smoke test or a control-plane pod.
            await auto.RunCommandAsync(
                $"test \"$(kubectl get deployments -n {radiusNamespace} -l radapp.io/application=app " +
                "-o jsonpath='{range .items[*]}{.metadata.labels.radapp\\.io/resource}{\"\\n\"}{end}' | sort)\" = $'api\\nweb'",
                counter);
            foreach (var resource in new[] { "api", "web" })
            {
                await auto.VerifyPodImageAsync(
                    counter, radiusNamespace, $"radapp.io/application=app,radapp.io/resource={resource}",
                    $"localhost:5001/{clusterName}/{resource}:{ImageTag}");
            }
 
            // Radius's container recipe creates {resource}-{resource} Services; exercising
            // web-web and the injected api-api DNS reference proves both endpoint mappings.
            await auto.VerifyForwardedEndpointAsync(
                counter, radiusNamespace, "svc/web-web", 18080, 8080, "/test-deployment", $"WEB->{response}");
        }
        finally
        {
            try
            {
                await auto.CleanupKubernetesDeploymentAsync(counter, clusterName);
            }
            catch (Exception ex)
            {
                output.WriteLine($"In-terminal cleanup failed: {ex.Message}");
            }
 
            await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output);
            foreach (var resource in new[] { "api", "web" })
            {
                await LocalDeploymentTestHelpers.CleanupImageAsync($"localhost:5001/{clusterName}/{resource}:{ImageTag}", output);
                await LocalDeploymentTestHelpers.CleanupImageAsync($"{clusterName}/{resource}:{ImageTag}", output);
            }
        }
    }
}