File: Publishing\BicepPostProcessor.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.
 
using System.Text.RegularExpressions;
using Azure.Provisioning;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Radius.Publishing;
 
/// <summary>
/// Utility methods for Bicep post-processing: identifier sanitization,
/// recipe reference validation, and <c>extension radius</c> injection.
/// </summary>
internal static partial class BicepPostProcessor
{
    /// <summary>
    /// Compiles <see cref="RadiusInfrastructureOptions"/> to Bicep using the
    /// Azure.Provisioning SDK pipeline (<c>Infrastructure.Build().Compile()</c>)
    /// and prepends the <c>extension radius</c> directive.
    /// </summary>
    internal static string CompileBicep(RadiusInfrastructureOptions options, string environmentName, ILogger logger)
    {
        // Validate recipe references before compiling
        ValidateRecipeReferences(options, logger);
 
        // Every construct below is emitted into a single Bicep file, and Bicep symbolic names
        // share one flat namespace — two `resource`/`param` declarations with the same identifier
        // is a BCP028 error. The SDK's Compile() does NOT detect this, so it would silently emit
        // broken Bicep that only fails later at `bicep build` / `rad deploy`. Fail fast here with
        // an actionable diagnostic instead. Runs after ConfigureRadiusInfrastructure callbacks
        // (which can rename/add constructs), so it validates the final emitted namespace.
        ValidateUniqueIdentifiers(options);
 
        var infra = new RadiusResourceInfrastructure(environmentName);
 
        // Top-level Bicep parameters (secret/parameter-backed container env values). Added first so
        // they render as `param` declarations ahead of the resources that reference them.
        foreach (var parameter in options.Parameters)
        {
            infra.Add(parameter);
        }
 
        // Add all constructs in block order:
        // 1. Recipe packs (referenced by environments)
        // 2. UDT environments
        // 3. UDT applications
        // 4. Legacy environments (Applications.Core/environments, fallback types)
        // 5. Legacy applications (Applications.Core/applications)
        // 6. Resource type instances
        // 7. Containers
        foreach (var resource in options.RecipePacks)
        {
            infra.Add(resource);
        }
 
        foreach (var resource in options.Environments)
        {
            infra.Add(resource);
        }
 
        foreach (var resource in options.Applications)
        {
            infra.Add(resource);
        }
 
        foreach (var resource in options.LegacyEnvironments)
        {
            infra.Add(resource);
        }
 
        foreach (var resource in options.LegacyApplications)
        {
            infra.Add(resource);
        }
 
        foreach (var resource in options.ResourceTypeInstances)
        {
            infra.Add(resource);
        }
 
        foreach (var resource in options.Containers)
        {
            infra.Add(resource);
        }
 
        // Secret stores (Applications.Core/secretStores) reference the legacy chain emitted above.
        foreach (var resource in options.SecretStores)
        {
            infra.Add(resource);
        }
 
        // Recipe-parameter / inline-secret backed secure `param` declarations.
        foreach (var parameter in options.RecipeParameters.Values)
        {
            infra.Add(parameter);
        }
 
        var plan = infra.Build(new ProvisioningBuildOptions());
        var compiled = plan.Compile();
 
        // The SDK generates a single file named "{infraName}.bicep"
        var bicepContent = compiled.Values.First().ToString();
 
        // Prepend `extension radius` directive (not natively supported by the SDK)
        var withExtension = $"extension radius\n\n{bicepContent}";
 
        // Ensure every Radius.Core/recipePacks resource has a `properties` block.
        // Azure.Provisioning's BicepDictionary serializer omits empty dictionaries,
        // so a recipe pack with no entries renders as `name: 'default' }` only,
        // which fails Bicep BCP035 because the recipePacks UDT requires `properties`.
        return EnsureRecipePackProperties(withExtension);
    }
 
    /// <summary>
    /// Injects <c>properties: { recipes: {} }</c> into <c>Radius.Core/recipePacks</c>
    /// resource blocks that lack a <c>properties:</c> key. See <see cref="CompileBicep"/>
    /// for the underlying SDK behaviour this works around.
    /// </summary>
    internal static string EnsureRecipePackProperties(string bicep)
    {
        return RecipePackWithoutProperties().Replace(bicep, m =>
        {
            var indent = m.Groups["indent"].Value;
            var body = m.Value.TrimEnd();
            if (body.EndsWith('}'))
            {
                body = body[..^1].TrimEnd();
            }
            return $"{body}\n{indent}  properties: {{\n{indent}    recipes: {{}}\n{indent}  }}\n{indent}}}";
        });
    }
 
    [GeneratedRegex(
        @"(?<indent>[ \t]*)resource[ \t]+\w+[ \t]+'Radius\.Core/recipePacks@[^']+'[ \t]*=[ \t]*\{[^{}]*?\n[ \t]*\}",
        RegexOptions.Singleline)]
    private static partial Regex RecipePackWithoutProperties();
 
