File: Deployment\ControlPlaneVersionGateTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Radius.Tests\Aspire.Hosting.Radius.Tests.csproj (Aspire.Hosting.Radius.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREPIPELINES001 // Experimental: the pipeline step graph is under test.
 
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Radius.Publishing;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit.Sdk;
 
namespace Aspire.Hosting.Radius.Tests.Deployment;
 
/// <summary>
/// The generated Bicep targets the Radius v0.60 schemas, and an older control plane drops the
/// fields it does not recognize instead of rejecting them — so a v0.59 cluster reports a successful
/// deploy and produces an application whose backing resources have no recipe. The deploy step turns
/// that into a loud failure, which only works if the control plane version is read correctly.
/// </summary>
public class ControlPlaneVersionGateTests
{
    /// <summary>
    /// The shape `rad version -o json` emits, from the CLI's <c>CombinedVersionInfo</c>:
    /// https://github.com/radius-project/radius/blob/main/pkg/cli/cmd/version/version.go.
    /// </summary>
    [Theory]
    [InlineData("""{"cli":{"release":"0.60.0","version":"v0.60.0","bicep":"0.35.1","commit":"abc"},"controlPlane":{"version":"0.60.0","status":"Installed"}}""", "0.60.0")]
    [InlineData("""{"controlPlane":{"version":"0.59.0","status":"Installed"}}""", "0.59.0")]
    [InlineData("""{"controlPlane":{"version":"v0.61.2","status":"Installed"}}""", "0.61.2")]
    public void ControlPlaneVersion_IsReadFromRadVersionJson(string json, string expected)
    {
        Assert.True(RadiusDeploymentPipelineStep.TryParseControlPlaneVersion(json, out var version));
        Assert.Equal(Version.Parse(expected), version);
    }
 
    /// <summary>
    /// Every payload the CLI emits when it cannot report a version has to read as *unknown*, never
    /// as an old version: the gate exists to convert one silent failure into a loud one, and must
    /// not become a new way for an otherwise valid deploy to fail.
    /// </summary>
    [Theory]
    // Cluster unreachable, or Radius not installed on it — the CLI still exits 0.
    [InlineData("""{"controlPlane":{"version":"Not installed","status":"Not connected"}}""")]
    // An edge/dev build of the control plane.
    [InlineData("""{"controlPlane":{"version":"edge","status":"Installed"}}""")]
    // `rad version --cli`, or a CLI predating the combined payload.
    [InlineData("""{"cli":{"release":"0.60.0","version":"v0.60.0"}}""")]
    [InlineData("not json at all")]
    [InlineData("")]
    // `version` present but not a string — must read as unknown rather than throwing.
    [InlineData("""{"controlPlane":{"version":59,"status":"Installed"}}""")]
    [InlineData("""{"controlPlane":{"version":{"major":0},"status":"Installed"}}""")]
    [InlineData("""{"controlPlane":"Installed"}""")]
    public void UnreadableControlPlaneVersion_IsTreatedAsUnknown(string json)
    {
        Assert.False(RadiusDeploymentPipelineStep.TryParseControlPlaneVersion(json, out var version));
        Assert.Null(version);
    }
 
    [Fact]
    public void UnsupportedControlPlaneException_NamesTheVersionsAndTheRemediation()
    {
        var ex = RadiusDeploymentPipelineStep.CreateUnsupportedControlPlaneException(new Version(0, 59), "kind-radius");
 
        Assert.Contains("0.59", ex.Message, StringComparison.Ordinal);
        Assert.Contains(RadiusDeploymentPipelineStep.MinimumControlPlaneVersion.ToString(), ex.Message, StringComparison.Ordinal);
        Assert.Contains("rad upgrade kubernetes", ex.Message, StringComparison.Ordinal);
        Assert.Contains("ASPIRERADIUS091", ex.Message, StringComparison.Ordinal);
        // The context is in the message because the gate deliberately inspects the workspace's
        // cluster rather than the ambient one; naming it makes a surprising verdict diagnosable.
        Assert.Contains("kind-radius", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Setting <c>KUBECONFIG</c> is not enough to aim <c>rad version</c> at the workspace's cluster:
    /// parts of the Radius CLI load <c>clientcmd.RecommendedHomeFile</c> (<c>~/.kube/config</c>)
    /// directly and ignore <c>KUBECONFIG</c>, so a supported ambient cluster could let an
    /// unsupported workspace target pass the gate — exactly the silent broken deployment the gate
    /// exists to prevent. The isolation therefore has to redirect the home directory as well, on
    /// every variable client-go's <c>homedir.HomeDir()</c> consults.
    /// </summary>
    [Fact]
    public void ControlPlaneGate_IsolatesTheHomeKubeconfigRadActuallyReads()
    {
        var home = Path.Combine(Path.GetTempPath(), "aspire-radius-kubeconfig-test");
        var expectedKubeConfig = Path.Combine(home, ".kube", "config");
 
        var environment = RadiusDeploymentPipelineStep.BuildIsolatedKubeConfigEnvironment(
            home,
            realHome: null,
            getEnvironmentVariable: static _ => null);
 
        // KUBECONFIG must name the file *inside* the redirected home, so the loaders that honor it
        // and the loaders that hardcode ~/.kube/config resolve to the same minified kubeconfig.
        Assert.Equal(expectedKubeConfig, environment["KUBECONFIG"]);
        Assert.Equal(home, environment["HOME"]);
        Assert.Equal(home, environment["USERPROFILE"]);
        // Removed, not merely blanked: homedir.HomeDir() only skips the HOMEDRIVE+HOMEPATH pair when
        // either is empty, and a surviving pair would point Windows back at the real profile.
        Assert.Null(environment["HOMEDRIVE"]);
        Assert.Null(environment["HOMEPATH"]);
    }
 
    /// <summary>
    /// <c>--flatten</c> inlines file-backed credentials but carries kubeconfig <c>exec</c> entries
    /// through untouched, and every managed cluster authenticates that way (<c>kubelogin</c> for
    /// AKS, <c>aws eks get-token</c> for EKS, <c>gke-gcloud-auth-plugin</c> for GKE). Those helpers
    /// resolve their own credentials under the home directory, so a bare redirect would leave them
    /// with an empty home, fail the authentication, and fail <c>rad version</c> — and because the
    /// gate fails open on a version it cannot read, it would silently skip for exactly the users it
    /// is most needed for. Each helper's state is pinned back to the real home instead.
    /// </summary>
    [Fact]
    public void ControlPlaneGate_KeepsExecCredentialHelpersPointedAtTheRealHome()
    {
        var home = Path.Combine(Path.GetTempPath(), "aspire-radius-kubeconfig-test");
        var realHome = Path.Combine(Path.GetTempPath(), "aspire-radius-real-home");
 
        var environment = RadiusDeploymentPipelineStep.BuildIsolatedKubeConfigEnvironment(
            home,
            realHome,
            getEnvironmentVariable: static _ => null);
 
        // The kubeconfig stays isolated; only the credential state follows the real home.
        Assert.Equal(Path.Combine(home, ".kube", "config"), environment["KUBECONFIG"]);
        Assert.Equal(home, environment["HOME"]);
 
        Assert.Equal(Path.Combine(realHome, ".azure"), environment["AZURE_CONFIG_DIR"]);
        Assert.Equal(Path.Combine(realHome, ".aws", "config"), environment["AWS_CONFIG_FILE"]);
        Assert.Equal(Path.Combine(realHome, ".aws", "credentials"), environment["AWS_SHARED_CREDENTIALS_FILE"]);
        Assert.Equal(Path.Combine(realHome, ".config", "gcloud"), environment["CLOUDSDK_CONFIG"]);
    }
 
    /// <summary>
    /// A credential-helper location the user has already relocated must win: the child inherits the
    /// ambient environment, and an explicit value may deliberately point outside the home. Only the
    /// unset variables are pinned.
    /// </summary>
    [Fact]
    public void ControlPlaneGate_DoesNotOverrideAnExplicitCredentialHelperLocation()
    {
        var home = Path.Combine(Path.GetTempPath(), "aspire-radius-kubeconfig-test");
        var realHome = Path.Combine(Path.GetTempPath(), "aspire-radius-real-home");
 
        var environment = RadiusDeploymentPipelineStep.BuildIsolatedKubeConfigEnvironment(
            home,
            realHome,
            getEnvironmentVariable: static name => name is "AZURE_CONFIG_DIR" ? "/custom/azure" : null);
 
        Assert.False(environment.ContainsKey("AZURE_CONFIG_DIR"));
        Assert.Equal(Path.Combine(realHome, ".config", "gcloud"), environment["CLOUDSDK_CONFIG"]);
    }
 
    /// <summary>
    /// With no resolvable home there is nothing to pin the helpers to, so the isolation is applied
    /// on its own rather than pointing them at paths built from an empty string.
    /// </summary>
    [Fact]
    public void ControlPlaneGate_WithNoResolvableRealHome_PinsNothing()
    {
        var home = Path.Combine(Path.GetTempPath(), "aspire-radius-kubeconfig-test");
 
        var environment = RadiusDeploymentPipelineStep.BuildIsolatedKubeConfigEnvironment(
            home,
            realHome: string.Empty,
            getEnvironmentVariable: static _ => null);
 
        Assert.Equal(
            new[] { "HOME", "HOMEDRIVE", "HOMEPATH", "KUBECONFIG", "USERPROFILE" },
            environment.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray());
    }
 
    /// <summary>
    /// The gate is a separate step precisely so it runs before anything mutates the cluster or the
    /// machine: registering cloud credentials rewrites installation-global <c>rad</c> state and
    /// applying sealed secrets writes to the cluster. Folding it back into the deploy step, or
    /// dropping one of these edges, would silently restore the ordering bug — which no
    /// parsing-level test can observe.
    /// </summary>
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ControlPlaneGate_RunsBeforeEveryStepThatMutatesTheClusterOrTheMachine(bool withCloudProvider)
    {
        var steps = await CreateEnvironmentStepsAsync("myenv", withCloudProvider);
 
        var gate = Assert.Single(steps, step => step.Name == "verify-radius-control-plane-myenv");
 
        Assert.Contains("deploy-radius-myenv", gate.RequiredBySteps);
        Assert.Contains("apply-sealed-secrets-myenv", gate.RequiredBySteps);
        Assert.Equal(withCloudProvider, gate.RequiredBySteps.Contains("register-radius-credentials-myenv"));
    }
 
    /// <summary>
    /// The gate contacts the cluster, which is deploy-only work: <c>aspire publish</c> must keep
    /// emitting artifacts on a machine with no cluster (or no <c>rad</c>) at all. Depending on
    /// <c>DeployPrereq</c> and being required only by deploy-side steps is what keeps it out of the
    /// publish graph.
    /// </summary>
    [Fact]
    public async Task ControlPlaneGate_IsNotPartOfThePublishGraph()
    {
        var steps = await CreateEnvironmentStepsAsync("myenv", withCloudProvider: false);
 
        var gate = Assert.Single(steps, step => step.Name == "verify-radius-control-plane-myenv");
 
        Assert.Equal([WellKnownPipelineSteps.DeployPrereq], gate.DependsOnSteps);
        Assert.DoesNotContain("publish-radius-myenv", gate.RequiredBySteps);
    }
 
    /// <summary>
    /// The end-to-end gate: a control plane below the minimum has to stop the deploy. The parsing
    /// and message tests above pin the pieces, but only this exercises the path that decides —
    /// issuing the commands, reading the result and throwing — so a regression that mishandles a
    /// successful response cannot pass unnoticed while the deployment tests keep installing a
    /// supported v0.60 control plane.
    /// </summary>
    [Fact]
    public async Task ControlPlaneGate_WithAnUnsupportedControlPlane_FailsTheDeploy()
    {
        var runner = new RecordingCommandRunner(controlPlaneVersion: "0.59.0");
 
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => RadiusDeploymentPipelineStep.VerifyControlPlaneVersionAsync(
                NullLogger.Instance,
                "kind-radius",
                runner.RunAsync,
                CancellationToken.None));
 
        Assert.Contains("ASPIRERADIUS091", ex.Message, StringComparison.Ordinal);
        Assert.Contains("0.59.0", ex.Message, StringComparison.Ordinal);
        Assert.Contains("kind-radius", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// The counterpart: the minimum supported version must proceed. Pinned alongside the failure
    /// case so a change that makes the comparison reject everything — turning the gate into a
    /// blanket deploy failure — is caught here rather than by users on a supported cluster.
    /// </summary>
    [Fact]
    public async Task ControlPlaneGate_WithASupportedControlPlane_AllowsTheDeploy()
    {
        var runner = new RecordingCommandRunner(controlPlaneVersion: "0.60.0");
 
        await RadiusDeploymentPipelineStep.VerifyControlPlaneVersionAsync(
            NullLogger.Instance,
            "kind-radius",
            runner.RunAsync,
            CancellationToken.None);
 
        Assert.Equal(["kubectl", "rad"], runner.Invocations.Select(invocation => invocation.FileName));
    }
 
    /// <summary>
    /// The gate has to inspect the workspace's cluster, not whichever one is ambient. That means
    /// exporting the workspace context and running <c>rad</c> against an isolated home containing
    /// the exported kubeconfig — asserted here on the real invocations rather than on the
    /// environment builder alone, so the wiring between the two cannot silently come apart.
    /// </summary>
    [Fact]
    public async Task ControlPlaneGate_RunsRadAgainstTheExportedWorkspaceKubeconfig()
    {
        var runner = new RecordingCommandRunner(controlPlaneVersion: "0.60.0");
 
        await RadiusDeploymentPipelineStep.VerifyControlPlaneVersionAsync(
            NullLogger.Instance,
            "kind-radius",
            runner.RunAsync,
            CancellationToken.None);
 
        var export = runner.Invocations[0];
        Assert.Equal(["config", "view", "--raw", "--minify", "--flatten", "--context", "kind-radius", "--output", "yaml"], export.Arguments);
        Assert.Null(export.Environment);
 
        var version = runner.Invocations[1];
        Assert.Equal(["version", "--output", "json"], version.Arguments);
        var kubeConfig = Assert.Contains("KUBECONFIG", version.Environment!);
 
        // The export has to be the file `rad` reads, and it has to still be on disk while `rad`
        // runs: the gate deletes the temporary home afterwards, so capturing the contents here is
        // the only point at which that can be verified.
        Assert.Equal(Path.Combine(version.Environment!["HOME"]!, ".kube", "config"), kubeConfig);
        Assert.Equal(RecordingCommandRunner.ExportedKubeConfig, version.KubeConfigContentsAtInvocation);
    }
 
    /// <summary>
    /// The gate fails open on anything it cannot read, because it exists to convert one silent
    /// failure into a loud one and must never become a new way for a valid deploy to fail. Each
    /// case here is a way the environment can be uncooperative rather than unsupported: no
    /// resolvable workspace context, no <c>kubectl</c> on PATH, an export that fails, no <c>rad</c>
    /// on PATH, and a <c>rad</c> that fails.
    /// </summary>
    [Fact]
    public async Task ControlPlaneGate_WithAnUnreadableEnvironment_SkipsInsteadOfFailing()
    {
        await RadiusDeploymentPipelineStep.VerifyControlPlaneVersionAsync(
            NullLogger.Instance, kubeContext: null, ThrowingRunner, CancellationToken.None);
 
        await RunWithAsync((fileName, _) => fileName == "kubectl" ? null : new ProcessRunResult(0, ""));
        await RunWithAsync((fileName, _) => fileName == "kubectl" ? new ProcessRunResult(1, "") : new ProcessRunResult(0, ""));
        await RunWithAsync((fileName, _) => fileName == "kubectl" ? new ProcessRunResult(0, "apiVersion: v1") : null);
        await RunWithAsync((fileName, _) => fileName == "kubectl" ? new ProcessRunResult(0, "apiVersion: v1") : new ProcessRunResult(1, ""));
 
        // A successful `rad` whose payload carries no readable control plane version. Guarded here
        // as well as in the parsing tests because reaching the comparison with an unknown version
        // would fail every deploy on a cluster the gate simply cannot describe.
        await RunWithAsync((fileName, _) => fileName == "kubectl"
            ? new ProcessRunResult(0, "apiVersion: v1")
            : new ProcessRunResult(0, """{"controlPlane":{"version":"edge","status":"Installed"}}"""));
 
        static Task<ProcessRunResult?> ThrowingRunner(
            string fileName, string[] arguments, IReadOnlyDictionary<string, string?>? environment, CancellationToken cancellationToken)
            => throw new XunitException($"The gate ran '{fileName}' with no resolvable workspace context.");
 
        static Task RunWithAsync(Func<string, string[], ProcessRunResult?> run)
            => RadiusDeploymentPipelineStep.VerifyControlPlaneVersionAsync(
                NullLogger.Instance,
                "kind-radius",
                (fileName, arguments, environment, cancellationToken) => Task.FromResult(run(fileName, arguments)),
                CancellationToken.None);
    }
 
    private sealed class RecordingCommandRunner(string controlPlaneVersion)
    {
        internal const string ExportedKubeConfig = "apiVersion: v1\nkind: Config\nclusters: []\n";
 
        public List<Invocation> Invocations { get; } = [];
 
        public Task<ProcessRunResult?> RunAsync(
            string fileName,
            string[] arguments,
            IReadOnlyDictionary<string, string?>? environment,
            CancellationToken _)
        {
            // Read the exported kubeconfig now rather than after the gate returns: the gate deletes
            // the temporary home in its finally block, so this is the only moment it exists.
            var kubeConfigPath = environment?.GetValueOrDefault("KUBECONFIG");
            var kubeConfigContents = kubeConfigPath is not null && File.Exists(kubeConfigPath)
                ? File.ReadAllText(kubeConfigPath)
                : null;
 
            Invocations.Add(new Invocation(fileName, arguments, environment, kubeConfigContents));
 
            return Task.FromResult<ProcessRunResult?>(fileName switch
            {
                "kubectl" => new ProcessRunResult(0, ExportedKubeConfig),
                "rad" => new ProcessRunResult(
                    0,
                    $$$"""{"controlPlane":{"version":"{{{controlPlaneVersion}}}","status":"Installed"}}"""),
                _ => throw new XunitException($"The gate ran an unexpected command: '{fileName}'."),
            });
        }
 
        internal sealed record Invocation(
            string FileName,
            string[] Arguments,
            IReadOnlyDictionary<string, string?>? Environment,
            string? KubeConfigContentsAtInvocation);
    }
 
    private static async Task<List<PipelineStep>> CreateEnvironmentStepsAsync(string environmentName, bool withCloudProvider)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var environment = builder.AddRadiusEnvironment(environmentName);
        if (withCloudProvider)
        {
            environment.WithAzureProvider(
                "00000000-0000-0000-0000-000000000000",
                "rg",
                azure => azure.WithServicePrincipal(
                    "00000000-0000-0000-0000-000000000001",
                    "00000000-0000-0000-0000-000000000002",
                    builder.AddParameter("clientsecret", "secret", secret: true)));
        }
 
        var annotation = Assert.Single(environment.Resource.Annotations.OfType<PipelineStepAnnotation>());
        var steps = await annotation.CreateStepsAsync(new PipelineStepFactoryContext
        {
            PipelineContext = null!,
            Resource = environment.Resource,
        });
 
        return steps.ToList();
    }
}