File: Publishing\RadiusDeploymentPipelineStep.cs
Web Access
Project: src\src\Aspire.Hosting.Radius\Aspire.Hosting.Radius.csproj (Aspire.Hosting.Radius)
// 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 ASPIRERADIUS004 // Experimental: ConfigureRadiusInfrastructure escape-hatch construct types are consumed internally by the publisher.
 
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREPIPELINES004
 
using System.ComponentModel;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Radius.Publishing;
 
/// <summary>
/// Pipeline step that deploys a Radius application by invoking <c>rad deploy app.bicep</c>.
/// Depends on the publish step only (not <see cref="WellKnownPipelineSteps.Push"/>)
/// to support kind clusters without a container registry.
/// </summary>
internal sealed class RadiusDeploymentPipelineStep
{
    internal const string RadInstallUrl = "https://docs.radapp.io/installation/";
 
    // Builds the user-facing exception thrown when the `rad` CLI is not found on PATH.
    // Centralized so both this deploy step and RadCredentialRegisterStep emit an identical
    // message that always includes the install link and PATH remediation — a dropped link is
    // then caught by RadCliDetectionTests.RadCliNotFoundException_ContainsInstallLinkAndRemediation exercising this method.
    internal static InvalidOperationException CreateRadCliNotFoundException() =>
        new($"The 'rad' CLI was not found. Please install it from {RadInstallUrl} and ensure it is available on your PATH.");
 
    // The oldest Radius control plane whose schemas match the Bicep this integration emits.
    // v0.60 renamed the `Radius.Core/recipePacks` fields `recipeKind`/`recipeLocation` to
    // `kind`/`source`. The Radius API server *drops* fields it does not recognize rather than
    // rejecting them, so deploying these artifacts to an older control plane silently produces an
    // empty recipe pack: `rad deploy` reports success and every backing resource then fails to
    // resolve a recipe. That failure mode is why this is a hard preflight rather than a README note.
    internal static Version MinimumControlPlaneVersion { get; } = new(0, 60);
 
    internal static InvalidOperationException CreateUnsupportedControlPlaneException(Version controlPlaneVersion, string kubeContext) =>
        new($"The Radius control plane in Kubernetes context '{kubeContext}' is v{controlPlaneVersion}, but this " +
            $"integration requires v{MinimumControlPlaneVersion} or later. Deploying to an older control plane appears " +
            $"to succeed while silently discarding the recipe pack, leaving every backing resource without a recipe. Run " +
            $"'rad upgrade kubernetes' to bring the control plane up to date, then deploy again. " +
            $"Diagnostic: ASPIRERADIUS091.");
 
    /// <summary>
    /// Extracts the control plane version from <c>rad version -o json</c>.
    /// </summary>
    /// <remarks>
    /// The payload is the CLI's <c>CombinedVersionInfo</c>:
    /// <code>
    /// { "cli": { "release": "0.60.0", "version": "v0.60.0", "bicep": "0.35.1", "commit": "abc123" },
    ///   "controlPlane": { "version": "0.60.0", "status": "Installed" } }
    /// </code>
    /// When the cluster is unreachable or Radius is not installed the CLI still exits 0 and reports
    /// <c>{ "version": "Not installed", "status": "Not connected" }</c>, and an edge/dev build
    /// reports a non-numeric version such as <c>edge</c>. Every one of those is "unknown" rather
    /// than "unsupported": the gate must not fail a deploy on a version it could not read.
    /// See https://github.com/radius-project/radius/blob/main/pkg/cli/cmd/version/version.go.
    /// </remarks>
    internal static bool TryParseControlPlaneVersion(string json, out Version? version)
    {
        version = null;
 
        try
        {
            // Read the node as a JsonValue and ask for a string rather than calling
            // GetValue<string>() directly: a `version` that is a number, object or array would make
            // GetValue<string>() throw InvalidOperationException, which would defeat the fail-open
            // policy this method exists to implement.
            if (JsonNode.Parse(json) is not JsonObject root ||
                root["controlPlane"] is not JsonObject controlPlane ||
                controlPlane["version"] is not JsonValue versionValue ||
                !versionValue.TryGetValue<string>(out var rawVersion))
            {
                return false;
            }
 
            // Releases are reported bare (`0.60.0`) but tolerate the `v` prefix the CLI uses for its
            // own version, in case the two ever converge on one format.
            return Version.TryParse(rawVersion.TrimStart('v', 'V'), out version);
        }
        catch (JsonException)
        {
            return false;
        }
    }
 