    /// <summary>
    /// Generates the companion bicepconfig.json content.
    /// </summary>
    internal static string RenderBicepConfig()
    {
        // The radius extension version is pinned (not `:latest`) so an upstream
        // tag move can't change the schema this AppHost emits against. See
        // RadiusBicepExtension for the version pin policy.
        // No aws extension is registered here — this integration does not emit
        // AWS resources, so listing it would pull a large extension package
        // for nothing and produce confusing "unknown resource" diagnostics if
        // future code accidentally referenced an AWS type.
        return $$"""
            {
                "experimentalFeaturesEnabled": {
                    "extensibility": true
                },
                "extensions": {
                    "radius": "{{RadiusBicepExtension.Reference}}"
                }
            }
            """;
    }
 
    /// <summary>
    /// Sanitizes a resource name into a valid Bicep identifier.
    /// </summary>
    internal static string SanitizeIdentifier(string name)
    {
        if (string.IsNullOrEmpty(name))
        {
            return "resource";
        }
 
        // Replace non-alphanumeric/underscore characters with underscores
        var sanitized = InvalidIdentifierChars().Replace(name, "_");
 
        // Names starting with a digit are prefixed with 'r'
        if (char.IsDigit(sanitized[0]))
        {
            sanitized = "r" + sanitized;
        }
 
        // "radius" collides with the `extension radius` directive
        if (string.Equals(sanitized, "radius", StringComparison.OrdinalIgnoreCase))
        {
            sanitized = "radiusenv";
        }
 
        return sanitized;
    }
 
    /// <summary>
    /// Converts a .NET value to a type compatible with <c>BicepValue</c> assignment.
    /// </summary>
    internal static object ToBicepLiteral(object value)
    {
        return value switch
        {
            string or int or long or bool or double or float or decimal => value,
            IDictionary<string, object> dictionary => ToBicepObject(dictionary),
            System.Collections.IEnumerable sequence => ToBicepArray(sequence),
            _ => throw new NotSupportedException(
                $"Bicep parameter type '{value.GetType().Name}' is not supported. " +
                $"Supported types: string, int, long, double, float, decimal, bool, " +
                $"arrays/enumerables, and string-keyed objects.")
        };
    }
 
    /// <summary>
    /// Recursively converts a string-keyed object to a Bicep object literal,
    /// preserving the Bicep type of each value.
    /// </summary>
    internal static BicepDictionary<object> ToBicepObject(IDictionary<string, object> dictionary)
    {
        var result = new BicepDictionary<object>();
        var sink = (IDictionary<string, IBicepValue>)result;
        foreach (var (key, value) in dictionary)
        {
            sink[key] = ToBicepValue(value);
        }
 
        return result;
    }
 
    /// <summary>
    /// Recursively converts an enumerable to a Bicep array literal, preserving the
    /// Bicep type of each element. Strings are handled as scalars before
    /// reaching this method.
    /// </summary>
    private static BicepList<object> ToBicepArray(System.Collections.IEnumerable sequence)
    {
        var result = new BicepList<object>();
        foreach (var element in sequence)
        {
            result.Add(ToBicepArrayElement(element));
        }
 
        return result;
    }
 
    /// <summary>
    /// Converts a single array element to a <see cref="BicepValue{T}"/>. Scalar elements
    /// are wrapped directly. Nested arrays/objects as array elements are not supported.
    /// </summary>
    private static BicepValue<object> ToBicepArrayElement(object? element)
    {
        if (element is null)
        {
            throw new NotSupportedException("Null recipe parameter array elements are not supported.");
        }
 
        var literal = ToBicepLiteral(element);
        if (literal is BicepDictionary<object> or BicepList<object>)
        {
            throw new NotSupportedException(
                "Nested arrays or objects as array elements are not supported in recipe parameters. " +
                "Use a string-keyed object whose values are arrays/objects instead.");
        }
 
        return new BicepValue<object>(literal);
    }
 
    /// <summary>
    /// Converts a single value to an <see cref="IBicepValue"/>, recursing into nested
    /// arrays and objects so their element/Bicep types are preserved. Nested collections
    /// are returned directly (they are <see cref="IBicepValue"/>); scalars are wrapped.
    /// </summary>
    internal static IBicepValue ToBicepValue(object? value)
    {
        if (value is null)
        {
            throw new NotSupportedException("Null recipe parameter values are not supported.");
        }
 
        // Pass through values that are already Bicep AST nodes (e.g. a `<resource>.id`
        // reference expression used by recipeConfig secret-store references).
        if (value is IBicepValue alreadyBicep)
        {
            return alreadyBicep;
        }
 
        if (value is Azure.Provisioning.Expressions.BicepExpression expression)
        {
            return new BicepValue<object>(expression);
        }
 
        return ToBicepLiteral(value) switch
        {
            BicepDictionary<object> nestedObject => nestedObject,
            BicepList<object> nestedArray => nestedArray,
            var scalar => new BicepValue<object>(scalar)
        };
    }
 
