|
// 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);
}
|