File: AzureBicepResource.cs
Web Access
Project: src\src\Aspire.Hosting.Azure\Aspire.Hosting.Azure.csproj (Aspire.Hosting.Azure)
// 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 ASPIREPIPELINES002
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREAZURE001
 
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure.Provisioning;
using Aspire.Hosting.Azure.Utils;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Azure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Azure;
 
/// <summary>
/// Represents an Azure Bicep resource.
/// </summary>
public class AzureBicepResource : Resource, IAzureResource, IResourceWithParameters
{
    /// <summary>
    /// Initializes a new instance of the <see cref="AzureBicepResource"/> class.
    /// </summary>
    /// <param name="name">Name of the resource. This will be the name of the deployment.</param>
    /// <param name="templateFile">The path to the bicep file.</param>
    /// <param name="templateString">A bicep snippet.</param>
    /// <param name="templateResourceName">The name of an embedded resource that represents the bicep file.</param>
    public AzureBicepResource(string name, string? templateFile = null, string? templateString = null, string? templateResourceName = null) : base(name)
    {
        TemplateFile = templateFile;
        TemplateString = templateString;
        TemplateResourceName = templateResourceName;
 
        Annotations.Add(new ManifestPublishingCallbackAnnotation(WriteToManifest));
 
        // Add pipeline step annotation to provision this bicep resource
        Annotations.Add(new PipelineStepAnnotation((factoryContext) =>
        {
            // Initialize the provisioning task completion source during step creation
            ProvisioningTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
 
            var provisionStep = new PipelineStep
            {
                Name = $"provision-{name}",
                Action = async ctx => await ProvisionAzureBicepResourceAsync(ctx, this).ConfigureAwait(false),
                Tags = [WellKnownPipelineTags.ProvisionInfrastructure]
            };
            provisionStep.RequiredBy(AzureEnvironmentResource.ProvisionInfrastructureStepName);
            provisionStep.DependsOn(AzureEnvironmentResource.CreateProvisioningContextStepName);
 
            return provisionStep;
        }));
 
        // Add pipeline configuration annotation to set up dependencies between Azure resources
        Annotations.Add(new PipelineConfigurationAnnotation(context =>
        {
            // Force evaluation of the Bicep template to ensure parameters are expanded
            _ = GetBicepTemplateString();
 
            // Find Azure resource references in the parameters
            var azureReferences = new HashSet<IAzureResource>();
            foreach (var parameter in Parameters)
            {
                ProcessAzureReferences(azureReferences, parameter.Value);
            }
 
            foreach (var reference in References)
            {
                ProcessAzureReferences(azureReferences, reference);
            }
 
            // Get the provision steps for this resource
            var provisionSteps = context.GetSteps(this, WellKnownPipelineTags.ProvisionInfrastructure);
 
            // Make this resource's provision steps depend on the provision steps of referenced Azure resources
            foreach (var azureReference in azureReferences)
            {
                var dependencySteps = context.GetSteps(azureReference, WellKnownPipelineTags.ProvisionInfrastructure);
                provisionSteps.DependsOn(dependencySteps);
            }
        }));
    }
 
    internal string? TemplateFile { get; }
 
    internal string? TemplateString { get; set; }
 
    internal string? TemplateResourceName { get; }
 
    /// <summary>
    /// Parameters that will be passed into the bicep template.
    /// </summary>
    public Dictionary<string, object?> Parameters { get; } = [];
 
    /// <summary>
    /// References to other objects that may contain Azure resource references.
    /// </summary>
    public HashSet<object> References { get; } = [];
 
    IDictionary<string, object?> IResourceWithParameters.Parameters => Parameters;
 
    /// <summary>
    /// Outputs that will be generated by the bicep template.
    /// </summary>
    public Dictionary<string, object?> Outputs { get; } = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
 
    /// <summary>
    /// Secret outputs that will be generated by the bicep template.
    /// </summary>
    public Dictionary<string, string?> SecretOutputs { get; } = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
 
