// 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.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure.Provisioning.Internal;
using Aspire.Hosting.Azure.Resources;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Utils;
using Azure;
using Azure.Core;
using Azure.Identity;
using Azure.ResourceManager;
using Azure.ResourceManager.Resources.Deployments;
using Azure.ResourceManager.Resources.Deployments.Models;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting.Azure.Provisioning;
internal sealed class BicepProvisioner(
ResourceNotificationService notificationService,
ResourceLoggerService loggerService,
IBicepCompiler bicepCompiler,
ISecretClientProvider secretClientProvider,
IDeploymentStateManager deploymentStateManager,
DistributedApplicationExecutionContext executionContext,
IFileSystemService fileSystemService,
ILogger<BicepProvisioner> logger,
TimeProvider? timeProvider = null) : IBicepProvisioner
{
internal const string DeploymentStateProvisioningStateKey = "ProvisioningState";
internal const string DeploymentStateProvisioningStateRunning = "Running";
internal const string DeploymentStateProvisioningStateCanceled = "Canceled";
internal const string DeploymentStateProvisioningStateFailed = "Failed";
internal const string DeploymentStateProvisioningStateSucceeded = "Succeeded";
private const string DeploymentActiveErrorCode = "DeploymentActive";
private const string DeploymentUrlName = "deployment";
private const string DeploymentOperationPropertyPrefix = "azure.deployment.operations.";
private const string DeploymentOperationSummaryPropertyName = DeploymentOperationPropertyPrefix + "summary";
private static readonly TimeSpan s_deploymentOperationPollingInterval = TimeSpan.FromSeconds(2);
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
/// <inheritdoc />
public async Task<bool> ConfigureResourceAsync(AzureBicepResource resource, CancellationToken cancellationToken)
{
var stateSection = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false);
if (stateSection.Data.Count == 0)
{
return false;
}
if (stateSection.Data[DeploymentStateProvisioningStateKey]?.GetValue<string>() is { Length: > 0 } provisioningState &&
!string.Equals(provisioningState, DeploymentStateProvisioningStateSucceeded, StringComparison.Ordinal))
{
logger.LogDebug("Cached deployment state for resource {ResourceName} is incomplete because provisioning state is {ProvisioningState}.", resource.Name, provisioningState);
return false;
}
var currentCheckSum = await BicepUtilities.GetCurrentChecksumAsync(resource, stateSection, executionContext, logger, cancellationToken).ConfigureAwait(false);
var configCheckSum = stateSection.Data[BicepUtilities.DeploymentStateChecksumKey]?.GetValue<string>();
if (string.IsNullOrEmpty(configCheckSum))
{
logger.LogDebug("Cached deployment state for resource {ResourceName} is incomplete because it is missing a checksum.", resource.Name);
return false;
}
if (string.IsNullOrEmpty(currentCheckSum) || !string.Equals(currentCheckSum, configCheckSum, StringComparison.Ordinal))
{
logger.LogDebug("Checksum mismatch for resource {ResourceName}. Expected cached checksum {ExpectedChecksum}, computed checksum {ActualChecksum}", resource.Name, configCheckSum, currentCheckSum);
return false;
}
logger.LogDebug("Configuring resource {ResourceName} from existing deployment state.", resource.Name);
if (stateSection.Data[BicepUtilities.DeploymentStateOutputsKey]?.GetValue<string>() is { Length: > 0 } 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();
}
}
if (resource is IAzureKeyVaultResource kvr)
{
ConfigureSecretResolver(kvr);
}
var portalUrls = new List<UrlSnapshot>();
string? deploymentId = null;
ResourceIdentifier? deploymentResourceId = null;
if (stateSection.Data[BicepUtilities.DeploymentStateIdKey]?.GetValue<string>() is { Length: > 0 } configuredDeploymentId &&
ResourceIdentifier.TryParse(configuredDeploymentId, out var id) &&
id is not null)
{
deploymentId = configuredDeploymentId;
deploymentResourceId = id;
portalUrls.Add(new(Name: DeploymentUrlName, Url: GetDeploymentUrl(id), IsInternal: false));
}
var azureContext = await GetCurrentAzureContextAsync(deploymentResourceId, cancellationToken).ConfigureAwait(false);
var configuredLocation = GetConfiguredLocation(stateSection, azureContext.Location);
await notificationService.PublishUpdateAsync(resource, state =>
{
// Reused deployment state should expose the same Azure identity metadata as a freshly provisioned resource
// so agents and commands can reliably locate the backing Azure deployment.
var props = state.Properties.WithoutAzureProvisioningFailureProperties()
.SetResourcePropertyRange(AzureResourceProperties.CreateContextProperties(
azureContext.SubscriptionId,
azureContext.ResourceGroup,
azureContext.TenantId,
azureContext.TenantDomain,
configuredLocation,
includeEmptyProperties: true))
.SetResourcePropertyRange([new(CustomResourceKnownProperties.Source, deploymentId)]);
return state with
{
State = new(AzureProvisioningController.ProvisionedState, KnownResourceStateStyles.Success),
Urls = [.. portalUrls],
Properties = props
};
}).ConfigureAwait(false);
return true;
}
/// <summary>
/// Adopts a cached ARM deployment that was still running when the AppHost last stopped or was canceled.
/// </summary>
/// <remarks>
/// The persisted state only tells us which deployment belongs to this resource. ARM remains the source of
/// truth, so reconciliation probes the deployment, follows it while it is still active, applies outputs if it
/// succeeded, records terminal failure/cancellation, or clears stale local state so normal provisioning can retry.
/// </remarks>
public async Task<bool> ReconcileDeploymentStateAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken)
{
// Reconciliation is a run-mode recovery step. Publish should not revive state
// left behind by a previous local command.
if (!context.ExecutionContext.IsRunMode)
{
return false;
}
// The cache stores the ARM deployment ID that was running before the AppHost
// stopped or a command was canceled.
var sectionName = $"Azure:Deployments:{resource.Name}";
var stateSection = await deploymentStateManager.AcquireSectionAsync(sectionName, cancellationToken).ConfigureAwait(false);
// Example cached section shape:
// Azure:Deployments:cache
// Id = /subscriptions/<subscription>/resourceGroups/<group>/providers/Microsoft.Resources/deployments/cache
// Parameters = {"redisName":{"value":"cache"}}
// Scope = {"subscriptionId":"<subscription>","resourceGroup":"<group>"}
// CheckSum = <model-checksum>
// ProvisioningState = Running
// Only sections that still claim a running deployment with a parseable ARM ID can be adopted.
if (!IsRunningCachedDeployment(stateSection) ||
stateSection.Data[BicepUtilities.DeploymentStateIdKey]?.GetValue<string>() is not { Length: > 0 } deploymentId ||
!ResourceIdentifier.TryParse(deploymentId, out var deploymentResourceId) ||
deploymentResourceId is null)
{
return false;
}
AzureDeploymentState? deployment;
try
{
// Probe ARM before mutating cached state. If the probe cannot run, leave
// the cache intact and let normal provisioning decide.
deployment = await context.ArmClient.GetDeploymentAsync(deploymentId, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (CredentialUnavailableException ex)
{
logger.LogDebug(ex, "Unable to reconcile cached deployment state for {ResourceName} because no Azure credential is available.", resource.Name);
return false;
}
catch (RequestFailedException ex)
{
logger.LogDebug(ex, "Unable to reconcile cached deployment state for {ResourceName} because the Azure deployment probe failed.", resource.Name);
return false;
}
if (deployment is null)
{
// ARM no longer has the cached deployment, so the local "running" marker
// is stale and should not block a fresh deployment.
logger.LogInformation("Cached Azure deployment {DeploymentId} for {ResourceName} no longer exists. Reprovisioning.", deploymentId, resource.Name);
await ClearCachedRunningDeploymentStateAsync(stateSection, cancellationToken).ConfigureAwait(false);
return false;
}
var currentLocation = GetConfiguredLocation(stateSection, context.Location.Name);
if (IsActiveDeploymentProvisioningState(deployment.ProvisioningState))
{
// A still-active ARM deployment should be adopted and observed to
// completion instead of starting a duplicate deployment.
deployment = await WaitForCachedRunningDeploymentAsync(
resource,
context,
deploymentId,
currentLocation,
deployment,
cancellationToken).ConfigureAwait(false);
if (deployment is null)
{
// The deployment disappeared while we were watching it; clear the
// stale marker so the caller reprovisions from the model.
logger.LogInformation("Cached Azure deployment {DeploymentId} for {ResourceName} no longer exists after reconciliation started. Reprovisioning.", deploymentId, resource.Name);
await ClearCachedRunningDeploymentStateAsync(stateSection, cancellationToken).ConfigureAwait(false);
return false;
}
}
// At this point the cached deployment is no longer active. Adopt known
// terminal outcomes; leave unknown states to the reprovision fallback below.
if (await TryApplyTerminalReconciledDeploymentStateAsync(resource, stateSection, deploymentResourceId, deployment, currentLocation, cancellationToken).ConfigureAwait(false))
{
return true;
}
logger.LogDebug("Cached Azure deployment {DeploymentId} for {ResourceName} has provisioning state {ProvisioningState}. Reprovisioning.", deploymentId, resource.Name, deployment.ProvisioningState);
return false;
}
private async Task<AzureDeploymentState?> WaitForCachedRunningDeploymentAsync(
AzureBicepResource resource,
ProvisioningContext context,
string deploymentId,
string currentLocation,
AzureDeploymentState deployment,
CancellationToken cancellationToken)
{
var tracker = new DeploymentOperationProgressTracker();
while (IsActiveDeploymentProvisioningState(deployment.ProvisioningState))
{
try
{
await PublishCachedRunningDeploymentStateAsync(resource, context, deploymentId, currentLocation, tracker, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (RequestFailedException ex)
{
logger.LogDebug(ex, "Unable to publish Azure deployment operation progress for cached deployment {DeploymentId} on {ResourceName}.", deploymentId, resource.Name);
}
catch (CredentialUnavailableException ex)
{
logger.LogDebug(ex, "Unable to publish Azure deployment operation progress for cached deployment {DeploymentId} on {ResourceName} because no Azure credential is available.", deploymentId, resource.Name);
}
await Task.Delay(s_deploymentOperationPollingInterval, _timeProvider, cancellationToken).ConfigureAwait(false);
AzureDeploymentState? latestDeployment;
try
{
latestDeployment = await context.ArmClient.GetDeploymentAsync(deploymentId, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (RequestFailedException ex)
{
logger.LogDebug(ex, "Unable to continue reconciling cached Azure deployment {DeploymentId} for {ResourceName}.", deploymentId, resource.Name);
return deployment;
}
catch (CredentialUnavailableException ex)
{
logger.LogDebug(ex, "Unable to continue reconciling cached Azure deployment {DeploymentId} for {ResourceName} because no Azure credential is available.", deploymentId, resource.Name);
return deployment;
}
if (latestDeployment is null)
{
return null;
}
deployment = latestDeployment;
}
return deployment;
}
private async Task PublishCachedRunningDeploymentStateAsync(
AzureBicepResource resource,
ProvisioningContext context,
string deploymentId,
string currentLocation,
DeploymentOperationProgressTracker tracker,
CancellationToken cancellationToken)
{
var deploymentResourceId = new ResourceIdentifier(deploymentId);
var deploymentUrl = GetDeploymentUrl(deploymentResourceId);
// A cached deployment can be adopted after the original AppHost process is gone,
// so rebuild the dashboard's "waiting for deployment" state from persisted ARM
// metadata before publishing more detailed operation progress.
await notificationService.PublishUpdateAsync(resource, state => state with
{
State = new(AzureProvisioningController.WaitingForDeploymentState, KnownResourceStateStyles.Info),
Urls = [.. state.Urls.Where(static url => !string.Equals(url.Name, DeploymentUrlName, StringComparison.Ordinal)), new(Name: DeploymentUrlName, Url: deploymentUrl, IsInternal: false)],
Properties = state.Properties
.WithoutAzureProvisioningFailureProperties()
.SetResourceProperty(CustomResourceKnownProperties.Source, deploymentId)
}).ConfigureAwait(false);
// Deployment operations carry provider-level progress and failure details that
// are not present on the deployment resource itself. Publish a best-effort
// summary so the adopted deployment looks like an in-flight deployment started
// by the current AppHost run.
await PublishDeploymentOperationSummaryAsync(resource, context, deploymentResourceId, currentLocation, tracker, force: false, cancellationToken).ConfigureAwait(false);
}
private async Task<bool> ConfigureSucceededReconciledDeploymentAsync(
AzureBicepResource resource,
DeploymentStateSection stateSection,
ResourceIdentifier deploymentId,
JsonObject? outputs,
string currentLocation,
CancellationToken cancellationToken)
{
if (!TryGetDeploymentStateJsonObject(stateSection, BicepUtilities.DeploymentStateParametersKey, resource.Name, out var parameters) ||
stateSection.Data[BicepUtilities.DeploymentStateChecksumKey]?.GetValue<string>() is not { Length: > 0 } checksum)
{
logger.LogDebug("Cached deployment state for resource {ResourceName} cannot be reconciled because parameters or checksum are missing.", resource.Name);
return false;
}
// Scope is optional cached state. If it is missing or malformed, we can still
// adopt the succeeded deployment because the current Azure context supplies
// the resource group scope when ConfigureResourceAsync reloads the outputs.
// The cached value is the JSON string produced by BicepUtilities.SetScopeAsync:
// {"resourceGroup":"<group>","subscription":"<subscription>"}
// Tenant-scoped resources also include "tenant": "current"; resources without
// an explicit/existing scope persist {"resourceGroup":null}.
var scope = TryGetDeploymentStateJsonObject(stateSection, BicepUtilities.DeploymentStateScopeKey, resource.Name, out var cachedScope)
? cachedScope
: null;
var locationOverride = stateSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue<string>();
UpdateDeploymentState(
stateSection,
locationOverride,
deploymentId,
parameters,
outputs,
scope,
checksum,
currentLocation,
provisioningState: null);
await deploymentStateManager.SaveSectionAsync(stateSection, cancellationToken).ConfigureAwait(false);
return await ConfigureResourceAsync(resource, cancellationToken).ConfigureAwait(false);
}
private async Task<bool> TryApplyTerminalReconciledDeploymentStateAsync(
AzureBicepResource resource,
DeploymentStateSection stateSection,
ResourceIdentifier deploymentId,
AzureDeploymentState deployment,
string currentLocation,
CancellationToken cancellationToken)
{
// Terminal deployment states are the only states we can safely adopt. A
// succeeded deployment updates cached outputs and configures the resource.
// Canceled/failed deployments persist the ARM terminal state and throw so
// the dashboard shows the original outcome. Any other state returns false
// so the caller can reprovision from the current model.
if (string.Equals(deployment.ProvisioningState, DeploymentStateProvisioningStateSucceeded, StringComparisons.AzureProvisioningState))
{
// Successful adoption replays the deployment outputs into the resource
// state exactly as normal provisioning would.
return await ConfigureSucceededReconciledDeploymentAsync(
resource,
stateSection,
deploymentId,
deployment.Outputs,
currentLocation,
cancellationToken).ConfigureAwait(false);
}
if (string.Equals(deployment.ProvisioningState, DeploymentStateProvisioningStateCanceled, StringComparisons.AzureProvisioningState))
{
// Preserve ARM's terminal cancellation so the dashboard explains why this
// resource stopped instead of silently retrying.
await PersistReconciledProvisioningStateAsync(stateSection, DeploymentStateProvisioningStateCanceled, cancellationToken).ConfigureAwait(false);
await PublishReconciledTerminalStateAsync(resource, AzureProvisioningStrings.ResourceStateAzureDeploymentCanceled).ConfigureAwait(false);
throw new InvalidOperationException($"Azure deployment for {resource.Name} was canceled.");
}
if (string.Equals(deployment.ProvisioningState, DeploymentStateProvisioningStateFailed, StringComparisons.AzureProvisioningState))
{
// Preserve ARM's terminal failure for the same reason as cancellation:
// this is the outcome of the adopted deployment.
await PersistReconciledProvisioningStateAsync(stateSection, DeploymentStateProvisioningStateFailed, cancellationToken).ConfigureAwait(false);
await PublishReconciledTerminalStateAsync(resource, AzureProvisioningStrings.ResourceStateAzureDeploymentFailed).ConfigureAwait(false);
throw new InvalidOperationException($"Azure deployment for {resource.Name} failed.");
}
return false;
}
private async Task PersistReconciledProvisioningStateAsync(DeploymentStateSection stateSection, string provisioningState, CancellationToken cancellationToken)
{
stateSection.Data[DeploymentStateProvisioningStateKey] = provisioningState;
await deploymentStateManager.SaveSectionAsync(stateSection, cancellationToken).ConfigureAwait(false);
}
private async Task PublishReconciledTerminalStateAsync(AzureBicepResource resource, string state)
{
await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = new(state, KnownResourceStateStyles.Error)
}).ConfigureAwait(false);
}
private async Task ClearCachedRunningDeploymentStateAsync(DeploymentStateSection stateSection, CancellationToken cancellationToken)
{
var locationOverride = stateSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue<string>();
stateSection.Data.Clear();
if (!string.IsNullOrWhiteSpace(locationOverride))
{
stateSection.Data[AzureProvisioningController.LocationOverrideKey] = locationOverride;
await deploymentStateManager.SaveSectionAsync(stateSection, cancellationToken).ConfigureAwait(false);
}
else
{
await deploymentStateManager.DeleteSectionAsync(stateSection, cancellationToken).ConfigureAwait(false);
}
}
private bool TryGetDeploymentStateJsonObject(
DeploymentStateSection stateSection,
string key,
string resourceName,
[NotNullWhen(true)] out JsonObject? value)
{
value = null;
if (stateSection.Data[key]?.GetValue<string>() is not { Length: > 0 } json)
{
return false;
}
try
{
value = AzureProvisioningJsonHelpers.ParseDeploymentStateJson(json)?.AsObject();
return value is not null;
}
catch (Exception ex)
{
logger.LogDebug(ex, "Unable to parse cached deployment state property {PropertyName} for resource {ResourceName}.", key, resourceName);
return false;
}
}
private static bool IsRunningCachedDeployment(DeploymentStateSection stateSection)
=> string.Equals(
stateSection.Data[DeploymentStateProvisioningStateKey]?.GetValue<string>(),
DeploymentStateProvisioningStateRunning,
StringComparison.Ordinal);
private static bool IsActiveDeploymentProvisioningState(string? provisioningState)
=> !string.IsNullOrEmpty(provisioningState) &&
!string.Equals(provisioningState, DeploymentStateProvisioningStateSucceeded, StringComparisons.AzureProvisioningState) &&
!string.Equals(provisioningState, DeploymentStateProvisioningStateFailed, StringComparisons.AzureProvisioningState) &&
!string.Equals(provisioningState, DeploymentStateProvisioningStateCanceled, StringComparisons.AzureProvisioningState);
/// <inheritdoc />
public async Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken)
{
var resourceGroup = context.ResourceGroup;
var subscription = context.Subscription;
var resourceLogger = loggerService.GetLogger(resource);
var targetScope = BicepUtilities.GetExistingResourceScope(resource);
var isTenantScoped = targetScope?.IsTenantScope == true;
var isSubscriptionScoped = !isTenantScoped &&
targetScope is { Subscription: not null, HasResourceGroup: false };
if (targetScope?.Subscription is { } existingSubscription)
{
var existingSubscriptionId = await ResolveScopeValueAsync(existingSubscription, cancellationToken).ConfigureAwait(false);
subscription = await context.ArmClient.GetSubscriptionAsync(existingSubscriptionId, cancellationToken).ConfigureAwait(false);
}
if (targetScope?.HasResourceGroup == true)
{
var existingResourceGroup = targetScope.ResourceGroup;
var existingResourceGroupName = await ResolveScopeValueAsync(existingResourceGroup, cancellationToken).ConfigureAwait(false);
var response = await subscription.GetResourceGroups().GetAsync(existingResourceGroupName, cancellationToken).ConfigureAwait(false);
resourceGroup = response.Value;
}
var resourceGroupName = isTenantScoped || isSubscriptionScoped ? null : resourceGroup.Id.Name;
var effectiveLocation = GetEffectiveLocation(resource, context);
await notificationService.PublishUpdateAsync(resource, state => state with
{
ResourceType = resource.GetType().Name,
State = new(AzureProvisioningController.StartingState, KnownResourceStateStyles.Info),
Properties = WithoutDeploymentOperationProperties(state.Properties.WithoutAzureProvisioningFailureProperties()).SetResourcePropertyRange(
AzureResourceProperties.CreateContextProperties(
subscription.Id.Name,
resourceGroupName,
context.Tenant.TenantId?.ToString(),
context.Tenant.DefaultDomain,
effectiveLocation,
includeEmptyProperties: true))
}).ConfigureAwait(false);
var tempDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-bicep").Path;
var template = resource.GetBicepTemplateFile(tempDirectory);
var path = template.Path;
// GetBicepTemplateFile may have added new well-known parameters, so we need
// to populate them only after calling GetBicepTemplateFile.
PopulateWellKnownParameters(resource, context);
await notificationService.PublishUpdateAsync(resource, state =>
{
return state with
{
State = new(AzureProvisioningController.CompilingArmTemplateState, KnownResourceStateStyles.Info)
};
})
.ConfigureAwait(false);
var armTemplateContents = await bicepCompiler.CompileBicepToArmAsync(path, cancellationToken).ConfigureAwait(false);
logger.LogDebug("Setting parameters and scope for resource {ResourceName}", resource.Name);
// Convert the parameters to a JSON object
var parameters = new JsonObject();
await BicepUtilities.SetParametersAsync(parameters, resource, executionContext, cancellationToken: cancellationToken).ConfigureAwait(false);
var scope = new JsonObject();
await BicepUtilities.SetScopeAsync(scope, resource, cancellationToken: cancellationToken).ConfigureAwait(false);
var deployments = isTenantScoped
? context.Tenant.GetArmDeployments()
: isSubscriptionScoped
? subscription.GetArmDeployments()
: resourceGroup.GetArmDeployments();
var deploymentSuffix = executionContext.IsPublishMode
? $"-{_timeProvider.GetUtcNow().ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)}"
: string.Empty;
// Reserve the publish suffix before shortening so ARM's 64-character limit is never exceeded.
// https://learn.microsoft.com/azure/azure-resource-manager/management/resource-name-rules#microsoftresources
var deploymentName = ResourceNameHelpers.TruncateWithHash(resource.Name, 64 - deploymentSuffix.Length) + deploymentSuffix;
var deploymentId = GetDeploymentId(isTenantScoped, isSubscriptionScoped, subscription, resourceGroup, deploymentName);
var checksum = BicepUtilities.GetChecksum(resource, parameters, scope);
var sw = Stopwatch.StartNew();
await notificationService.PublishUpdateAsync(resource, state =>
{
return state with
{
State = new(AzureProvisioningController.CreatingArmDeploymentState, KnownResourceStateStyles.Info),
Properties = state.Properties.SetResourceProperty(CustomResourceKnownProperties.Source, deploymentId.ToString()),
};
})
.ConfigureAwait(false);
var deploymentTargetName = GetDeploymentTargetName(isTenantScoped, isSubscriptionScoped, subscription, resourceGroup);
resourceLogger.LogInformation("Deploying {Name} to {DeploymentTarget}", resource.Name, deploymentTargetName);
logger.LogDebug("Starting deployment of resource {ResourceName} to {DeploymentTarget}", resource.Name, deploymentTargetName);
var deploymentContent = new ArmDeploymentContent(new(ArmDeploymentMode.Incremental)
{
Template = BinaryData.FromString(armTemplateContents),
Parameters = BinaryData.FromObjectAsJson(parameters),
DebugSettingDetailLevel = "ResponseContent"
});
if (isTenantScoped || isSubscriptionScoped)
{
deploymentContent.Location = new AzureLocation(effectiveLocation);
}
ArmOperation<ArmDeploymentResource> operation;
try
{
operation = await deployments.CreateOrUpdateAsync(WaitUntil.Started, deploymentName, deploymentContent, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
if (context.ExecutionContext.IsRunMode &&
await TryCancelDeploymentAsync(deployments, deploymentName, resourceLogger, treatMissingOrInactiveAsCanceled: false).ConfigureAwait(false))
{
var sectionName = $"Azure:Deployments:{resource.Name}";
var canceledStateSection = await deploymentStateManager.AcquireSectionAsync(sectionName, CancellationToken.None).ConfigureAwait(false);
var canceledLocationOverride = canceledStateSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue<string>();
UpdateDeploymentState(canceledStateSection, canceledLocationOverride, deploymentId, parameters, outputObj: null, scope, checksum, effectiveLocation, DeploymentStateProvisioningStateCanceled);
await deploymentStateManager.SaveSectionAsync(canceledStateSection, CancellationToken.None).ConfigureAwait(false);
}
throw;
}
catch (RequestFailedException ex)
{
if (context.ExecutionContext.IsRunMode &&
IsActiveDeploymentConflict(ex) &&
await TryAdoptActiveDeploymentConflictAsync(
resource,
context,
deploymentId,
checksum,
effectiveLocation,
cancellationToken).ConfigureAwait(false))
{
return;
}
// Some validation failures occur before Azure creates a deployment operation. In that
// path there is no operation list to poll, so parse and enrich the provider error from
// the CreateOrUpdate response itself.
var failureDetails = AzureProvisioningFailureDetails.FromRequestFailedException(ex, AzureProvisioningFailureDetails.ProvisionOperation);
if (!failureDetails.IsLocationAvailabilityFailure)
{
LogProvisioningFailure(resourceLogger, failureDetails);
throw;
}
failureDetails = await EnrichFailureDetailsAsync(failureDetails, context, effectiveLocation, cancellationToken).ConfigureAwait(false);
if (context.ExecutionContext.IsRunMode)
{
await notificationService.PublishUpdateAsync(resource, state => state with
{
State = new(AzureProvisioningStrings.ResourceStateAzureDeploymentFailed, KnownResourceStateStyles.Error),
Properties = failureDetails.SetResourceProperties(WithoutDeploymentOperationProperties(state.Properties), AzureProvisioningFailureDetails.ProvisionOperation)
}).ConfigureAwait(false);
}
LogProvisioningFailure(resourceLogger, failureDetails);
throw new AzureProvisioningFailureException(failureDetails, ex);
}
// Run mode keeps persisting deployment state and final diagnostics after cancellation so
// Ctrl+C leaves enough information for recovery. Publish/deploy should still honor the
// caller's cancellation token because those operations are command-scoped.
var statePersistenceCancellationToken = context.ExecutionContext.IsRunMode ? CancellationToken.None : cancellationToken;
DeploymentStateSection? stateSection = null;
string? locationOverride = null;
if (context.ExecutionContext.IsRunMode)
{
var sectionName = $"Azure:Deployments:{resource.Name}";
stateSection = await deploymentStateManager.AcquireSectionAsync(sectionName, statePersistenceCancellationToken).ConfigureAwait(false);
locationOverride = stateSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue<string>();
UpdateDeploymentState(stateSection, locationOverride, deploymentId, parameters, outputObj: null, scope, checksum, effectiveLocation, DeploymentStateProvisioningStateRunning);
await deploymentStateManager.SaveSectionAsync(stateSection, statePersistenceCancellationToken).ConfigureAwait(false);
}
// Resolve the deployment URL before waiting for the operation to complete
var url = GetDeploymentUrl(deploymentId);
resourceLogger.LogInformation("Deployment started: {Url}", url);
await notificationService.PublishUpdateAsync(resource, state =>
{
return state with
{
State = new(AzureProvisioningController.WaitingForDeploymentState, KnownResourceStateStyles.Info),
Urls = [.. state.Urls, new(Name: DeploymentUrlName, Url: url, IsInternal: false)],
Properties = state.Properties.SetResourceProperty(CustomResourceKnownProperties.Source, deploymentId.ToString()),
};
})
.ConfigureAwait(false);
using var deploymentOperationTrackingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var deploymentOperationTracker = new DeploymentOperationProgressTracker();
// Operation polling is run-mode UX only. Publish/deploy paths do a single operation lookup
// after an LRO failure so CLI deployments get the same provider-level diagnostics without
// adding steady-state polling to successful deployments.
var deploymentOperationTrackingTask = context.ExecutionContext.IsRunMode
? TrackDeploymentOperationsAsync(resource, context, deploymentId, effectiveLocation, deploymentOperationTracker, deploymentOperationTrackingCts.Token)
: Task.CompletedTask;
try
{
await operation.WaitForCompletionAsync(cancellationToken).ConfigureAwait(false);
if (context.ExecutionContext.IsRunMode)
{
await PublishDeploymentOperationSummaryAsync(resource, context, deploymentId, effectiveLocation, deploymentOperationTracker, force: true, statePersistenceCancellationToken).ConfigureAwait(false);
}
}
catch (RequestFailedException ex)
{
var summary = await PublishDeploymentOperationSummaryAsync(
resource,
context,
deploymentId,
effectiveLocation,
deploymentOperationTracker,
force: true,
statePersistenceCancellationToken).ConfigureAwait(false);
if (summary.FailedOperations.FirstOrDefault(static operation => operation.FailureDetails is not null)?.FailureDetails is { } failureDetails)
{
LogProvisioningFailure(resourceLogger, failureDetails);
throw new AzureProvisioningFailureException(failureDetails, ex);
}
var requestFailureDetails = AzureProvisioningFailureDetails.FromRequestFailedException(ex, AzureProvisioningFailureDetails.ProvisionOperation);
if (requestFailureDetails.IsLocationAvailabilityFailure)
{
requestFailureDetails = await EnrichFailureDetailsAsync(requestFailureDetails, context, effectiveLocation, cancellationToken).ConfigureAwait(false);
LogProvisioningFailure(resourceLogger, requestFailureDetails);
throw new AzureProvisioningFailureException(requestFailureDetails, ex);
}
LogProvisioningFailure(resourceLogger, requestFailureDetails);
throw;
}
catch (OperationCanceledException)
{
if (await TryCancelDeploymentAsync(deployments, deploymentName, resourceLogger, treatMissingOrInactiveAsCanceled: true).ConfigureAwait(false) &&
stateSection is not null)
{
UpdateDeploymentState(stateSection, locationOverride, deploymentId, parameters, outputObj: null, scope, checksum, effectiveLocation, DeploymentStateProvisioningStateCanceled);
await deploymentStateManager.SaveSectionAsync(stateSection, statePersistenceCancellationToken).ConfigureAwait(false);
}
throw;
}
finally
{
deploymentOperationTrackingCts.Cancel();
await deploymentOperationTrackingTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
sw.Stop();
resourceLogger.LogInformation("Deployment of {Name} to {DeploymentTarget} took {Elapsed}", resource.Name, deploymentTargetName, sw.Elapsed);
logger.LogDebug("Deployment of resource {ResourceName} to {DeploymentTarget} completed in {Elapsed}", resource.Name, deploymentTargetName, sw.Elapsed);
var deployment = operation.Value;
var provisioningState = deployment.Data.Properties.ProvisioningState;
if (provisioningState == ResourcesProvisioningState.Succeeded)
{
if (context.ExecutionContext.IsRunMode)
{
template.Dispose();
}
}
else
{
if (stateSection is not null)
{
UpdateDeploymentState(stateSection, locationOverride, deployment.Id, parameters, outputObj: null, scope, checksum, effectiveLocation, provisioningState.ToString());
await deploymentStateManager.SaveSectionAsync(stateSection, statePersistenceCancellationToken).ConfigureAwait(false);
}
resourceLogger.LogError("Azure deployment of {Name} to {DeploymentTarget} completed with provisioning state {ProvisioningState}.", resource.Name, deploymentTargetName, provisioningState);
throw new InvalidOperationException($"Deployment of {resource.Name} to {deploymentTargetName} failed with {provisioningState}");
}
// e.g. { "sqlServerName": { "type": "String", "value": "<value>" }}
var outputs = deployment.Data.Properties.Outputs;
var outputObj = outputs?.ToObjectFromJson<JsonObject>();
if (stateSection is null)
{
var sectionName = $"Azure:Deployments:{resource.Name}";
stateSection = await deploymentStateManager.AcquireSectionAsync(sectionName, statePersistenceCancellationToken).ConfigureAwait(false);
locationOverride = stateSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue<string>();
}
UpdateDeploymentState(stateSection, locationOverride, deployment.Id, parameters, outputObj, scope, checksum, effectiveLocation, provisioningState: null);
await deploymentStateManager.SaveSectionAsync(stateSection, statePersistenceCancellationToken).ConfigureAwait(false);
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 (resource is IAzureKeyVaultResource kvr)
{
ConfigureSecretResolver(kvr);
}
await notificationService.PublishUpdateAsync(resource, state =>
{
ImmutableArray<ResourcePropertySnapshot> properties = state.Properties.WithoutAzureProvisioningFailureProperties()
.SetResourcePropertyRange(AzureResourceProperties.CreateContextProperties(
subscription.Id.Name,
resourceGroupName,
context.Tenant.TenantId?.ToString(),
context.Tenant.DefaultDomain,
effectiveLocation,
includeEmptyProperties: true))
.SetResourceProperty(CustomResourceKnownProperties.Source, deployment.Id.ToString());
return state with
{
State = new(AzureProvisioningController.ProvisionedState, KnownResourceStateStyles.Success),
CreationTimeStamp = _timeProvider.GetUtcNow().UtcDateTime,
Properties = properties
};
})
.ConfigureAwait(false);
}
// Handles ARM 409 DeploymentActive responses by adopting the deployment that Azure says is
// already running, but only when persisted state proves it belongs to the same resource model.
// If any proof is missing, stale, or unreadable, return false so the caller uses the normal
// failure path instead of accidentally adopting an unrelated deployment.
private async Task<bool> TryAdoptActiveDeploymentConflictAsync(
AzureBicepResource resource,
ProvisioningContext context,
ResourceIdentifier deploymentId,
string checksum,
string effectiveLocation,
CancellationToken cancellationToken)
{
var sectionName = $"Azure:Deployments:{resource.Name}";
var stateSection = await deploymentStateManager.AcquireSectionAsync(sectionName, cancellationToken).ConfigureAwait(false);
var cachedDeploymentId = stateSection.Data[BicepUtilities.DeploymentStateIdKey]?.GetValue<string>();
var cachedChecksum = stateSection.Data[BicepUtilities.DeploymentStateChecksumKey]?.GetValue<string>();
// The active deployment must be the same ARM deployment and the same compiled model checksum
// that this AppHost is trying to run. Otherwise, the 409 could be from another command or a
// previous model, and adopting it would publish the wrong outputs.
if (!IsRunningCachedDeployment(stateSection) ||
!string.Equals(cachedDeploymentId, deploymentId.ToString(), StringComparisons.AzureResourceId) ||
!string.Equals(cachedChecksum, checksum, StringComparison.Ordinal))
{
logger.LogDebug("Azure deployment {DeploymentId} for {ResourceName} is active, but cached state does not match the current deployment request.", deploymentId, resource.Name);
return false;
}
AzureDeploymentState? deployment;
try
{
// ARM is the source of truth after the 409. The cache proves ownership, but ARM tells us
// whether the deployment is still active, terminal, or already gone.
deployment = await context.ArmClient.GetDeploymentAsync(deploymentId.ToString(), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (RequestFailedException ex)
{
logger.LogDebug(ex, "Unable to adopt active Azure deployment {DeploymentId} for {ResourceName} because the Azure deployment probe failed.", deploymentId, resource.Name);
return false;
}
catch (CredentialUnavailableException ex)
{
logger.LogDebug(ex, "Unable to adopt active Azure deployment {DeploymentId} for {ResourceName} because no Azure credential is available.", deploymentId, resource.Name);
return false;
}
if (deployment is null)
{
// The active-deployment conflict raced with deletion or cleanup. Do not clear local state
// here because this path runs while handling a failed create attempt; let the caller surface
// the original ARM failure.
logger.LogDebug("Unable to adopt active Azure deployment {DeploymentId} for {ResourceName} because it could not be found.", deploymentId, resource.Name);
return false;
}
var currentLocation = GetConfiguredLocation(stateSection, effectiveLocation);
if (IsActiveDeploymentProvisioningState(deployment.ProvisioningState))
{
// The deployment is still running, so wait on the existing ARM operation rather than
// starting a duplicate deployment with the same name.
logger.LogInformation("Azure deployment {DeploymentId} for {ResourceName} is already active. Waiting for the existing deployment to complete.", deploymentId, resource.Name);
deployment = await WaitForCachedRunningDeploymentAsync(
resource,
context,
deploymentId.ToString(),
currentLocation,
deployment,
cancellationToken).ConfigureAwait(false);
if (deployment is null)
{
// If the deployment disappears while we are waiting, adoption is no longer possible.
// Return false so the caller keeps the original conflict behavior.
logger.LogDebug("Unable to adopt active Azure deployment {DeploymentId} for {ResourceName} because it disappeared while waiting.", deploymentId, resource.Name);
return false;
}
}
// The conflict pointed at an existing deployment for this model. If it has
// now reached a known terminal state, adopt that result instead of starting
// another deployment.
if (await TryApplyTerminalReconciledDeploymentStateAsync(resource, stateSection, deploymentId, deployment, currentLocation, cancellationToken).ConfigureAwait(false))
{
return true;
}
// A non-terminal or unknown ARM state cannot be safely mapped to resource outputs or a dashboard
// terminal state, so leave it unadopted and let the original active-deployment failure stand.
logger.LogDebug("Unable to adopt active Azure deployment {DeploymentId} for {ResourceName} because provisioning state is {ProvisioningState}.", deploymentId, resource.Name, deployment.ProvisioningState);
return false;
}
private static bool IsActiveDeploymentConflict(RequestFailedException ex)
=> ex.Status == 409 &&
string.Equals(ex.ErrorCode, DeploymentActiveErrorCode, StringComparisons.AzureProvisioningErrorCode);
private async Task TrackDeploymentOperationsAsync(
AzureBicepResource resource,
ProvisioningContext context,
ResourceIdentifier deploymentId,
string currentLocation,
DeploymentOperationProgressTracker tracker,
CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
await PublishDeploymentOperationSummaryAsync(resource, context, deploymentId, currentLocation, tracker, force: false, cancellationToken).ConfigureAwait(false);
await Task.Delay(s_deploymentOperationPollingInterval, _timeProvider, cancellationToken).ConfigureAwait(false);
}
}
private async Task<AzureDeploymentOperationSummary> PublishDeploymentOperationSummaryAsync(
AzureBicepResource resource,
ProvisioningContext context,
ResourceIdentifier deploymentId,
string currentLocation,
DeploymentOperationProgressTracker tracker,
bool force,
CancellationToken cancellationToken)
{
AzureDeploymentOperationSummary summary;
try
{
summary = await GetDeploymentOperationSummaryAsync(context, deploymentId, currentLocation, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// Deployment operation polling is best-effort status reporting. The ARM deployment
// operation itself remains the source of truth and will surface terminal failures.
logger.LogDebug(ex, "Unable to query Azure deployment operations for {DeploymentId}.", deploymentId);
return tracker.Current;
}
if (!tracker.TryUpdate(summary, force))
{
return summary;
}
await notificationService.PublishUpdateAsync(resource, state =>
{
var properties = WithoutDeploymentOperationProperties(state.Properties).SetResourcePropertyRange(CreateDeploymentOperationProperties(summary));
if (summary.FailedOperations.FirstOrDefault(static operation => operation.FailureDetails is not null)?.FailureDetails is { } failureDetails)
{
properties = failureDetails.SetResourceProperties(properties, AzureProvisioningFailureDetails.ProvisionOperation);
}
return state with
{
State = CreateDeploymentOperationState(summary),
Properties = properties
};
})
.ConfigureAwait(false);
return summary;
}
private async Task<AzureDeploymentOperationSummary> GetDeploymentOperationSummaryAsync(
ProvisioningContext context,
ResourceIdentifier deploymentId,
string currentLocation,
CancellationToken cancellationToken)
{
var operations = ImmutableArray.CreateBuilder<AzureDeploymentOperationDetails>();
var runningOperations = ImmutableArray.CreateBuilder<AzureDeploymentOperationDetails>();
var succeededOperations = ImmutableArray.CreateBuilder<AzureDeploymentOperationDetails>();
var failedOperations = ImmutableArray.CreateBuilder<AzureDeploymentOperationDetails>();
var canceledOperations = ImmutableArray.CreateBuilder<AzureDeploymentOperationDetails>();
var enrichmentTasks = new List<(int OperationIndex, Task<AzureProvisioningFailureDetails> EnrichmentTask)>();
await foreach (var operation in context.ArmClient.GetDeploymentOperationsAsync(deploymentId.ToString(), recursive: true, cancellationToken).ConfigureAwait(false))
{
if (operation.FailureDetails is { } failureDetails)
{
// Supported-location lookups are independent provider metadata calls. Start them as
// operations arrive and await them together so multiple failed resources do not add
// serial ARM round trips to failure reporting.
enrichmentTasks.Add((operations.Count, EnrichFailureDetailsAsync(failureDetails, context, currentLocation, cancellationToken)));
}
operations.Add(operation);
}
if (enrichmentTasks.Count > 0)
{
await Task.WhenAll(enrichmentTasks.Select(static enrichment => enrichment.EnrichmentTask)).ConfigureAwait(false);
foreach (var (operationIndex, enrichmentTask) in enrichmentTasks)
{
operations[operationIndex] = operations[operationIndex] with
{
FailureDetails = await enrichmentTask.ConfigureAwait(false)
};
}
}
foreach (var operationDetails in operations)
{
if (operationDetails is not { IsCreateOperation: true, TargetResource: not null } ||
operationDetails.IsNestedDeploymentCreate)
{
continue;
}
if (string.Equals(operationDetails.ProvisioningState, AzureDeploymentOperationDetails.RunningState, StringComparisons.AzureProvisioningState))
{
runningOperations.Add(operationDetails);
}
else if (string.Equals(operationDetails.ProvisioningState, AzureDeploymentOperationDetails.SucceededState, StringComparisons.AzureProvisioningState))
{
succeededOperations.Add(operationDetails);
}
else if (string.Equals(operationDetails.ProvisioningState, AzureDeploymentOperationDetails.FailedState, StringComparisons.AzureProvisioningState))
{
failedOperations.Add(operationDetails);
}
else if (string.Equals(operationDetails.ProvisioningState, AzureDeploymentOperationDetails.CanceledState, StringComparisons.AzureProvisioningState))
{
canceledOperations.Add(operationDetails);
}
}
return new(
Operations: operations.ToImmutable(),
RunningOperations: runningOperations.ToImmutable(),
SucceededOperations: succeededOperations.ToImmutable(),
FailedOperations: failedOperations.ToImmutable(),
CanceledOperations: canceledOperations.ToImmutable());
}
private async Task<AzureProvisioningFailureDetails> EnrichFailureDetailsAsync(
AzureProvisioningFailureDetails failureDetails,
ProvisioningContext context,
string currentLocation,
CancellationToken cancellationToken)
{
if (!failureDetails.IsLocationAvailabilityFailure)
{
return context.ExecutionContext.IsRunMode
? failureDetails
: failureDetails.WithDeploymentRecommendedActions();
}
try
{
var supportedLocations = await context.ArmClient.GetSupportedLocationsAsync(context.Subscription.Id.Name, failureDetails.ResourceType!, cancellationToken).ConfigureAwait(false);
var enrichedFailureDetails = failureDetails.WithLocationAvailability(currentLocation, supportedLocations);
return context.ExecutionContext.IsRunMode
? enrichedFailureDetails
: enrichedFailureDetails.WithDeploymentRecommendedActions();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// Provider metadata is advisory diagnostic context. Preserve the provider error even
// when ARM blocks or throttles the supported-location lookup.
logger.LogDebug(ex, "Unable to query supported Azure locations for resource type {ResourceType}.", failureDetails.ResourceType);
var enrichedFailureDetails = failureDetails.WithLocationAvailability(currentLocation, []);
return context.ExecutionContext.IsRunMode
? enrichedFailureDetails
: enrichedFailureDetails.WithDeploymentRecommendedActions();
}
}
private static ImmutableArray<ResourcePropertySnapshot> CreateDeploymentOperationProperties(AzureDeploymentOperationSummary summary)
{
var properties = ImmutableArray.CreateBuilder<ResourcePropertySnapshot>();
if (CreateDeploymentOperationSummaryProperty(summary) is { } summaryProperty)
{
properties.Add(summaryProperty);
}
properties.Add(new("azure.deployment.operations.total", summary.Operations.Length));
properties.Add(new("azure.deployment.operations.running", summary.RunningOperations.Length));
properties.Add(new("azure.deployment.operations.succeeded", summary.SucceededOperations.Length));
properties.Add(new("azure.deployment.operations.failed", summary.FailedOperations.Length));
properties.Add(new("azure.deployment.operations.canceled", summary.CanceledOperations.Length));
AddResourceLabels("azure.deployment.operations.running.resources", summary.RunningOperations);
AddResourceLabels("azure.deployment.operations.succeeded.resources", summary.SucceededOperations);
AddResourceLabels("azure.deployment.operations.failed.resources", summary.FailedOperations);
AddResourceLabels("azure.deployment.operations.canceled.resources", summary.CanceledOperations);
return properties.ToImmutable();
void AddResourceLabels(string propertyName, ImmutableArray<AzureDeploymentOperationDetails> operations)
{
var labels = CreateOperationResourceLabels(operations);
if (labels.Length > 0)
{
properties.Add(new(propertyName, labels));
}
}
}
private static ResourcePropertySnapshot? CreateDeploymentOperationSummaryProperty(AzureDeploymentOperationSummary summary)
{
if (TryCreateSummaryValue(summary.FailedOperations, AzureProvisioningStrings.DeploymentOperationFailedResourcesFormat, out var failedValue))
{
return CreateSummaryProperty(failedValue);
}
if (TryCreateSummaryValue(summary.CanceledOperations, AzureProvisioningStrings.DeploymentOperationCanceledResourcesFormat, out var canceledValue))
{
return CreateSummaryProperty(canceledValue);
}
if (TryCreateSummaryValue(summary.RunningOperations, AzureProvisioningStrings.DeploymentOperationRunningResourcesFormat, out var runningValue))
{
return CreateSummaryProperty(runningValue);
}
return null;
static bool TryCreateSummaryValue(ImmutableArray<AzureDeploymentOperationDetails> operations, string format, [NotNullWhen(true)] out string? value)
{
var labels = CreateOperationResourceLabels(operations);
if (labels.Length > 0)
{
value = string.Format(CultureInfo.CurrentCulture, format, string.Join(", ", labels));
return true;
}
value = null;
return false;
}
static ResourcePropertySnapshot CreateSummaryProperty(string value)
{
return new(DeploymentOperationSummaryPropertyName, value)
{
DisplayName = AzureProvisioningStrings.DeploymentOperationSummaryDisplayName,
IsHighlighted = true
};
}
}
private static ImmutableArray<ResourcePropertySnapshot> WithoutDeploymentOperationProperties(ImmutableArray<ResourcePropertySnapshot> properties)
{
if (properties.IsDefaultOrEmpty)
{
return [];
}
if (!properties.Any(static property => property.Name.StartsWith(DeploymentOperationPropertyPrefix, StringComparison.Ordinal)))
{
return properties;
}
// A later deployment can fail before operations exist, or the set of running/succeeded
// resources can shrink between polls. Filter all old operation properties before publishing
// new state so describe output never mixes a fresh failure with stale resource counters.
return [.. properties.Where(static property => !property.Name.StartsWith(DeploymentOperationPropertyPrefix, StringComparison.Ordinal))];
}
private static string[] CreateOperationResourceLabels(IEnumerable<AzureDeploymentOperationDetails> operations)
{
return
[
.. operations
.Select(static operation => operation.TargetResource switch
{
{ ResourceName.Length: > 0, ResourceType.Length: > 0 } target => $"{target.ResourceName} ({target.ResourceType})",
{ ResourceName.Length: > 0 } target => target.ResourceName,
{ ResourceType.Length: > 0 } target => target.ResourceType,
{ Id.Length: > 0 } target => target.Id,
_ => null
})
.Where(static label => !string.IsNullOrEmpty(label))
.Select(static label => label!)
.Distinct(StringComparers.AzureResourceName)
.OrderBy(static label => label, StringComparers.AzureResourceName)
];
}
private static ResourceStateSnapshot CreateDeploymentOperationState(AzureDeploymentOperationSummary summary)
{
if (summary.FailedOperations.Length > 0)
{
return new(AzureProvisioningStrings.ResourceStateAzureDeploymentFailed, KnownResourceStateStyles.Error);
}
var runningLabels = CreateOperationResourceLabels(summary.RunningOperations);
if (runningLabels.Length > 0)
{
var runningText = runningLabels.Length == 1
? string.Format(CultureInfo.CurrentCulture, AzureProvisioningStrings.ResourceStateProvisioningResourceFormat, runningLabels[0])
: string.Format(CultureInfo.CurrentCulture, AzureProvisioningStrings.ResourceStateProvisioningMultipleAzureResourcesFormat, runningLabels.Length);
return new(runningText, KnownResourceStateStyles.Info);
}
if (summary.SucceededOperations.Length > 0)
{
return new(
string.Format(CultureInfo.CurrentCulture, AzureProvisioningStrings.ResourceStateProvisionedMultipleAzureResourcesFormat, summary.SucceededOperations.Length),
KnownResourceStateStyles.Info);
}
return new(AzureProvisioningController.WaitingForDeploymentState, KnownResourceStateStyles.Info);
}
private static void LogProvisioningFailure(ILogger resourceLogger, AzureProvisioningFailureDetails failureDetails)
{
resourceLogger.LogError("Azure provisioning failed: {Message}{Details}", failureDetails.ErrorMessage, CreateFailureLogDetails(failureDetails));
}
private static string CreateFailureLogDetails(AzureProvisioningFailureDetails failureDetails)
{
var details = new List<string>();
AddDetail("code", failureDetails.ErrorCode);
AddDetail("resource type", failureDetails.ResourceType);
AddDetail("resource name", failureDetails.ResourceName);
AddDetail("target resource", failureDetails.TargetResourceId);
AddDetail("current location", failureDetails.CurrentLocation);
AddDetail("request id", failureDetails.RequestId);
AddDetail("correlation id", failureDetails.CorrelationId);
return details.Count == 0 ? string.Empty : $" ({string.Join("; ", details)})";
void AddDetail(string name, string? value)
{
if (!string.IsNullOrEmpty(value))
{
details.Add($"{name}: {value}");
}
}
}
private static async Task<string> ResolveScopeValueAsync(object scopeValue, CancellationToken cancellationToken)
{
return scopeValue switch
{
string value => value,
IValueProvider valueProvider => await valueProvider.GetValueAsync(cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The Azure resource scope value cannot be null."),
_ => throw new NotSupportedException($"The scope value type {scopeValue.GetType()} is not supported.")
};
}
private async Task<bool> TryCancelDeploymentAsync(IArmDeploymentCollection deployments, string deploymentName, ILogger resourceLogger, bool treatMissingOrInactiveAsCanceled)
{
try
{
await deployments.CancelAsync(deploymentName, CancellationToken.None).ConfigureAwait(false);
resourceLogger.LogInformation("Cancellation requested for Azure deployment {DeploymentName}.", deploymentName);
return true;
}
catch (RequestFailedException ex) when (treatMissingOrInactiveAsCanceled && (ex.Status == 404 || ex.Status == 409))
{
logger.LogInformation(ex, "Azure deployment {DeploymentName} was already absent or no longer active during cancellation.", deploymentName);
resourceLogger.LogInformation("Azure deployment {DeploymentName} was already absent or no longer active during cancellation.", deploymentName);
return true;
}
catch (RequestFailedException ex)
{
logger.LogWarning(ex, "Failed to cancel Azure deployment {DeploymentName}.", deploymentName);
resourceLogger.LogWarning("Failed to cancel Azure deployment {DeploymentName}: {Message}", deploymentName, ex.Message);
return false;
}
}
private static void UpdateDeploymentState(
DeploymentStateSection stateSection,
string? locationOverride,
ResourceIdentifier deploymentId,
JsonObject parameters,
JsonObject? outputObj,
JsonObject? scope,
string checksum,
string effectiveLocation,
string? provisioningState)
{
stateSection.Data.Clear();
// Only preserve a per-resource override when it still matches the resource we just deployed. This keeps
// run-mode reprovisioning sticky while allowing global context changes to clear stale overrides naturally.
if (!string.IsNullOrEmpty(locationOverride) &&
string.Equals(locationOverride, effectiveLocation, StringComparisons.AzureLocation))
{
stateSection.Data[AzureProvisioningController.LocationOverrideKey] = locationOverride;
}
stateSection.Data[BicepUtilities.DeploymentStateIdKey] = deploymentId.ToString();
stateSection.Data[BicepUtilities.DeploymentStateParametersKey] = parameters.ToJsonString();
if (outputObj is not null)
{
stateSection.Data[BicepUtilities.DeploymentStateOutputsKey] = outputObj.ToJsonString();
}
if (scope is not null)
{
stateSection.Data[BicepUtilities.DeploymentStateScopeKey] = scope.ToJsonString();
}
stateSection.Data[BicepUtilities.DeploymentStateChecksumKey] = checksum;
if (!string.IsNullOrEmpty(provisioningState))
{
stateSection.Data[DeploymentStateProvisioningStateKey] = provisioningState;
}
}
private void ConfigureSecretResolver(IAzureKeyVaultResource kvr)
{
var resource = (AzureBicepResource)kvr;
var vaultUri = resource.Outputs[kvr.VaultUriOutputReference.Name] as string ?? throw new InvalidOperationException($"{kvr.VaultUriOutputReference.Name} not found in outputs.");
// Set the client for resolving secrets at runtime
var client = secretClientProvider.GetSecretClient(new(vaultUri));
kvr.SecretResolver = async (secretRef, ct) =>
{
var secret = await client.GetSecretAsync(secretRef.SecretName, cancellationToken: ct).ConfigureAwait(false);
return secret.Value.Value;
};
}
private static void PopulateWellKnownParameters(AzureBicepResource resource, ProvisioningContext context)
{
static void ValidateUnknownPrincipalParameter(ProvisioningContext context)
{
// Application principal parameters can only be populated in run mode. Published artifacts
// bind them from their outer template because the application identity can differ from the
// credential applying the deployment.
// We assume that the BicepProvisioner only runs in publish mode during `aspire deploy` operations
// and not from azd. azd fills in principal parameters during its deployment process with a managed
// identity it creates. The deployment-principal parameters are the exception: direct `aspire deploy`
// intentionally binds them from the current credential because that identity performs subsequent
// data-plane operations.
if (context.ExecutionContext.IsPublishMode)
{
throw new InvalidOperationException("An Azure principal parameter was not supplied a value. Ensure you are using an environment that supports role assignments, for example AddAzureContainerAppEnvironment.");
}
}
if (resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.PrincipalId, out var principalId) && principalId is null)
{
ValidateUnknownPrincipalParameter(context);
resource.Parameters[AzureBicepResource.KnownParameters.PrincipalId] = context.Principal.Id;
}
var hasUserPrincipalId = resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.UserPrincipalId, out var userPrincipalId);
var populatedUserPrincipalId = false;
if (hasUserPrincipalId && userPrincipalId is null)
{
// Published artifacts bind this deployment-principal parameter from the outer
// main.bicep template. Direct `aspire deploy` has no outer template, so use the
// authenticated principal that performs the data-plane deployment.
resource.Parameters[AzureBicepResource.KnownParameters.UserPrincipalId] = context.Principal.Id;
populatedUserPrincipalId = true;
}
if (resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.PrincipalName, out var principalName) && principalName is null)
{
ValidateUnknownPrincipalParameter(context);
resource.Parameters[AzureBicepResource.KnownParameters.PrincipalName] = context.Principal.Name;
}
if (resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.PrincipalType, out var principalType) && principalType is null)
{
if (hasUserPrincipalId && !populatedUserPrincipalId)
{
throw new InvalidOperationException(
$"The Azure parameter '{AzureBicepResource.KnownParameters.PrincipalType}' must be supplied when " +
$"'{AzureBicepResource.KnownParameters.UserPrincipalId}' is provided explicitly.");
}
if (!hasUserPrincipalId)
{
ValidateUnknownPrincipalParameter(context);
}
// Use the principal type detected from the credential's access token (the `idtyp`
// claim) instead of hardcoding "User". A hardcoded "User" caused the role-assignment
// `-roles` deployments synthesized by AzureResourcePreparer to fail with
// `UnmatchedPrincipalType` / `PrincipalNotFound` whenever the AppHost ran under a
// service-principal / federated-workload-identity credential (CI, CD, deploy bots).
// See https://github.com/microsoft/aspire/issues/13933.
resource.Parameters[AzureBicepResource.KnownParameters.PrincipalType] = context.Principal.Type;
}
if (resource.HasLocationParameter &&
(!resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.Location, out var location) || location is null))
{
resource.Parameters[AzureBicepResource.KnownParameters.Location] = context.Location.Name;
}
}
public static string GetDeploymentUrl(ResourceIdentifier deploymentId) =>
AzurePortalUrls.GetDeploymentUrl(deploymentId);
private static string GetDeploymentTargetName(bool isTenantScoped, bool isSubscriptionScoped, ISubscriptionResource subscription, IResourceGroupResource resourceGroup)
{
if (isTenantScoped)
{
return "tenant";
}
if (isSubscriptionScoped)
{
return $"subscription {subscription.Id.Name}";
}
return $"resource group {resourceGroup.Name}";
}
private static ResourceIdentifier GetDeploymentId(bool isTenantScoped, bool isSubscriptionScoped, ISubscriptionResource subscription, IResourceGroupResource resourceGroup, string deploymentName)
{
var deploymentPath = isTenantScoped
? $"/providers/Microsoft.Resources/deployments/{deploymentName}"
: isSubscriptionScoped
? $"{subscription.Id}/providers/Microsoft.Resources/deployments/{deploymentName}"
: $"{resourceGroup.Id}/providers/Microsoft.Resources/deployments/{deploymentName}";
return new(deploymentPath);
}
private async Task<AzureContextState> GetCurrentAzureContextAsync(ResourceIdentifier? deploymentId, CancellationToken cancellationToken)
{
var section = await deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false);
return new AzureContextState(
GetStateValue(section, "SubscriptionId") ?? deploymentId?.SubscriptionId,
deploymentId is not null ? deploymentId.ResourceGroupName : GetStateValue(section, "ResourceGroup"),
GetStateValue(section, "TenantId"),
GetStateValue(section, "Tenant"),
GetStateValue(section, "Location"));
}
private static string? GetStateValue(DeploymentStateSection section, string key) =>
section.Data[key]?.GetValue<string>() is { Length: > 0 } value ? value : null;
private static string GetConfiguredLocation(DeploymentStateSection section, string? fallbackLocation)
{
if (section.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue<string>() is { Length: > 0 } locationOverride)
{
return locationOverride;
}
if (section.Data[BicepUtilities.DeploymentStateParametersKey]?.GetValue<string>() is { Length: > 0 } parametersJson)
{
try
{
if (JsonNode.Parse(parametersJson)?[AzureBicepResource.KnownParameters.Location]?["value"]?.GetValue<string>() is { Length: > 0 } configuredLocation)
{
return configuredLocation;
}
}
catch
{
}
}
return fallbackLocation ?? string.Empty;
}
private static string GetEffectiveLocation(AzureBicepResource resource, ProvisioningContext context) =>
resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.Location, out var location) && location is not null
? location.ToString() ?? context.Location.ToString()
: context.Location.ToString();
private sealed class DeploymentOperationProgressTracker
{
private string? _signature;
public AzureDeploymentOperationSummary Current { get; private set; } = AzureDeploymentOperationSummary.Empty;
public bool TryUpdate(AzureDeploymentOperationSummary summary, bool force)
{
var signature = CreateSignature(summary);
if (!force && string.Equals(signature, _signature, StringComparison.Ordinal))
{
return false;
}
Current = summary;
_signature = signature;
return true;
}
private static string CreateSignature(AzureDeploymentOperationSummary summary)
{
return string.Join(
"|",
summary.Operations
.OrderBy(static operation => operation.OperationId, StringComparer.Ordinal)
.Select(static operation => $"{operation.OperationId}:{operation.ProvisioningState}:{operation.TargetResource?.Id}:{operation.FailureDetails?.ErrorCode}"));
}
}
private sealed record AzureContextState(string? SubscriptionId, string? ResourceGroup, string? TenantId, string? TenantDomain, string? Location);
}