|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO.Hashing;
using System.Text;
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Process;
using Azure;
using Azure.Core;
using Azure.ResourceManager.KeyVault;
using Azure.ResourceManager.KeyVault.Models;
using Azure.ResourceManager.Resources;
using Azure.ResourceManager.Resources.Models;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting.Azure.Provisioning;
internal sealed class BicepProvisioner(
ResourceNotificationService notificationService,
ResourceLoggerService loggerService) : AzureResourceProvisioner<AzureBicepResource>
{
public override bool ShouldProvision(IConfiguration configuration, AzureBicepResource resource)
=> !resource.IsContainer();
public override async Task<bool> ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken)
{
var section = configuration.GetSection($"Azure:Deployments:{resource.Name}");
if (!section.Exists())
{
return false;
}
var currentCheckSum = await GetCurrentChecksumAsync(resource, section, cancellationToken).ConfigureAwait(false);
var configCheckSum = section["CheckSum"];
if (currentCheckSum != configCheckSum)
{
return false;
}
if (section["Outputs"] is string outputJson)
{
JsonNode? outputObj = null;
try
{
outputObj = JsonNode.Parse(outputJson);
if (outputObj is null)
{
return false;
}
}
catch
{
// Unable to parse the JSON, to treat it as not existing
return false;
}
foreach (var item in outputObj.AsObject())
{
// TODO: Handle complex output types
// Populate the resource outputs
resource.Outputs[item.Key] = item.Value?.Prop("value").ToString();
}
}
foreach (var item in section.GetSection("SecretOutputs").GetChildren())
{
resource.SecretOutputs[item.Key] = item.Value;
}
var portalUrls = new List<UrlSnapshot>();
if (section["Id"] is string deploymentId &&
ResourceIdentifier.TryParse(deploymentId, out var id) &&
id is not null)
{
portalUrls.Add(new(Name: "deployment", Url: GetDeploymentUrl(id), IsInternal: false));
}
await notificationService.PublishUpdateAsync(resource, state =>
{
ImmutableArray<ResourcePropertySnapshot> props = [
.. state.Properties,
new("azure.subscription.id", configuration["Azure:SubscriptionId"]),
// new("azure.resource.group", configuration["Azure:ResourceGroup"]!),
new("azure.tenant.domain", configuration["Azure:Tenant"]),
new("azure.location", configuration["Azure:Location"]),
new(CustomResourceKnownProperties.Source, section["Id"])
];
return state with
{
State = new("Running", KnownResourceStateStyles.Success),
Urls = [.. portalUrls],
Properties = props
};
}).ConfigureAwait(false);
return true;
}
public override async Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken)
{
await notificationService.PublishUpdateAsync(resource, state => state with
{
ResourceType = resource.GetType().Name,
State = new("Starting", KnownResourceStateStyles.Info),
Properties = [
new("azure.subscription.id", context.Subscription.Id.Name),
new("azure.resource.group", context.ResourceGroup.Id.Name),
new("azure.tenant.domain", context.Tenant.Data.DefaultDomain),
new("azure.location", context.Location.ToString()),
]
}).ConfigureAwait(false);
var resourceLogger = loggerService.GetLogger(resource);
PopulateWellKnownParameters(resource, context);
if (FindFullPathFromPath("az") is not { } azPath)
{
throw new AzureCliNotOnPathException();
}
var template = resource.GetBicepTemplateFile();
var path = template.Path;
KeyVaultResource? keyVault = null;
if (resource.Parameters.ContainsKey(AzureBicepResource.KnownParameters.KeyVaultName))
{
// This could be done as a bicep template that imports the other bicep template but this is
// quick and dirty for now
var keyVaults = context.ResourceGroup.GetKeyVaults();
// Check to see if there's a key vault for this resource already
await foreach (var kv in keyVaults.GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
{
if (kv.Data.Tags.TryGetValue("aspire-secret-store", out var secretStore) && secretStore == resource.Name)
{
resourceLogger.LogInformation("Found key vault {vaultName} for resource {resource} in {location}...", kv.Data.Name, resource.Name, context.Location);
keyVault = kv;
break;
}
}
if (keyVault is null)
{
await notificationService.PublishUpdateAsync(resource, state => state with
{
State = new("Provisioning Keyvault", KnownResourceStateStyles.Info)
}).ConfigureAwait(false);
// A vault's name must be between 3-24 alphanumeric characters. The name must begin with a letter, end with a letter or digit, and not contain consecutive hyphens.
// Follow this link for more information: https://go.microsoft.com/fwlink/?linkid=2147742
var vaultName = $"v{Guid.NewGuid().ToString("N")[0..20]}";
resourceLogger.LogInformation("Creating key vault {vaultName} for resource {resource} in {location}...", vaultName, resource.Name, context.Location);
var properties = new KeyVaultProperties(context.Subscription.Data.TenantId!.Value, new KeyVaultSku(KeyVaultSkuFamily.A, KeyVaultSkuName.Standard))
{
EnabledForTemplateDeployment = true,
EnableRbacAuthorization = true
};
var kvParameters = new KeyVaultCreateOrUpdateContent(context.Location, properties);
kvParameters.Tags.Add("aspire-secret-store", resource.Name);
var kvOperation = await keyVaults.CreateOrUpdateAsync(WaitUntil.Completed, vaultName, kvParameters, cancellationToken).ConfigureAwait(false);
keyVault = kvOperation.Value;
resourceLogger.LogInformation("Key vault {vaultName} created.", keyVault.Data.Name);
// Key Vault Administrator
// https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#key-vault-administrator
var roleDefinitionId = CreateRoleDefinitionId(context.Subscription, "00482a5a-887f-4fb3-b363-3b7fe8e74483");
await DoRoleAssignmentAsync(context.ArmClient, keyVault.Id, context.Principal.Id, roleDefinitionId, cancellationToken).ConfigureAwait(false);
}
resource.Parameters[AzureBicepResource.KnownParameters.KeyVaultName] = keyVault.Data.Name;
}
// Use the azure CLI to run the bicep compiler to transpile the bicep file to a ARM JSON file
var armTemplateContents = new StringBuilder();
var templateSpec = new ProcessSpec(azPath)
{
Arguments = $"bicep build --file \"{path}\" --stdout",
OnOutputData = data => armTemplateContents.AppendLine(data),
OnErrorData = data => resourceLogger.Log(LogLevel.Error, 0, data, null, (s, e) => s),
};
await notificationService.PublishUpdateAsync(resource, state =>
{
return state with
{
State = new("Compiling ARM template", KnownResourceStateStyles.Info)
};
})
.ConfigureAwait(false);
if (!await ExecuteCommand(templateSpec).ConfigureAwait(false))
{
throw new InvalidOperationException();
}
var deployments = context.ResourceGroup.GetArmDeployments();
resourceLogger.LogInformation("Deploying {Name} to {ResourceGroup}", resource.Name, context.ResourceGroup.Data.Name);
// Convert the parameters to a JSON object
var parameters = new JsonObject();
await SetParametersAsync(parameters, resource, cancellationToken: cancellationToken).ConfigureAwait(false);
var sw = Stopwatch.StartNew();
await notificationService.PublishUpdateAsync(resource, state =>
{
return state with
{
State = new("Creating ARM Deployment", KnownResourceStateStyles.Info)
};
})
.ConfigureAwait(false);
var operation = await deployments.CreateOrUpdateAsync(WaitUntil.Started, resource.Name, new ArmDeploymentContent(new(ArmDeploymentMode.Incremental)
{
Template = BinaryData.FromString(armTemplateContents.ToString()),
Parameters = BinaryData.FromObjectAsJson(parameters),
DebugSettingDetailLevel = "ResponseContent"
}),
cancellationToken).ConfigureAwait(false);
// Resolve the deployment URL before waiting for the operation to complete
var url = GetDeploymentUrl(context, resource.Name);
resourceLogger.LogInformation("Deployment started: {Url}", url);
await notificationService.PublishUpdateAsync(resource, state =>
{
return state with
{
State = new("Waiting for Deployment", KnownResourceStateStyles.Info),
Urls = [.. state.Urls, new(Name: "deployment", Url: url, IsInternal: false)],
};
})
.ConfigureAwait(false);
await operation.WaitForCompletionAsync(cancellationToken).ConfigureAwait(false);
sw.Stop();
resourceLogger.LogInformation("Deployment of {Name} to {ResourceGroup} took {Elapsed}", resource.Name, context.ResourceGroup.Data.Name, sw.Elapsed);
var deployment = operation.Value;
var outputs = deployment.Data.Properties.Outputs;
if (deployment.Data.Properties.ProvisioningState == ResourcesProvisioningState.Succeeded)
{
template.Dispose();
}
else
{
throw new InvalidOperationException($"Deployment of {resource.Name} to {context.ResourceGroup.Data.Name} failed with {deployment.Data.Properties.ProvisioningState}");
}
// e.g. { "sqlServerName": { "type": "String", "value": "<value>" }}
var outputObj = outputs?.ToObjectFromJson<JsonObject>();
var az = context.UserSecrets.Prop("Azure");
az["Tenant"] = context.Tenant.Data.DefaultDomain;
var resourceConfig = context.UserSecrets
.Prop("Azure")
.Prop("Deployments")
.Prop(resource.Name);
// Clear the entire section
resourceConfig.AsObject().Clear();
// Save the deployment id to the configuration
resourceConfig["Id"] = deployment.Id.ToString();
// Stash all parameters as a single JSON string
resourceConfig["Parameters"] = parameters.ToJsonString();
if (outputObj is not null)
{
// Same for outputs
resourceConfig["Outputs"] = outputObj.ToJsonString();
}
// Save the checksum to the configuration
resourceConfig["CheckSum"] = GetChecksum(resource, parameters);
if (outputObj is not null)
{
foreach (var item in outputObj.AsObject())
{
// TODO: Handle complex output types
// Populate the resource outputs
resource.Outputs[item.Key] = item.Value?.Prop("value").ToString();
}
}
// Populate secret outputs from key vault (if any)
if (keyVault is not null)
{
var configOutputs = resourceConfig.Prop("SecretOutputs");
var client = new SecretClient(keyVault.Data.Properties.VaultUri, context.Credential);
await foreach (var item in keyVault.GetKeyVaultSecrets().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
{
var response = await client.GetSecretAsync(item.Data.Name, cancellationToken: cancellationToken).ConfigureAwait(false);
var secret = response.Value;
resource.SecretOutputs[item.Data.Name] = secret.Value;
}
foreach (var item in resource.SecretOutputs)
{
// Save them to configuration
configOutputs[item.Key] = resource.SecretOutputs[item.Key];
}
}
await notificationService.PublishUpdateAsync(resource, state =>
{
ImmutableArray<ResourcePropertySnapshot> properties = [
.. state.Properties,
new(CustomResourceKnownProperties.Source, deployment.Id.Name)
];
return state with
{
State = new("Running", KnownResourceStateStyles.Success),
CreationTimeStamp = DateTime.UtcNow,
Properties = properties
};
})
.ConfigureAwait(false);
}
private static void PopulateWellKnownParameters(AzureBicepResource resource, ProvisioningContext context)
{
if (resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.PrincipalId, out var principalId) && principalId is null)
{
resource.Parameters[AzureBicepResource.KnownParameters.PrincipalId] = context.Principal.Id;
}
if (resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.PrincipalName, out var principalName) && principalName is null)
{
resource.Parameters[AzureBicepResource.KnownParameters.PrincipalName] = context.Principal.Name;
}
if (resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.PrincipalType, out var principalType) && principalType is null)
{
resource.Parameters[AzureBicepResource.KnownParameters.PrincipalType] = "User";
}
if (resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.LogAnalyticsWorkspaceId, out var logAnalyticsWorkspaceId) && logAnalyticsWorkspaceId is null)
{
// We don't have a log analytics workspace for environments so we will just let the bicep file create one
resource.Parameters.Remove(AzureBicepResource.KnownParameters.LogAnalyticsWorkspaceId);
}
// Always specify the location
resource.Parameters[AzureBicepResource.KnownParameters.Location] = context.Location.Name;
}
private static async Task<bool> ExecuteCommand(ProcessSpec processSpec)
{
var sw = Stopwatch.StartNew();
var (task, disposable) = ProcessUtil.Run(processSpec);
try
{
var result = await task.ConfigureAwait(false);
sw.Stop();
return result.ExitCode == 0;
}
finally
{
await disposable.DisposeAsync().ConfigureAwait(false);
}
}
private static string? FindFullPathFromPath(string command) => FindFullPathFromPath(command, Environment.GetEnvironmentVariable("PATH"), Path.PathSeparator, File.Exists);
private static string? FindFullPathFromPath(string command, string? pathVariable, char pathSeparator, Func<string, bool> fileExists)
{
Debug.Assert(!string.IsNullOrWhiteSpace(command));
if (OperatingSystem.IsWindows())
{
command += ".cmd";
}
foreach (var directory in (pathVariable ?? string.Empty).Split(pathSeparator))
{
var fullPath = Path.Combine(directory, command);
if (fileExists(fullPath))
{
return fullPath;
}
}
return null;
}
internal static string GetChecksum(AzureBicepResource resource, JsonObject parameters)
{
// TODO: PERF Inefficient
// Combine the parameter values with the bicep template to create a unique value
var input = parameters.ToJsonString() + resource.GetBicepTemplateString();
// Hash the contents
var hashedContents = Crc32.Hash(Encoding.UTF8.GetBytes(input));
// Convert the hash to a string
return Convert.ToHexString(hashedContents).ToLowerInvariant();
}
internal static async ValueTask<string?> GetCurrentChecksumAsync(AzureBicepResource resource, IConfiguration section, CancellationToken cancellationToken = default)
{
// Fill in parameters from configuration
if (section["Parameters"] is not string jsonString)
{
return null;
}
try
{
var parameters = JsonNode.Parse(jsonString)?.AsObject();
if (parameters is null)
{
return null;
}
// Now overwrite with live object values skipping known and generated values.
// This is important because the provisioner will fill in the known values and
// generated values would change every time, so they can't be part of the checksum.
await SetParametersAsync(parameters, resource, skipDynamicValues: true, cancellationToken: cancellationToken).ConfigureAwait(false);
// Get the checksum of the new values
return GetChecksum(resource, parameters);
}
catch
{
// Unable to parse the JSON, to treat it as not existing
return null;
}
}
// 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.KeyVaultName,
AzureBicepResource.KnownParameters.Location,
AzureBicepResource.KnownParameters.LogAnalyticsWorkspaceId,
];
// Converts the parameters to a JSON object compatible with the ARM template
internal static async Task SetParametersAsync(JsonObject parameters, AzureBicepResource resource, bool skipDynamicValues = false, CancellationToken cancellationToken = default)
{
// Convert the parameters to a JSON object
foreach (var parameter in resource.Parameters)
{
if (skipDynamicValues &&
(s_knownParameterNames.Contains(parameter.Key) || IsParameterWithGeneratedValue(parameter.Value)))
{
continue;
}
// Execute parameter values which are deferred.
var parameterValue = parameter.Value is Func<object?> f ? f() : parameter.Value;
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.")
}
};
}
}
private static bool IsParameterWithGeneratedValue(object? value)
{
return value is ParameterResource { Default: not null };
}
private const string PortalDeploymentOverviewUrl = "https://portal.azure.com/#view/HubsExtension/DeploymentDetailsBlade/~/overview/id";
private static string GetDeploymentUrl(ProvisioningContext provisioningContext, string deploymentName)
{
var prefix = PortalDeploymentOverviewUrl;
var subId = provisioningContext.Subscription.Data.Id.ToString();
var rgName = provisioningContext.ResourceGroup.Data.Name;
var subAndRg = $"{subId}/resourceGroups/{rgName}";
var deployId = deploymentName;
var path = $"{subAndRg}/providers/Microsoft.Resources/deployments/{deployId}";
var encodedPath = Uri.EscapeDataString(path);
return $"{prefix}/{encodedPath}";
}
public static string GetDeploymentUrl(ResourceIdentifier deploymentId) =>
$"{PortalDeploymentOverviewUrl}/{Uri.EscapeDataString(deploymentId.ToString())}";
}
|