    /// <summary>
    /// The task completion source for the provisioning operation.
    /// </summary>
    public TaskCompletionSource? ProvisioningTaskCompletionSource { get; set; }
 
    /// <summary>
    /// The scope of the resource that will be configured in the main Bicep file.
    /// </summary>
    /// <remarks>
    /// The property is used to configure the Bicep scope that is emitted
    /// in the module definition for a given resource. It is
    /// only emitted for schema versions azure.bicep.v1.
    /// </remarks>
    public AzureBicepResourceScope? Scope { get; set; }
 
    /// <summary>
    /// For testing purposes only.
    /// </summary>
    internal string? TempDirectory { get; set; }
 
    /// <summary>
    /// Gets the path to the bicep file. If the template is a string or embedded resource, it will be written to a temporary file.
    /// </summary>
    /// <param name="directory">The directory where the bicep file will be written to (if it's a temporary file)</param>
    /// <param name="deleteTemporaryFileOnDispose">A boolean that determines if the file should be deleted on disposal of the <see cref="BicepTemplateFile"/>.</param>
    /// <returns>A <see cref="BicepTemplateFile"/> that represents the bicep file.</returns>
    /// <exception cref="InvalidOperationException"></exception>
    public virtual BicepTemplateFile GetBicepTemplateFile(string? directory = null, bool deleteTemporaryFileOnDispose = true)
    {
        // Throw if multiple template sources are specified
        if (TemplateFile is not null && (TemplateString is not null || TemplateResourceName is not null))
        {
            throw new InvalidOperationException("Multiple template sources are specified.");
        }
 
        var path = TemplateFile;
        var isTempFile = false;
 
        if (path is null)
        {
            isTempFile = directory is null;
 
            path = TempDirectory is null
                ? Path.Combine(directory ?? Directory.CreateTempSubdirectory("aspire").FullName, $"{Name.ToLowerInvariant()}.module.bicep")
                : Path.Combine(TempDirectory, $"{Name.ToLowerInvariant()}.module.bicep");
 
            if (TemplateResourceName is null)
            {
                // REVIEW: Consider making users specify a name for the template
                File.WriteAllText(path, TemplateString);
            }
            else
            {
                path = directory is null
                    ? path
                    : Path.Combine(directory, $"{TemplateResourceName.ToLowerInvariant()}");
 
                // REVIEW: We should allow the user to specify the assembly where the resources reside.
                using var resourceStream = GetType().Assembly.GetManifestResourceStream(TemplateResourceName)
                    ?? throw new InvalidOperationException($"Could not find resource {TemplateResourceName} in assembly {GetType().Assembly}");
 
                using var fs = File.OpenWrite(path);
                resourceStream.CopyTo(fs);
            }
        }
 
        var targetPath = directory is not null ? Path.Combine(directory, path) : path;
        return new(targetPath, isTempFile && deleteTemporaryFileOnDispose);
    }
 
    /// <summary>
    /// Get the bicep template as a string. Does not write to disk.
    /// </summary>
    public virtual string GetBicepTemplateString()
    {
        if (TemplateString is not null)
        {
            return TemplateString;
        }
 
        if (TemplateResourceName is not null)
        {
            using var resourceStream = GetType().Assembly.GetManifestResourceStream(TemplateResourceName)
                ?? throw new InvalidOperationException($"Could not find resource {TemplateResourceName} in assembly {GetType().Assembly}");
 
            using var reader = new StreamReader(resourceStream, Encoding.UTF8);
            return reader.ReadToEnd();
        }
 
        if (TemplateFile is null)
        {
            throw new InvalidOperationException("No template source specified.");
        }
 
        return File.ReadAllText(TemplateFile);
    }
 
