File: Publishing\RadiusBicepPublishingContext.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 Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Radius.Publishing.Constructs;
using Aspire.Hosting.Radius.ResourceMapping;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Radius.Publishing;
 
/// <summary>
/// Orchestrates Bicep generation for a Radius environment: walks the
/// <see cref="DistributedApplicationModel"/>, builds an Azure.Provisioning
/// AST via <see cref="RadiusInfrastructureBuilder"/>, compiles to Bicep via
/// <c>Infrastructure.Build().Compile()</c>, and writes <c>app.bicep</c> +
/// <c>bicepconfig.json</c> to the output directory.
/// </summary>
internal sealed class RadiusBicepPublishingContext
{
    private readonly RadiusEnvironmentResource _environment;
 
    internal RadiusBicepPublishingContext(RadiusEnvironmentResource environment)
    {
        _environment = environment;
    }
 
    /// <summary>
    /// Creates a <see cref="PipelineStep"/> that generates Radius Bicep during publish.
    /// </summary>
    internal PipelineStep CreatePipelineStep()
    {
        var step = new PipelineStep
        {
            Name = $"publish-radius-{_environment.Name}",
            Description = $"Publish Radius environment '{_environment.Name}' as Bicep",
            Action = ExecuteAsync
        };
        step.RequiredBy(WellKnownPipelineSteps.Publish);
        return step;
    }
 
    internal async Task ExecuteAsync(PipelineStepContext context)
    {
        var model = context.Model;
        var cancellationToken = context.CancellationToken;
        var logger = context.Logger;
 
        logger.LogInformation(
            "Starting Bicep generation for Radius environment '{EnvironmentName}'",
            _environment.Name);
 
        var task = await context.ReportingStep.CreateTaskAsync(
            $"Generating Bicep for Radius environment '{_environment.Name}'...",
            cancellationToken).ConfigureAwait(false);
 
        try
        {
            // Build the infrastructure AST
            var typeMapper = new ResourceTypeMapper(
                context.Services.GetRequiredService<ILoggerFactory>().CreateLogger<ResourceTypeMapper>());
            var builder = new RadiusInfrastructureBuilder(_environment, model, typeMapper, logger);
 
            var options = await builder.BuildAsync(context.ExecutionContext, cancellationToken).ConfigureAwait(false);
 
            var resourceCount = options.Environments.Count + options.Applications.Count
                + options.RecipePacks.Count + options.ResourceTypeInstances.Count
                + options.Containers.Count;
 
            logger.LogInformation(
                "Built Radius infrastructure AST with {ResourceCount} resources for environment '{EnvironmentName}'",
                resourceCount,
                _environment.Name);
 
            // Log recipe pack summary
            LogRecipePackSummary(options, logger);
 
            // Compile Bicep via Azure.Provisioning SDK pipeline
            var bicepContent = BicepPostProcessor.CompileBicep(options, _environment.Name, logger);
            var bicepConfigContent = BicepPostProcessor.RenderBicepConfig();
 
            // Write output files
            var outputDir = PublishingContextUtils.GetEnvironmentOutputPath(context, _environment);
 
            Directory.CreateDirectory(outputDir);
 
            var bicepPath = Path.Combine(outputDir, "app.bicep");
            var configPath = Path.Combine(outputDir, "bicepconfig.json");
 
            await File.WriteAllTextAsync(bicepPath, bicepContent, cancellationToken).ConfigureAwait(false);
            await File.WriteAllTextAsync(configPath, bicepConfigContent, cancellationToken).ConfigureAwait(false);
 
            // Copy each committed (encrypted) SealedSecret manifest into a per-store subdirectory of
            // the environment output so a cross-machine `deploy` can apply the self-contained
            // artifact when the author's source manifest path is absent. No-op when none declared.
            CopySealedSecretManifests(options, outputDir, logger);
 
            logger.LogInformation(
                "Bicep generation complete for environment '{EnvironmentName}': {BicepPath}",
                _environment.Name,
                bicepPath);
 
            await task.CompleteAsync(
                $"Bicep generated: {bicepPath}",
                cancellationToken: cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            logger.LogError(ex,
                "Bicep generation failed for Radius environment '{EnvironmentName}'",
                _environment.Name);
 
            context.ReportingStep.Log(LogLevel.Error, ex.Message);
            throw;
        }
    }
 
    internal RadiusInfrastructureOptions BuildOptions(DistributedApplicationModel model, ILogger? logger = null)
    {
        logger ??= Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
 
        var typeMapper = new ResourceTypeMapper(logger);
        var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish);
        var builder = new RadiusInfrastructureBuilder(_environment, model, typeMapper, logger);
 
        return builder.BuildAsync(executionContext, CancellationToken.None).GetAwaiter().GetResult();
    }
 
    internal string GenerateBicep(DistributedApplicationModel model, ILogger? logger = null)
    {
        logger ??= Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
        var options = BuildOptions(model, logger);
 
        return BicepPostProcessor.CompileBicep(options, _environment.Name, logger);
    }
 
    private static void LogRecipePackSummary(RadiusInfrastructureOptions options, ILogger logger)
    {
        foreach (var pack in options.RecipePacks.OfType<RadiusRecipePackConstruct>())
        {
            var recipeCount = pack.Recipes.Count;
            var recipeTypes = string.Join(", ", pack.Recipes.Keys);
            logger.LogInformation(
                "Recipe pack '{PackName}' contains {RecipeCount} recipe(s): {RecipeTypes}",
                pack.BicepIdentifier,
                recipeCount,
                recipeTypes);
        }
    }
 
    // Writes each committed (encrypted) SealedSecret manifest into a per-store subdirectory
    // (sealed-secrets/<storeName>/<file>) next to the emitted app.bicep so the published artifact
    // is self-contained and the deploy step can apply it. Namespacing by the unique store name means
    // two stores whose source manifests share a file name (but live in different source directories)
    // cannot silently overwrite each other. The manifest is already encrypted, and we write the exact
    // bytes validated at build time so a later source-file swap cannot change the published artifact.
    // Missing manifests were already rejected at build time (ASPIRERADIUS044).
    private static void CopySealedSecretManifests(RadiusInfrastructureOptions options, string outputDir, ILogger logger)
    {
        // The pipeline output directory is persistent and is NOT cleaned by PipelineOutputService, so
        // a prior publish's manifests for stores that were since removed or renamed would linger and
        // keep obsolete (encrypted) credential material in the published artifact. The `sealed-secrets`
        // subtree is owned entirely by this integration, so clear it wholesale before writing the
        // current set — this runs even when there are no manifests now, so removing the last store also
        // prunes its stale artifact.
        var root = Secrets.SealedSecretArtifact.RootPath(outputDir);
        if (Directory.Exists(root))
        {
            Directory.Delete(root, recursive: true);
        }
 
        foreach (var (storeName, manifest) in options.SealedSecretManifests)
        {
            var destination = Secrets.SealedSecretArtifact.ResolvePath(outputDir, storeName, manifest.SourcePath);
            Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
            File.WriteAllBytes(destination, manifest.Content.ToArray());
            logger.LogInformation("Copied SealedSecret manifest to {Destination}", destination);
        }
    }
 
}