    /// <summary>
    /// Fails the deploy when the cluster the active <c>rad</c> workspace targets runs a control
    /// plane older than <see cref="MinimumControlPlaneVersion"/>. A version that cannot be
    /// determined is allowed through: the gate exists to convert one specific silent failure into a
    /// loud one, not to become a new way for deploys to fail.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <c>rad version</c> accepts no context flag and leaves the CLI's <c>KubeContext</c> unset, so
    /// on its own it reports the control plane in whatever context happens to be current in the
    /// ambient kubeconfig. That is not necessarily the cluster <c>rad deploy</c> will target: a user
    /// can switch kubectl contexts without switching rad workspaces, which would let this gate
    /// approve an unsupported cluster or reject a supported one.
    /// </para>
    /// <para>
    /// To inspect the correct cluster the gate resolves the active workspace's context through
    /// <see cref="RadiusWorkspaceKubeContext"/> — the same resolver
    /// <see cref="SealedSecretApplyStep"/> uses, including the
    /// <c>ASPIRE_RADIUS_KUBE_CONTEXT</c> override — then flattens that single context into a
    /// throwaway, self-contained kubeconfig with <c>kubectl config view --minify --flatten</c> and
    /// runs the child
    /// <c>rad version</c> against a private home directory holding that kubeconfig. When the
    /// context cannot be resolved the gate is skipped rather than falling back to ambient state,
    /// because a verdict about the wrong cluster is worse than no verdict.
    /// </para>
    /// <para>
    /// Setting <c>KUBECONFIG</c> alone is not sufficient. Parts of the Radius CLI load
    /// <c>clientcmd.RecommendedHomeFile</c> (<c>~/.kube/config</c>) directly instead of going
    /// through client-go's standard loading rules, so they ignore <c>KUBECONFIG</c> entirely — the
    /// same behavior documented in <c>KubernetesDeployTestHelpers.InstallRadiusControlPlaneAsync</c>
    /// and visible in <c>pkg/kubeutil.NewClientConfigFromLocal</c>. Because a supported ambient
    /// cluster could then let an unsupported workspace target pass the gate (or the reverse), the
    /// isolation redirects the home directory the CLI resolves <c>~/.kube/config</c> against, and
    /// sets <c>KUBECONFIG</c> as well for the code paths that do honor it. Both point at the same
    /// minified file, so every loader inside <c>rad</c> reaches the workspace's cluster. The
    /// redirect deliberately keeps kubeconfig <c>exec</c> plugins working by pinning their
    /// credential state back to the real home — see
    /// <see cref="BuildIsolatedKubeConfigEnvironment"/>, which explains why <c>--flatten</c> is not
    /// enough for managed clusters.
    /// See https://github.com/radius-project/radius/blob/main/pkg/kubeutil/config.go.
    /// </para>
    /// </remarks>
    internal static async Task VerifyControlPlaneVersionAsync(ILogger logger, CancellationToken cancellationToken)
    {
        var kubeContext = await RadiusWorkspaceKubeContext.TryResolveAsync(RunAsync, cancellationToken).ConfigureAwait(false);
        await VerifyControlPlaneVersionAsync(logger, kubeContext, RunAsync, cancellationToken).ConfigureAwait(false);
    }
 