    /// <summary>
    /// Writes the resource to the manifest.
    /// </summary>
    /// <param name="context">The <see cref="ManifestPublishingContext"/>.</param>
    public virtual void WriteToManifest(ManifestPublishingContext context)
    {
        using var template = GetBicepTemplateFile(Path.GetDirectoryName(context.ManifestPath), deleteTemporaryFileOnDispose: false);
        var path = template.Path;
 
        if (Scope is null)
        {
            context.Writer.WriteString("type", "azure.bicep.v0");
        }
        else
        {
            context.Writer.WriteString("type", "azure.bicep.v1");
        }
 
        // Write a connection string if it exists.
        context.WriteConnectionString(this);
 
        // REVIEW: Consider multiple files.
        context.Writer.WriteString("path", context.GetManifestRelativePath(path));
 
        if (Parameters.Count > 0)
        {
            context.Writer.WriteStartObject("params");
            foreach (var input in Parameters)
            {
                // Used for deferred evaluation of parameter.
                object? inputValue = input.Value is Func<object?> f ? f() : input.Value;
 
                if (inputValue is JsonNode || inputValue is IEnumerable<string>)
                {
                    context.Writer.WritePropertyName(input.Key);
                    // Write JSON objects to the manifest for JSON node parameters
                    JsonSerializer.Serialize(context.Writer, inputValue);
                    continue;
                }
 
                var value = inputValue switch
                {
                    IManifestExpressionProvider output => output.ValueExpression,
                    object obj => obj.ToString(),
                    null => ""
                };
 
                context.Writer.WriteString(input.Key, value);
 
                context.TryAddDependentResources(input.Value);
            }
            context.Writer.WriteEndObject();
        }
 
        if (Scope is not null)
        {
            context.Writer.WriteStartObject("scope");
            var resourceGroup = Scope.ResourceGroup switch
            {
                IManifestExpressionProvider output => output.ValueExpression,
                object obj => obj.ToString(),
                null => ""
            };
            context.Writer.WriteString("resourceGroup", resourceGroup);
            context.Writer.WriteEndObject();
        }
    }
 
    /// <summary>
    /// Provisions this Azure Bicep resource using the bicep provisioner.
    /// </summary>
    /// <param name="context">The pipeline step context.</param>
    /// <param name="resource">The Azure Bicep resource to provision.</param>
    private static async Task ProvisionAzureBicepResourceAsync(PipelineStepContext context, AzureBicepResource resource)
    {
        // Skip if the resource is excluded from publish
        if (resource.IsExcludedFromPublish())
        {
            context.Logger.LogDebug("Resource {ResourceName} is excluded from publish. Skipping provisioning.", resource.Name);
            return;
        }
 
        // Skip if already provisioned
        if (resource.ProvisioningTaskCompletionSource != null &&
            resource.ProvisioningTaskCompletionSource.Task.IsCompleted)
        {
            context.Logger.LogDebug("Resource {ResourceName} is already provisioned. Skipping provisioning.", resource.Name);
            return;
        }
 
        var bicepProvisioner = context.Services.GetRequiredService<IBicepProvisioner>();
        var configuration = context.Services.GetRequiredService<IConfiguration>();
 
        // Find the AzureEnvironmentResource from the application model
        var azureEnvironment = context.Model.Resources.OfType<AzureEnvironmentResource>().FirstOrDefault();
        if (azureEnvironment == null)
        {
            throw new InvalidOperationException("AzureEnvironmentResource must be present in the application model.");
        }
 
        var provisioningContext = await azureEnvironment.ProvisioningContextTask.Task.ConfigureAwait(false);
 
        var resourceTask = await context.ReportingStep
            .CreateTaskAsync($"Deploying **{resource.Name}**", context.CancellationToken)
            .ConfigureAwait(false);
 
        await using (resourceTask.ConfigureAwait(false))
        {
            try
            {
                if (await bicepProvisioner.ConfigureResourceAsync(
                    configuration, resource, context.CancellationToken).ConfigureAwait(false))
                {
                    resource.ProvisioningTaskCompletionSource?.TrySetResult();
                    await resourceTask.CompleteAsync(
                        $"Using existing deployment for **{resource.Name}**",
                        CompletionState.Completed,
                        context.CancellationToken).ConfigureAwait(false);
                }
                else
                {
                    await bicepProvisioner.GetOrCreateResourceAsync(
                        resource, provisioningContext, context.CancellationToken)
                        .ConfigureAwait(false);
                    resource.ProvisioningTaskCompletionSource?.TrySetResult();
                    await resourceTask.CompleteAsync(
                        $"Successfully provisioned **{resource.Name}**",
                        CompletionState.Completed,
                        context.CancellationToken).ConfigureAwait(false);
                }
            }
            catch (Exception ex)
            {
                var errorMessage = ex switch
                {
                    RequestFailedException requestEx =>
                        $"Deployment failed: {ExtractDetailedErrorMessage(requestEx)}",
                    _ => $"Deployment failed: {ex.Message}"
                };
                resource.ProvisioningTaskCompletionSource?.TrySetException(ex);
                await resourceTask.CompleteAsync(
                    $"Failed to provision **{resource.Name}**: {errorMessage}",
                    CompletionState.CompletedWithError,
                    context.CancellationToken).ConfigureAwait(false);
                throw;
            }
        }
    }
 
