File: Provisioning\BicepUtilities.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 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
 
using System.IO.Hashing;
using System.Text;
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Azure.Provisioning;
 
/// <summary>
/// Utility methods for working with Bicep resources.
/// </summary>
internal static class BicepUtilities
{
    internal const string DeploymentStateIdKey = "Id";
    internal const string DeploymentStateParametersKey = "Parameters";
    internal const string DeploymentStateOutputsKey = "Outputs";
    internal const string DeploymentStateScopeKey = "Scope";
    internal const string DeploymentStateChecksumKey = "CheckSum";
 
    // Known values since they will be filled in by the provisioner
    private static readonly string[] s_knownParameterNames =
    [
        AzureBicepResource.KnownParameters.PrincipalName,
        AzureBicepResource.KnownParameters.PrincipalId,
        AzureBicepResource.KnownParameters.PrincipalType,
        AzureBicepResource.KnownParameters.UserPrincipalId,
        AzureBicepResource.KnownParameters.Location,
    ];
 
    /// <summary>
    /// Converts the parameters to a JSON object compatible with the ARM template.
    /// </summary>
    public static async Task SetParametersAsync(JsonObject parameters, AzureBicepResource resource, bool skipKnownValues = false, CancellationToken cancellationToken = default)
    {
        // Convert the parameters to a JSON object
        foreach (var parameter in resource.Parameters)
        {
            // Execute parameter values which are deferred.
            var parameterValue = parameter.Value is Func<object?> f ? f() : parameter.Value;
 
            // Skip known parameters with 'null' values, like PrincipalType and PrincipalId, since they are filled in by the provisioner
            // and are not available at this time. If we don't do this, the "roles" resources will be re-deployed every run.
            if (skipKnownValues && s_knownParameterNames.Contains(parameter.Key) && parameterValue is null)
            {
                continue;
            }
 
            parameters[parameter.Key] = new JsonObject()
            {
                ["value"] = parameterValue switch
                {
                    string s => s,
                    IEnumerable<string> s => new JsonArray(s.Select(s => JsonValue.Create(s)).ToArray()),
                    int i => i,
                    bool b => b,
                    Guid g => g.ToString(),
                    JsonNode node => node,
                    IValueProvider v => await v.GetValueAsync(cancellationToken).ConfigureAwait(false),
                    null => null,
                    _ => throw new NotSupportedException($"The parameter value type {parameterValue.GetType()} is not supported.")
                }
            };
        }
    }
 
    /// <summary>
    /// Sets the scope information for a Bicep resource.
    /// </summary>
    public static async Task SetScopeAsync(JsonObject scope, AzureBicepResource resource, CancellationToken cancellationToken = default)
    {
        scope.Clear();
 
        // Resolve the scope from the AzureBicepResource if it has already been set
        // via the ConfigureInfrastructure callback. If not, fallback to the ExistingAzureResourceAnnotation.
        var targetScope = GetExistingResourceScope(resource);
        if (targetScope is null)
        {
            scope["resourceGroup"] = null;
            return;
        }
 
        if (targetScope.HasResourceGroup)
        {
            await SetScopeValueAsync(scope, "resourceGroup", targetScope.ResourceGroup, cancellationToken).ConfigureAwait(false);
        }
        await SetScopeValueAsync(scope, "subscription", targetScope.Subscription, cancellationToken).ConfigureAwait(false);
        if (targetScope.IsTenantScope)
        {
            scope["tenant"] = "current";
        }
    }
 
    /// <summary>
    /// Gets the checksum for a Bicep resource configuration.
    /// </summary>
    public static string GetChecksum(AzureBicepResource resource, JsonObject parameters, JsonObject? scope)
    {
        // TODO: PERF Inefficient
 
        // Combine the parameter values with the bicep template to create a unique value
        var input = parameters.ToJsonString() + resource.GetBicepTemplateString();
        if (scope is not null)
        {
            input += scope.ToJsonString();
        }
 
        // Hash the contents
        var hashedContents = Crc32.Hash(Encoding.UTF8.GetBytes(input));
 
        // Convert the hash to a string
        return Convert.ToHexString(hashedContents).ToLowerInvariant();
    }
 