    private static void ValidateUniqueIdentifiers(RadiusInfrastructureOptions options)
    {
        // Maps each claimed Bicep identifier to a human-readable description of the construct that
        // first claimed it, so the diagnostic can name both sides of a collision. Ordinal because
        // Bicep identifiers are case-sensitive.
        var seen = new Dictionary<string, string>(StringComparer.Ordinal);
 
        void Register(string identifier, string description)
        {
            if (seen.TryGetValue(identifier, out var existing))
            {
                throw new InvalidOperationException(
                    $"Two Radius constructs emit the same Bicep identifier '{identifier}' ({existing} and {description}). " +
                    "Bicep symbolic names share a single flat namespace, so every emitted resource and parameter must " +
                    "have a distinct identifier. Rename the conflicting resource — note that resource names are sanitized " +
                    "to Bicep identifiers (e.g. 'my-x' and 'my.x' both become 'my_x'), and that the publisher reserves the " +
                    "identifiers 'app', 'app_legacy', and 'recipepack' for its synthesized constructs. Diagnostic: ASPIRERADIUS056.");
            }
 
            seen[identifier] = description;
        }
 
        // Enumerate every collection added to the flat namespace in CompileBicep, in the same order.
        foreach (var pack in options.RecipePacks)
        {
            Register(pack.BicepIdentifier, "a recipe pack");
        }
 
        foreach (var environment in options.Environments)
        {
            Register(environment.BicepIdentifier, "a Radius environment");
        }
 
        foreach (var application in options.Applications)
        {
            Register(application.BicepIdentifier, "a Radius application");
        }
 
        foreach (var environment in options.LegacyEnvironments)
        {
            Register(environment.BicepIdentifier, "a legacy environment");
        }
 
        foreach (var application in options.LegacyApplications)
        {
            Register(application.BicepIdentifier, "a legacy application");
        }
 
        foreach (var instance in options.ResourceTypeInstances)
        {
            Register(instance.BicepIdentifier, "a resource type instance");
        }
 
        foreach (var container in options.Containers)
        {
            Register(container.BicepIdentifier, "a container workload");
        }
 
        // Secret/parameter-backed env values become top-level `param` declarations, which share the
        // same flat Bicep symbol namespace. Registering them here turns an identifier collision
        // (e.g. two Aspire parameters whose names both sanitize to the same Bicep identifier) into a
        // clear ASPIRERADIUS056 error instead of an opaque duplicate-declaration Bicep compile
        // failure or a silently clobbered deploy-parameter value.
        foreach (var parameter in options.Parameters)
        {
            Register(parameter.BicepIdentifier, "a secret/parameter env value");
        }
 
        // Secret stores and recipe-parameter/inline-secret `param`s share the same flat symbol
        // namespace; register them so an identifier collision surfaces as a clear ASPIRERADIUS056
        // error rather than an opaque duplicate-declaration Bicep compile failure.
        foreach (var store in options.SecretStores)
        {
            Register(store.BicepIdentifier, "a secret store");
        }
 
        foreach (var parameter in options.RecipeParameters.Values)
        {
            Register(parameter.BicepIdentifier, "a recipe/secret parameter value");
        }
 
    }
 
    private static void ValidateRecipeReferences(RadiusInfrastructureOptions options, ILogger logger)
    {
        // Collect all recipe type keys from both UDT recipe packs and legacy
        // environments (which carry recipes inline).
        var registeredTypes = new HashSet<string>(StringComparer.Ordinal);
        foreach (var pack in options.RecipePacks)
        {
            foreach (var key in pack.Recipes.Keys)
            {
                registeredTypes.Add(key);
            }
        }
 
        foreach (var legacyEnv in options.LegacyEnvironments)
        {
            foreach (var key in legacyEnv.Recipes.Keys)
            {
                registeredTypes.Add(key);
            }
        }
 
        // Check resource type instances for recipes referencing unregistered types
        foreach (var instance in options.ResourceTypeInstances)
        {
            if (!instance.RecipeName.IsEmpty && !registeredTypes.Contains(instance.RadiusType))
            {
                logger.LogWarning(
                    "Resource '{ResourceName}' references a recipe but resource type '{ResourceType}' is not registered in any recipe pack or legacy environment.",
                    instance.BicepIdentifier,
                    instance.RadiusType);
            }
        }
    }
 
    [GeneratedRegex("[^a-zA-Z0-9_]")]
    private static partial Regex InvalidIdentifierChars();
}