    /// <summary>
    /// Extracts detailed error information from Azure RequestFailedException responses.
    /// Parses the following JSON error structures:
    /// 1. Standard Azure error format: { "error": { "code": "...", "message": "...", "details": [...] } }
    /// 2. Deployment-specific error format: { "properties": { "error": { "code": "...", "message": "..." } } }
    /// 3. Nested error details with recursive parsing for deeply nested error hierarchies
    /// </summary>
    /// <param name="requestEx">The Azure RequestFailedException containing the error response</param>
    /// <returns>The most specific error message found, or the original exception message if parsing fails</returns>
    private static string ExtractDetailedErrorMessage(RequestFailedException requestEx)
    {
        try
        {
            var response = requestEx.GetRawResponse();
            if (response?.Content is not null)
            {
                var responseContent = response.Content.ToString();
                if (!string.IsNullOrEmpty(responseContent))
                {
                    if (JsonNode.Parse(responseContent) is JsonObject responseObj)
                    {
                        if (responseObj["error"] is JsonObject errorObj)
                        {
                            var code = errorObj["code"]?.ToString();
                            var message = errorObj["message"]?.ToString();
 
                            if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(message))
                            {
                                if (errorObj["details"] is JsonArray detailsArray && detailsArray.Count > 0)
                                {
                                    var deepestErrorMessage = ExtractDeepestErrorMessage(detailsArray);
                                    if (!string.IsNullOrEmpty(deepestErrorMessage))
                                    {
                                        return deepestErrorMessage;
                                    }
                                }
 
                                return $"{code}: {message}";
                            }
                        }
 
                        if (responseObj["properties"]?["error"] is JsonObject deploymentErrorObj)
                        {
                            var code = deploymentErrorObj["code"]?.ToString();
                            var message = deploymentErrorObj["message"]?.ToString();
 
                            if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(message))
                            {
                                return $"{code}: {message}";
                            }
                        }
                    }
                }
            }
        }
        catch (JsonException) { }
 
        return requestEx.Message;
    }
 
    private static string ExtractDeepestErrorMessage(JsonArray detailsArray)
    {
        foreach (var detail in detailsArray)
        {
            if (detail is JsonObject detailObj)
            {
                var detailCode = detailObj["code"]?.ToString();
                var detailMessage = detailObj["message"]?.ToString();
 
                if (detailObj["details"] is JsonArray nestedDetailsArray && nestedDetailsArray.Count > 0)
                {
                    var deeperMessage = ExtractDeepestErrorMessage(nestedDetailsArray);
                    if (!string.IsNullOrEmpty(deeperMessage))
                    {
                        return deeperMessage;
                    }
                }
 
                if (!string.IsNullOrEmpty(detailCode) && !string.IsNullOrEmpty(detailMessage))
                {
                    return $"{detailCode}: {detailMessage}";
                }
            }
        }
 
        return string.Empty;
    }
 
    /// <summary>
    /// Known parameters that will be filled in automatically by the host environment.
    /// </summary>
    public static class KnownParameters
    {
        private const string UserPrincipalIdConst = "userPrincipalId";
        private const string PrincipalIdConst = "principalId";
        private const string PrincipalNameConst = "principalName";
        private const string PrincipalTypeConst = "principalType";
        private const string KeyVaultNameConst = "keyVaultName";
        private const string LocationConst = "location";
        private const string LogAnalyticsWorkspaceIdConst = "logAnalyticsWorkspaceId";
 
        /// <summary>
        /// The principal id of the current user or managed identity.
        /// </summary>
        public static readonly string PrincipalId = PrincipalIdConst;
 
        /// <summary>
        /// The principal name of the current user or managed identity.
        /// </summary>
        public static readonly string PrincipalName = PrincipalNameConst;
 
        /// <summary>
        /// The principal type of the current user or managed identity. Either 'User' or 'ServicePrincipal'.
        /// </summary>
        public static readonly string PrincipalType = PrincipalTypeConst;
 
        /// <summary>
        /// The principal id of the user doing the deployment.
        /// </summary>
        /// <remarks>Referred as Deployment principal in ARM documentation.</remarks>
        public static readonly string UserPrincipalId = UserPrincipalIdConst;
 
        /// <summary>
        /// The name of the key vault resource used to store secret outputs.
        /// </summary>
        [Obsolete("KnownParameters.KeyVaultName is deprecated. Use an AzureKeyVaultResource instead.")]
        public static readonly string KeyVaultName = KeyVaultNameConst;
 
        /// <summary>
        /// The location of the resource. This is required for all resources.
        /// </summary>
        public static readonly string Location = LocationConst;
 
        /// <summary>
        /// The resource id of the log analytics workspace.
        /// </summary>
        [Obsolete("KnownParameters.LogAnalyticsWorkspaceId is deprecated. Use an AzureLogAnalyticsWorkspaceResource instead.")]
        public static readonly string LogAnalyticsWorkspaceId = LogAnalyticsWorkspaceIdConst;
 
        internal static bool IsKnownParameterName(string name) =>
            name is PrincipalIdConst or UserPrincipalIdConst or PrincipalNameConst or PrincipalTypeConst or KeyVaultNameConst or LocationConst or LogAnalyticsWorkspaceIdConst;
 
    }
 
    /// <summary>
    /// Processes a value to extract Azure resource references and adds them to the collection.
    /// Uses IValueWithReferences to recursively walk the reference graph.
    /// </summary>
    private static void ProcessAzureReferences(HashSet<IAzureResource> azureReferences, object? value)
    {
        ProcessAzureReferences(azureReferences, value, []);
    }
 
    private static void ProcessAzureReferences(HashSet<IAzureResource> azureReferences, object? value, HashSet<object> visited)
    {
        // Null values can be added by environment variable or command line argument callbacks
        // and should be ignored since they cannot contain Azure resource references.
        if (value is null || !visited.Add(value))
        {
            return;
        }
 
        // Check if the value itself is an IAzureResource
        if (value is IAzureResource azureResource)
        {
            azureReferences.Add(azureResource);
        }
 
        // Recursively process references if the value implements IValueWithReferences
        if (value is IValueWithReferences vwr)
        {
            foreach (var reference in vwr.References)
            {
                ProcessAzureReferences(azureReferences, reference, visited);
            }
        }
    }
}
 