    /// <summary>
    /// Gets the current checksum for a Bicep resource from configuration.
    /// </summary>
    public static async ValueTask<string?> GetCurrentChecksumAsync(AzureBicepResource resource, IConfiguration section, CancellationToken cancellationToken = default)
    {
        // Fill in parameters from configuration
        if (section[DeploymentStateParametersKey] is not string jsonString)
        {
            return null;
        }
 
        try
        {
            var parameters = JsonNode.Parse(jsonString)?.AsObject();
            var scope = section[DeploymentStateScopeKey] is string scopeString
                ? JsonNode.Parse(scopeString)?.AsObject()
                : GetExistingResourceScope(resource) is not null
                    ? new JsonObject()
                    : null;
 
            if (parameters is null)
            {
                return null;
            }
 
            // Force evaluation of the Bicep template to ensure parameters are expanded
            _ = resource.GetBicepTemplateString();
 
            // Now overwrite with live object values skipping known values.
            await SetParametersAsync(parameters, resource, skipKnownValues: true, cancellationToken: cancellationToken).ConfigureAwait(false);
            if (scope is not null)
            {
                await SetScopeAsync(scope, resource, cancellationToken).ConfigureAwait(false);
            }
 
            // Get the checksum of the new values
            return GetChecksum(resource, parameters, scope);
        }
        catch
        {
            // Unable to parse the JSON, to treat it as not existing
            return null;
        }
    }
 
    /// <summary>
    /// Gets the current checksum for a Bicep resource from deployment state.
    /// </summary>
    public static async ValueTask<string?> GetCurrentChecksumAsync(AzureBicepResource resource, DeploymentStateSection section, ILogger logger, CancellationToken cancellationToken = default)
    {
        if (section.Data[DeploymentStateParametersKey]?.GetValue<string>() is not { Length: > 0 } jsonString)
        {
            return null;
        }
 
        try
        {
            var parameters = JsonNode.Parse(jsonString)?.AsObject();
            var scope = section.Data[DeploymentStateScopeKey]?.GetValue<string>() is { Length: > 0 } scopeString
                ? JsonNode.Parse(scopeString)?.AsObject()
                : GetExistingResourceScope(resource) is not null
                    ? new JsonObject()
                    : null;
 
            if (parameters is null)
            {
                return null;
            }
 
            _ = resource.GetBicepTemplateString();
 
            await SetParametersAsync(parameters, resource, skipKnownValues: true, cancellationToken: cancellationToken).ConfigureAwait(false);
            if (scope is not null)
            {
                await SetScopeAsync(scope, resource, cancellationToken).ConfigureAwait(false);
            }
 
            return GetChecksum(resource, parameters, scope);
        }
        catch (Exception ex)
        {
            logger.LogDebug(ex, "Unable to compute current checksum for resource {ResourceName}.", resource.Name);
            return null;
        }
    }
 
    internal static AzureBicepResourceScope? GetExistingResourceScope(AzureBicepResource resource)
    {
        if (resource.Scope is not null)
        {
            return resource.Scope;
        }
 
        return resource.TryGetLastAnnotation<ExistingAzureResourceAnnotation>(out var existingResource)
            ? AzureBicepResourceScope.FromExistingResourceAnnotation(existingResource)
            : null;
    }
 
    private static async Task SetScopeValueAsync(JsonObject scope, string propertyName, object? value, CancellationToken cancellationToken)
    {
        if (value is null)
        {
            return;
        }
 
        scope[propertyName] = value switch
        {
            string s => s,
            IValueProvider v => await v.GetValueAsync(cancellationToken).ConfigureAwait(false),
            _ => throw new NotSupportedException($"The scope {propertyName} value type {value.GetType()} is not supported.")
        };
    }
}