    /// <summary>
    /// The gate's decision logic, with the two things that need a real cluster — resolving the
    /// workspace's Kubernetes context and running <c>kubectl</c>/<c>rad</c> — supplied by the
    /// caller. Everything the gate actually decides (which commands it issues, the environment it
    /// isolates them with, how it reads the version, and whether it throws or lets the deploy
    /// through) runs unchanged, so a regression that lets an unsupported control plane pass is
    /// caught here rather than only against a live v0.59 cluster.
    /// </summary>
    internal static async Task VerifyControlPlaneVersionAsync(
        ILogger logger,
        string? kubeContext,
        RadiusCommandRunner runCommand,
        CancellationToken cancellationToken)
    {
        if (kubeContext is null)
        {
            logger.LogDebug(
                "Could not resolve the active Radius workspace's Kubernetes context; skipping the control plane version check.");
            return;
        }
 
        // Created with restrictive permissions because the minified kubeconfig written into it can
        // carry client certificates or tokens for the target cluster.
        var kubeConfigHome = Directory.CreateTempSubdirectory("aspire-radius-kubeconfig");
 
        try
        {
            // Laid out as a real home directory (<home>/.kube/config) rather than an arbitrary
            // path, because the isolation works by redirecting HOME: the parts of `rad` that ignore
            // KUBECONFIG resolve exactly this location.
            var kubeConfigPath = Path.Combine(kubeConfigHome.FullName, ".kube", "config");
            Directory.CreateDirectory(Path.GetDirectoryName(kubeConfigPath)!);
 
            // --flatten inlines file-backed credentials (client certificates, keys, token files)
            // into the exported document. Without it --minify still emits *paths*, and kubeconfig
            // resolves relative paths against the file's own location — so relocating the export
            // under the temporary home would break them, `rad version` would fail, and the gate
            // would skip and let an unsupported control plane through.
            var minified = await runCommand(
                "kubectl",
                ["config", "view", "--raw", "--minify", "--flatten", "--context", kubeContext, "--output", "yaml"],
                environment: null,
                cancellationToken).ConfigureAwait(false);
 
            if (minified is not { ExitCode: 0, StandardOutput.Length: > 0 } minifiedResult)
            {
                logger.LogDebug(
                    "Could not export a kubeconfig for Kubernetes context '{KubeContext}'; skipping the control plane version check.",
                    kubeContext);
                return;
            }
 
            await File.WriteAllTextAsync(kubeConfigPath, minifiedResult.StandardOutput, cancellationToken).ConfigureAwait(false);
 
            var version = await runCommand(
                "rad",
                ["version", "--output", "json"],
                environment: BuildIsolatedKubeConfigEnvironment(
                    kubeConfigHome.FullName,
                    Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
                    Environment.GetEnvironmentVariable),
                cancellationToken).ConfigureAwait(false);
 
            if (version is not { ExitCode: 0 } versionResult)
            {
                logger.LogDebug("Could not run 'rad version --output json'; skipping the control plane version check.");
                return;
            }
 
            if (!TryParseControlPlaneVersion(versionResult.StandardOutput, out var controlPlaneVersion) || controlPlaneVersion is null)
            {
                logger.LogDebug(
                    "Could not determine the Radius control plane version in Kubernetes context '{KubeContext}'; skipping the control plane version check.",
                    kubeContext);
                return;
            }
 
            if (controlPlaneVersion < MinimumControlPlaneVersion)
            {
                throw CreateUnsupportedControlPlaneException(controlPlaneVersion, kubeContext);
            }
 
            logger.LogInformation(
                "Radius control plane v{ControlPlaneVersion} detected in Kubernetes context '{KubeContext}'.",
                controlPlaneVersion,
                kubeContext);
        }
        finally
        {
            try
            {
                kubeConfigHome.Delete(recursive: true);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                // Best effort: a leaked temp kubeconfig must never fail an otherwise valid deploy.
            }
        }
    }
 