/// <summary>
/// Represents a bicep template file.
/// </summary>
/// <param name="path">The path to the bicep file.</param>
/// <param name="deleteFileOnDispose">Determines if the file should be deleted on disposal.</param>
public readonly struct BicepTemplateFile(string path, bool deleteFileOnDispose) : IDisposable
{
    /// <summary>
    /// The path to the bicep file.
    /// </summary>
    public string Path { get; } = path;
 
    /// <summary>
    /// Releases the resources used by the current instance of <see cref="BicepTemplateFile" />.
    /// </summary>
    public void Dispose()
    {
        if (deleteFileOnDispose)
        {
            File.Delete(Path);
        }
    }
}
 
/// <summary>
/// A reference to a KeyVault secret from a bicep template.
/// </summary>
/// <param name="name">The name of the KeyVault secret.</param>
/// <param name="resource">The <see cref="AzureBicepResource"/>.</param>
[Obsolete("BicepSecretOutputReference is no longer supported. Use IAzureKeyVaultResource instead.")]
public sealed class BicepSecretOutputReference(string name, AzureBicepResource resource) : IManifestExpressionProvider, IValueProvider, IValueWithReferences
{
    /// <summary>
    /// Name of the KeyVault secret.
    /// </summary>
    public string Name { get; } = name;
 
