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.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.");
 
    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 parameters = annotations.Last().Parameters;
        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;
            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);
        }
    }
}