    /// <summary>
    /// Builds the environment for the child <c>rad version</c> so every kubeconfig loader inside the
    /// CLI resolves to <paramref name="kubeConfigHome"/>'s <c>.kube/config</c> rather than the
    /// developer's ambient cluster. A <see langword="null"/> value means the variable is removed
    /// from the child's environment.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <c>HOME</c>, <c>USERPROFILE</c> and <c>HOMEDRIVE</c>/<c>HOMEPATH</c> are all set or cleared
    /// because client-go's <c>homedir.HomeDir()</c> — which computes the
    /// <c>clientcmd.RecommendedHomeFile</c> that <c>rad</c> reads directly — consults <c>HOME</c> on
    /// Unix and <c>USERPROFILE</c> then <c>HOMEDRIVE</c>+<c>HOMEPATH</c> on Windows. Leaving any of
    /// them pointing at the real profile would let the ambient kubeconfig win on that platform.
    /// See https://github.com/kubernetes/client-go/blob/master/util/homedir/homedir.go.
    /// </para>
    /// <para>
    /// Redirecting the home directory is what isolates the kubeconfig, but it would also hide the
    /// credential state that kubeconfig <c>exec</c> plugins read, and <c>--flatten</c> does not help
    /// there: it inlines file-backed certificates, keys and token files, but an <c>exec</c> entry is
    /// carried through untouched and still shells out at load time. Managed clusters are configured
    /// exactly that way — <c>kubelogin</c> for AKS, <c>aws eks get-token</c> for EKS,
    /// <c>gke-gcloud-auth-plugin</c> for GKE — and each of those resolves its own credentials under
    /// the home directory. Under a bare redirect they would find an empty home, fail to
    /// authenticate, and take <c>rad version</c> down with them; because the gate fails open on an
    /// unreadable version, it would then silently skip for most managed-cluster users, which is the
    /// population it is most needed for. Each helper's state is therefore pinned back to
    /// <paramref name="realHome"/> through the variable it already documents for relocating it.
    /// </para>
    /// <para>
    /// A variable the caller has already set is left alone: the child inherits the ambient
    /// environment, and an explicit value may deliberately point somewhere other than the home
    /// directory. Values are pinned without probing the file system, because a helper that finds no
    /// state under the real home is no worse off than one pointed at the empty temporary home.
    /// </para>
    /// </remarks>
    internal static Dictionary<string, string?> BuildIsolatedKubeConfigEnvironment(
        string kubeConfigHome,
        string? realHome,
        Func<string, string?> getEnvironmentVariable)
    {
        var environment = new Dictionary<string, string?>(StringComparer.Ordinal)
        {
            ["KUBECONFIG"] = Path.Combine(kubeConfigHome, ".kube", "config"),
            ["HOME"] = kubeConfigHome,
            ["USERPROFILE"] = kubeConfigHome,
            // Cleared rather than split into a drive/path pair: an empty HOMEDRIVE or HOMEPATH makes
            // homedir.HomeDir() skip the combination entirely and fall through to USERPROFILE above.
            ["HOMEDRIVE"] = null,
            ["HOMEPATH"] = null,
        };
 
        if (string.IsNullOrEmpty(realHome))
        {
            return environment;
        }
 
        // https://learn.microsoft.com/cli/azure/azure-cli-configuration - `az`, and so `kubelogin`
        // in its azurecli login mode, keeps its token cache and profile here.
        Pin("AZURE_CONFIG_DIR", Path.Combine(realHome, ".azure"));
 
        // https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html - `aws eks
        // get-token` reads the profile and long-lived credentials from these two files.
        Pin("AWS_CONFIG_FILE", Path.Combine(realHome, ".aws", "config"));
        Pin("AWS_SHARED_CREDENTIALS_FILE", Path.Combine(realHome, ".aws", "credentials"));
 
        // https://cloud.google.com/sdk/docs/configurations - `gke-gcloud-auth-plugin` resolves the
        // active gcloud configuration and its credentials from this directory.
        Pin("CLOUDSDK_CONFIG", Path.Combine(realHome, ".config", "gcloud"));
 
        return environment;
 
        void Pin(string variable, string homeRelativeDefault)
        {
            if (string.IsNullOrEmpty(getEnvironmentVariable(variable)))
            {
                environment[variable] = homeRelativeDefault;
            }
        }
    }
 