    /// <summary>
    /// The instance of the bicep resource.
    /// </summary>
    public AzureBicepResource Resource { get; } = resource;
 
    /// <summary>
    /// The value of the output.
    /// </summary>
    /// <param name="cancellationToken"> A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
    public async ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
    {
        if (Resource.ProvisioningTaskCompletionSource is not null)
        {
            await Resource.ProvisioningTaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
        }
 
        return Value;
    }
 
    /// <summary>
    /// The value of the output.
    /// </summary>
    public string? Value
    {
        get
        {
            if (!Resource.SecretOutputs.TryGetValue(Name, out var value))
            {
                throw new InvalidOperationException($"No secret output for {Name}");
            }
            return value;
        }
    }
 
    /// <summary>
    /// The expression used in the manifest to reference the value of the secret output.
    /// </summary>
    public string ValueExpression => $"{{{Resource.Name}.secretOutputs.{Name}}}";
 
    IEnumerable<object> IValueWithReferences.References => [Resource];
}
 
/// <summary>
/// A reference to an output from a bicep template.
/// </summary>
/// <param name="name">The name of the output</param>
/// <param name="resource">The <see cref="AzureBicepResource"/>.</param>
public sealed class BicepOutputReference(string name, AzureBicepResource resource) : IManifestExpressionProvider, IValueProvider, IValueWithReferences, IEquatable<BicepOutputReference>
{
    /// <summary>
    /// Name of the output.
    /// </summary>
    public string Name { get; } = BicepIdentifierHelpers.ThrowIfInvalid(name);
 
    /// <summary>
    /// The instance of the bicep resource.
    /// </summary>
    public AzureBicepResource Resource { get; } = resource;
 
    IEnumerable<object> IValueWithReferences.References => [Resource];
 
    /// <summary>
    /// The value of the output.
    /// </summary>
    /// <param name="cancellationToken"> A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
    public async ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
    {
        var provisioning = Resource.ProvisioningTaskCompletionSource;
        if (provisioning is not null)
        {
            await provisioning.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
        }
 
        return Value;
    }
 
    /// <summary>
    /// The value of the output.
    /// </summary>
    public string? Value
    {
        get
        {
            if (!Resource.Outputs.TryGetValue(Name, out var value))
            {
                throw new InvalidOperationException($"No output for {Name}");
            }
 
            return value?.ToString();
        }
    }
 
    /// <summary>
    /// The expression used in the manifest to reference the value of the output.
    /// </summary>
    public string ValueExpression => $"{{{Resource.Name}.outputs.{Name}}}";
 
    bool IEquatable<BicepOutputReference>.Equals(BicepOutputReference? other) =>
        other is not null &&
        other.Resource == Resource &&
        other.Name == Name;
 
    /// <inheritdoc/>
    public override int GetHashCode() =>
        HashCode.Combine(Resource, Name);
}