    // Returns null when the executable is not on PATH, which every caller here treats as "unknown"
    // rather than "unsupported". A null value in `environment` removes that variable from the child.
    // Internal because it is the production RadiusCommandRunner shared by the deploy-time steps.
    internal static async Task<ProcessRunResult?> RunAsync(
        string fileName,
        string[] arguments,
        IReadOnlyDictionary<string, string?>? environment,
        CancellationToken cancellationToken)
    {
        using var process = new Process();
        process.StartInfo = new ProcessStartInfo
        {
            FileName = fileName,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true,
        };
 
        foreach (var argument in arguments)
        {
            process.StartInfo.ArgumentList.Add(argument);
        }
 
        if (environment is not null)
        {
            foreach (var (key, value) in environment)
            {
                if (value is null)
                {
                    process.StartInfo.Environment.Remove(key);
                }
                else
                {
                    process.StartInfo.Environment[key] = value;
                }
            }
        }
 
        try
        {
            process.Start();
        }
        catch (Win32Exception)
        {
            return null;
        }
 
        // Read both pipes concurrently, and start reading before waiting: draining only stdout
        // lets a chatty stderr fill its pipe buffer, block the child, and hang the wait forever.
        var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
        var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
 
        try
        {
            await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
            var standardOutput = await stdoutTask.ConfigureAwait(false);
            await stderrTask.ConfigureAwait(false);
            return new ProcessRunResult(process.ExitCode, standardOutput);
        }
        catch (OperationCanceledException)
        {
            // Disposing Process only releases the handle, so without this the cancelled deploy
            // would leave the child — and the Helm/Kubernetes work it started — running, which on
            // Windows also keeps the temporary kubeconfig open and defeats its cleanup.
            if (!process.HasExited)
            {
                try
                {
                    process.Kill(entireProcessTree: true);
                }
                catch (Exception ex) when (ex is InvalidOperationException or Win32Exception)
                {
                    // The process exited between the HasExited check and Kill, or termination was
                    // refused. Either way the caller asked for cancellation, so surface that rather
                    // than replacing it with a teardown failure.
                }
            }
 
            await ObserveCleanupAsync(process, stdoutTask, stderrTask).ConfigureAwait(false);
 
            throw;
        }
    }
 
    // A cancelled deploy must stay cancelled: if Kill was refused and the child keeps the pipes
    // open, waiting on it without a bound would hang the caller forever, which is worse than the
    // leaked process this cleanup exists to prevent.
    private static readonly TimeSpan s_cancellationCleanupTimeout = TimeSpan.FromSeconds(5);
 
    // Attempts a bounded reap of the child and observation of both drain tasks before the caller's
    // `using` disposes the Process — tasks still attached to the pipes would otherwise fault against
    // a disposed handle. Cleanup that has not finished within the timeout is deliberately abandoned.
    private static async Task ObserveCleanupAsync(Process process, Task<string> stdoutTask, Task<string> stderrTask)
    {
        var cleanupTask = Task.WhenAll(
            process.WaitForExitAsync(CancellationToken.None),
            stdoutTask,
            stderrTask);
 
        // Attached before the bounded wait: when that wait gives up, this task may still fault
        // afterwards, and an unobserved faulted task would reach TaskScheduler.UnobservedTaskException.
        // Observing the aggregate is enough — WhenAll consumes its antecedents' exceptions to build it.
        _ = cleanupTask.ContinueWith(
            static faulted => _ = faulted.Exception,
            CancellationToken.None,
            TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);
 
        try
        {
            await cleanupTask.WaitAsync(s_cancellationCleanupTimeout).ConfigureAwait(false);
        }
        catch (Exception)
        {
            // Timed out, or a wait failed. Deliberately swallowed: the caller's cancellation is the
            // meaningful outcome, and abandoning the wait is what keeps a child that refused to die
            // from hanging the deploy.
        }
    }
 
    private readonly RadiusEnvironmentResource _environment;
 
    internal RadiusDeploymentPipelineStep(RadiusEnvironmentResource environment)
    {
        _environment = environment;
    }
 
    internal static async Task<bool> DetectRadCliAsync(CancellationToken cancellationToken = default)
    {
        // Honour cancellation up front so a pre-cancelled token surfaces as
        // OperationCanceledException regardless of whether `rad` is present. Otherwise, when
        // `rad` is not on PATH, process.Start() throws Win32Exception first — which the catch
        // filter below swallows into a `false` — and cancellation is never observed.
        cancellationToken.ThrowIfCancellationRequested();
 
        try
        {
            using var process = new Process();
            process.StartInfo = new ProcessStartInfo
            {
                FileName = "rad",
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true,
            };
            // Probe with `rad version --cli`: this prints only the CLI version and exits 0 when
            // `rad` is installed, without contacting the Radius control plane — so detection
            // doesn't depend on a reachable cluster. Note the Radius CLI uses the `version`
            // subcommand, not a global `--version` flag. See https://docs.radapp.io/reference/cli/rad_version/
            process.StartInfo.ArgumentList.Add("version");
            process.StartInfo.ArgumentList.Add("--cli");
            process.Start();
            await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
            return process.ExitCode == 0;
        }
        catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException or OperationCanceledException)
        {
            if (ex is OperationCanceledException)
            {
                throw;
            }
 
            return false;
        }
    }
 
    /// <summary>
    /// Creates a <see cref="PipelineStep"/> that deploys a Radius application via <c>rad deploy</c>.
    /// The step depends on the publish step for this environment (Bicep must be generated first).
    /// </summary>
    internal PipelineStep CreatePipelineStep()
    {
        var step = new PipelineStep
        {
            Name = $"deploy-radius-{_environment.Name}",
            Description = $"Deploy Radius environment '{_environment.Name}' via rad CLI",
            Action = ExecuteAsync
        };
        step.DependsOn($"publish-radius-{_environment.Name}");
        step.RequiredBy(WellKnownPipelineSteps.Deploy);
        step.DependsOn(WellKnownPipelineSteps.DeployPrereq);
        return step;
    }
 
    internal async Task ExecuteAsync(PipelineStepContext context)
    {
        var cancellationToken = context.CancellationToken;
        var logger = context.Logger;
 
        // Detect rad CLI availability
        var radAvailable = await DetectRadCliAsync(cancellationToken).ConfigureAwait(false);
        if (!radAvailable)
        {
            logger.LogError("The 'rad' CLI was not found on PATH. Install it from {InstallUrl}", RadInstallUrl);
            throw CreateRadCliNotFoundException();
        }
 
        logger.LogInformation("rad CLI detected on PATH for environment '{EnvironmentName}'", _environment.Name);
 
        // Resolve the output directory where Bicep was generated
        var outputDir = PublishingContextUtils.GetEnvironmentOutputPath(context, _environment);
        var bicepPath = Path.Combine(outputDir, "app.bicep");
 
        if (!File.Exists(bicepPath))
        {
            logger.LogError("Bicep file not found at {BicepPath}. Ensure the publish step completed successfully.", bicepPath);
            throw new InvalidOperationException(
                $"Bicep file not found at '{bicepPath}'. Ensure the publish step completed successfully before deploying.");
        }
 
        logger.LogInformation("Starting rad deploy with Bicep file '{BicepPath}'", bicepPath);
 
        // Declared outside the try so the finally can always clean up the temp secrets file, even
        // if resolving/writing it (below) fails partway.
        string? parametersFilePath = null;
 
        var deployTask = await context.ReportingStep.CreateTaskAsync(
            $"Deploying Radius environment '{_environment.Name}' via rad deploy...",
            cancellationToken).ConfigureAwait(false);
 
        try
        {
            // Resolve secret/parameter values at deploy time and write them to an owner-only
            // temporary ARM JSON parameters file. This supplies the `@secure()` Bicep params
            // emitted by publish without inlining secrets into the artifact or exposing them on the
            // `rad` command line. Written inside the try so the finally always deletes it.
            parametersFilePath = await WriteDeployParametersFileAsync(logger, cancellationToken).ConfigureAwait(false);
 
            var stderrBuilder = new System.Text.StringBuilder();
 
            using var process = new Process();
            // Use ArgumentList rather than Arguments so the bicep path doesn't need shell-style
            // quoting and is forwarded verbatim — paths containing spaces or special characters
            // (e.g. `C:\Program Files\…`) survive intact on every platform without double-escaping.
            process.StartInfo = new ProcessStartInfo
            {
                FileName = "rad",
                WorkingDirectory = outputDir,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true,
            };
            process.StartInfo.ArgumentList.Add("deploy");
            process.StartInfo.ArgumentList.Add(bicepPath);
            if (parametersFilePath is not null)
            {
                // `@<file>` instructs rad to read an ARM JSON parameter file. Forwarded verbatim via
                // ArgumentList (no shell), so the leading '@' and path survive intact.
                process.StartInfo.ArgumentList.Add("--parameters");
                process.StartInfo.ArgumentList.Add("@" + parametersFilePath);
            }
            logger.LogInformation("Running: rad {Args}", string.Join(' ', process.StartInfo.ArgumentList));
 
            process.OutputDataReceived += (_, e) =>
            {
                if (e.Data is not null)
                {
                    logger.LogInformation("rad (stdout): {Output}", e.Data);
                    context.ReportingStep.Log(LogLevel.Information, e.Data);
                }
            };
 
            process.ErrorDataReceived += (_, e) =>
            {
                if (e.Data is not null)
                {
                    stderrBuilder.AppendLine(e.Data);
                    logger.LogWarning("rad (stderr): {Error}", e.Data);
                    context.ReportingStep.Log(LogLevel.Warning, e.Data);
                }
            };
 
            process.Start();
            process.BeginOutputReadLine();
            process.BeginErrorReadLine();
 
            try
            {
                await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
                if (!process.HasExited)
                {
                    logger.LogWarning("Cancellation requested — terminating rad deploy process for environment '{EnvironmentName}'", _environment.Name);
                    try
                    {
                        process.Kill(entireProcessTree: true);
                    }
                    catch (InvalidOperationException)
                    {
                        // Race: the process exited between HasExited check and Kill. Nothing to do.
                    }
                }
 
                throw;
            }
 
            var exitCode = process.ExitCode;
 
            logger.LogInformation("rad deploy exited with code {ExitCode} for environment '{EnvironmentName}'", exitCode, _environment.Name);
 
            if (exitCode != 0)
            {
                var stderrText = stderrBuilder.ToString().Trim();
                var errorMessage = string.IsNullOrEmpty(stderrText)
                    ? $"rad deploy failed with exit code {exitCode}"
                    : $"rad deploy failed with exit code {exitCode}: {stderrText}";
 
                logger.LogError("rad deploy failed for environment '{EnvironmentName}': {ErrorMessage}", _environment.Name, errorMessage);
                context.ReportingStep.Log(LogLevel.Error, errorMessage);
 
                throw new InvalidOperationException(errorMessage);
            }
 
            await deployTask.CompleteAsync(
                $"Radius deployment complete for '{_environment.Name}'",
                cancellationToken: cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex) when (ex is not InvalidOperationException and not OperationCanceledException)
        {
            logger.LogError(ex, "Unexpected error during rad deploy for environment '{EnvironmentName}'", _environment.Name);
            context.ReportingStep.Log(LogLevel.Error, ex.Message);
            throw;
        }
        finally
        {
            DeleteDeployParametersFile(parametersFilePath, logger);
        }
    }
 
    // Resolves the deploy-time parameter values (from RadiusDeployParametersAnnotation) and writes
    // them to an owner-only temporary ARM JSON parameters file. Returns the file path, or null when
    // there are no parameters to supply. The caller is responsible for deleting the file. Internal so
    // tests can exercise the real file contract (ARM JSON shape + owner-only permissions).
    internal async Task<string?> WriteDeployParametersFileAsync(ILogger logger, CancellationToken cancellationToken)
    {
        if (!_environment.TryGetAnnotationsOfType<RadiusDeployParametersAnnotation>(out var annotations))
        {
            return null;
        }
 
        var annotation = annotations.Last();
        var parameters = annotation.Parameters;
        var rabbitMqUserNames = annotation.RabbitMqUserNames;
        if (parameters.Count == 0)
        {
            return null;
        }
 
        // ARM JSON deployment parameter file:
        //   { "$schema": "...", "contentVersion": "1.0.0.0",
        //     "parameters": { "<bicepParam>": { "value": "<resolved>" } } }
        var parametersNode = new JsonObject();
        foreach (var (identifier, parameter) in parameters)
        {
            var value = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false) ?? string.Empty;
            if (rabbitMqUserNames.TryGetValue(parameter, out var rabbitMqOwners) &&
                string.Equals(value, "guest", StringComparison.Ordinal))
            {
                throw RadiusInfrastructureBuilder.CreateRabbitMqGuestUserNameException(rabbitMqOwners[0]);
            }
 
            parametersNode[identifier] = new JsonObject { ["value"] = value };
        }
 
        var document = new JsonObject
        {
            ["$schema"] = "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
            ["contentVersion"] = "1.0.0.0",
            ["parameters"] = parametersNode,
        };
 
        // CreateTempSubdirectory creates the directory with owner-only permissions (0700) on Unix.
        var directory = Directory.CreateTempSubdirectory("radius-deploy-");
        var filePath = Path.Combine(directory.FullName, "parameters.json");
        try
        {
            await File.WriteAllTextAsync(
                filePath,
                document.ToJsonString(new JsonSerializerOptions { WriteIndented = true }),
                cancellationToken).ConfigureAwait(false);
 
            // Restrict the file itself to the owner (read/write) on Unix; on Windows it inherits the
            // per-user temp directory ACL.
            if (!OperatingSystem.IsWindows())
            {
                File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
            }
        }
        catch
        {
            // Don't leave a partially-written secrets file behind if writing/perm-setting fails
            // (e.g. on cancellation). The caller only cleans up files we successfully return.
            DeleteDeployParametersFile(filePath, logger);
            throw;
        }
 
        logger.LogInformation(
            "Wrote {Count} deploy parameter value(s) to a temporary owner-only file for environment '{EnvironmentName}'",
            parameters.Count, _environment.Name);
 
        return filePath;
    }
 
    // Internal so tests can assert file cleanup.
    internal static void DeleteDeployParametersFile(string? parametersFilePath, ILogger logger)
    {
        if (parametersFilePath is null)
        {
            return;
        }
 
        try
        {
            // Delete the whole temp subdirectory so nothing (file or dir) is left behind.
            var directory = Path.GetDirectoryName(parametersFilePath);
            if (directory is not null && Directory.Exists(directory))
            {
                Directory.Delete(directory, recursive: true);
            }
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            // Best-effort cleanup: a leftover temp parameters file must not fail an otherwise
            // successful deploy.
            logger.LogWarning(ex, "Failed to delete temporary deploy parameters file '{Path}'", parametersFilePath);
        }
    }
}