// 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 ASPIREAZURE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Channels;
using Aspire.Dashboard.Model;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure.Provisioning;
using Aspire.Hosting.Azure.Provisioning.Internal;
using Aspire.Hosting.Azure.Resources;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Pipelines;
using Azure;
using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Aspire.Hosting.Azure;
/// <summary>
/// Coordinates Azure run-mode provisioning, recovery, and drift detection through a single serialized control loop.
/// </summary>
/// <remarks>
/// <para>
/// The controller uses a channel-based queue with a single reader to serialize all Azure operations. Every
/// public method (provision, reprovision, reset, change-location, change-context, delete, drift-check) wraps
/// a typed intent record and writes it to the channel. A background loop dequeues one intent at a time,
/// executes it, and completes the caller's TaskCompletionSource with the result. This eliminates races between
/// concurrent dashboard commands, CLI commands, and the periodic drift monitor.
/// </para>
/// <para>
/// Within a provisioning pass, individual resources are fanned out concurrently but ordered by dependency.
/// Each resource gets a per-resource ProvisioningTaskCompletionSource that downstream resources await before
/// starting their own deployment. This TCS is completed by CompleteProvisioning, FailProvisioning, or
/// CancelProvisioning — the only three completion paths — so dependent resources unblock with the prerequisite's
/// result as soon as it finishes, not when the entire batch completes.
/// </para>
/// <para>
/// The controller tracks lightweight in-memory state (AzureControllerState) under a lock. This state drives
/// command enablement in the dashboard (commands are disabled while an operation targeting the same resources
/// is running). Azure identity properties shown on the AzureEnvironmentResource are read from persisted
/// context when the environment state is published.
/// </para>
/// <para>
/// Location overrides let a user deploy a single resource to a different Azure region. Overrides are persisted
/// in the deployment state store and survive resets/reprovisioning. When a location change is requested, the
/// controller deletes the existing Azure resource first (to avoid ARM InvalidResourceLocation conflicts), sets
/// the override, and reprovisions.
/// </para>
/// <para>
/// Drift detection runs periodically. It probes ARM to verify each running resource still exists and
/// marks missing resources and the environment with drift states. The drift monitor queues at
/// most one check at a time through the same serialized channel.
/// </para>
/// <para>
/// The controller only orchestrates run-mode behavior. Deployment state persistence, Bicep compilation, and
/// ARM deployment are delegated to BicepProvisioner. Publish-time resource creation flows through separate
/// publishing contexts.
/// </para>
/// </remarks>
internal sealed class AzureProvisioningController(
IConfiguration configuration,
IOptions<AzureProvisionerOptions> provisionerOptions,
IServiceProvider serviceProvider,
IBicepProvisioner bicepProvisioner,
IDeploymentStateManager deploymentStateManager,
IDistributedApplicationEventing eventing,
IProvisioningContextProvider provisioningContextProvider,
IAzureProvisioningOptionsManager provisioningOptionsManager,
ResourceNotificationService notificationService,
ResourceLoggerService loggerService,
ILogger<AzureProvisioningController> logger)
{
internal const string ForgetStateCommandName = "forget-state";
internal const string ChangeResourceLocationCommandName = "change-location";
internal const string ConfirmDeleteArgumentName = "confirmDelete";
internal const string GetAzureResourceCommandName = "get-azure-resource";
internal const string CancelCommandName = "cancel-azure-operation";
internal const string DeleteAzureResourceCommandName = "delete-azure-resource";
internal const string ReprovisionResourceCommandName = "reprovision";
internal const string ResetProvisioningStateCommandName = "reset-provisioning-state";
internal const string ChangeAzureContextCommandName = "change-azure-context";
internal const string ReprovisionAllCommandName = "reprovision-all";
internal const string DeleteAzureResourcesCommandName = "delete-azure-resources";
internal const string LocationOverrideKey = "LocationOverride";
internal static string MissingInAzureState => AzureProvisioningStrings.ResourceStateMissingInAzure;
internal static string DriftedState => AzureProvisioningStrings.ResourceStateDrifted;
internal static string StartingState => AzureProvisioningStrings.ResourceStateStarting;
internal static string CompilingArmTemplateState => AzureProvisioningStrings.ResourceStateCompilingArmTemplate;
internal static string CreatingArmDeploymentState => AzureProvisioningStrings.ResourceStateCreatingArmDeployment;
internal static string WaitingForDeploymentState => AzureProvisioningStrings.ResourceStateWaitingForDeployment;
internal static string ChangingLocationState => AzureProvisioningStrings.ResourceStateChangingLocation;
internal static string DeletingState => AzureProvisioningStrings.ResourceStateDeleting;
internal static string CancelingState => AzureProvisioningStrings.ResourceStateCanceling;
internal static string ProvisionedState => AzureProvisioningStrings.ResourceStateProvisioned;
private const string KeyVaultVaultResourceType = "Microsoft.KeyVault/vaults";
private static string ProvisioningStatePrefix => AzureProvisioningStrings.ResourceStateProvisioningPrefix;
private const string SubscriptionIdArgumentName = "subscriptionId";
private const string ResourceGroupArgumentName = "resourceGroup";
private const string LocationArgumentName = "location";
private const string TenantIdArgumentName = "tenantId";
private static readonly string[] s_resettableProperties =
[
"azure.subscription.id",
"azure.resource.group",
"azure.tenant.domain",
"azure.tenant.id",
"azure.location",
AzureResourceProperties.OperationName,
AzureResourceProperties.OperationPhase,
AzureResourceProperties.OperationStatus,
AzureResourceProperties.OperationTargetLocation,
AzureResourceProperties.OperationStartedAt,
CustomResourceKnownProperties.Source
];
private readonly Channel<QueuedOperation> _operationChannel = Channel.CreateUnbounded<QueuedOperation>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
private readonly ILogger<AzureProvisioningController> _logger = logger;
private readonly TimeProvider _timeProvider = serviceProvider.GetService<TimeProvider>() ?? TimeProvider.System;
private readonly object _operationStateLock = new();
private readonly List<AzureOperationState> _queuedOperationStates = [];
private AzureControllerState _state = AzureControllerState.Empty;
private int _operationLoopStarted;
private int _driftMonitorStarted;
private int _azureContextNotificationStarted;
private bool _driftCheckQueued;
// Drift checks are intentionally periodic and non-overlapping. The monitor queues at most one check at a time so
// command execution and background drift probing share the same serialized control loop.
internal TimeSpan DriftCheckInterval { get; set; } = TimeSpan.FromSeconds(30);
// The dashboard uses declaration order when rendering commands. Highlighted commands are promoted ahead of
// the remaining command list, so keep each definition list ordered by the desired fallback display order.
internal static ImmutableArray<EnvironmentCommandDefinition> EnvironmentCommandDefinitions { get; } =
[
new(
AzureEnvironmentCommand.ResetProvisioningState,
ResetProvisioningStateCommandName,
AzureProvisioningStrings.ResetProvisioningStateCommandName,
AzureProvisioningStrings.ResetProvisioningStateCommandDescription,
AzureProvisioningStrings.ResetProvisioningStateCommandConfirmation,
"ArrowSync",
IconVariant.Regular,
IsHighlighted: true,
ExecuteCommand: static (controller, context) => controller.ExecuteResetProvisioningStateCommandAsync(context)),
new(
AzureEnvironmentCommand.ChangeAzureContext,
ChangeAzureContextCommandName,
AzureProvisioningStrings.ChangeAzureContextCommandName,
AzureProvisioningStrings.ChangeAzureContextCommandDescription,
AzureProvisioningStrings.ChangeAzureContextCommandConfirmation,
"Edit",
IconVariant.Regular,
IsHighlighted: true,
ExecuteCommand: static (controller, context) => controller.ExecuteChangeAzureContextCommandAsync(context),
Arguments: CreateAzureContextCommandArguments(),
ValidateArguments: ValidateAzureContextCommandArguments),
new(
AzureEnvironmentCommand.ReprovisionAll,
ReprovisionAllCommandName,
AzureProvisioningStrings.ReprovisionAllCommandName,
AzureProvisioningStrings.ReprovisionAllCommandDescription,
AzureProvisioningStrings.ReprovisionAllCommandConfirmation,
"ArrowSync",
IconVariant.Regular,
IsHighlighted: false,
ExecuteCommand: static (controller, context) => controller.ExecuteReprovisionAllCommandAsync(context)),
new(
AzureEnvironmentCommand.DeleteAzureResources,
DeleteAzureResourcesCommandName,
AzureProvisioningStrings.DeleteAzureResourcesCommandName,
AzureProvisioningStrings.DeleteAzureResourcesCommandDescription,
AzureProvisioningStrings.DeleteAzureResourcesCommandConfirmation,
"Delete",
IconVariant.Regular,
IsHighlighted: false,
ExecuteCommand: static (controller, context) => controller.ExecuteDeleteAzureResourcesCommandAsync(context))
];
// Keep this in the desired dashboard command order; see EnvironmentCommandDefinitions for ordering rules.
internal static ImmutableArray<ResourceCommandDefinition> ResourceCommandDefinitions { get; } =
[
new(
AzureResourceCommand.ChangeLocation,
ChangeResourceLocationCommandName,
AzureProvisioningStrings.ChangeResourceLocationCommandName,
AzureProvisioningStrings.ChangeResourceLocationCommandDescription,
ConfirmationMessage: null,
"Location",
IconVariant.Regular,
IsHighlighted: false,
ExecuteCommand: static (controller, resourceName, context) => controller.ExecuteChangeResourceLocationCommandAsync(resourceName, context),
Arguments: CreateChangeLocationCommandArguments(deploymentStateResourceName: null)),
new(
AzureResourceCommand.GetAzureResource,
GetAzureResourceCommandName,
AzureProvisioningStrings.GetAzureResourceCommandName,
AzureProvisioningStrings.GetAzureResourceCommandDescription,
ConfirmationMessage: null,
"Info",
IconVariant.Regular,
IsHighlighted: false,
ExecuteCommand: static (controller, resourceName, context) => controller.ExecuteGetAzureResourceCommandAsync(resourceName, context)),
new(
AzureResourceCommand.Cancel,
CancelCommandName,
AzureProvisioningStrings.CancelCommandName,
AzureProvisioningStrings.CancelCommandDescription,
AzureProvisioningStrings.CancelCommandConfirmation,
"Stop",
IconVariant.Regular,
IsHighlighted: true,
ExecuteCommand: static (controller, resourceName, context) => controller.ExecuteCancelCommandAsync(resourceName, context)),
new(
AzureResourceCommand.Reprovision,
ReprovisionResourceCommandName,
AzureProvisioningStrings.ReprovisionResourceCommandName,
AzureProvisioningStrings.ReprovisionResourceCommandDescription,
AzureProvisioningStrings.ReprovisionResourceCommandConfirmation,
"ArrowSync",
IconVariant.Regular,
IsHighlighted: true,
ExecuteCommand: static (controller, resourceName, context) => controller.ExecuteReprovisionResourceCommandAsync(resourceName, context)),
new(
AzureResourceCommand.DeleteAzureResource,
DeleteAzureResourceCommandName,
AzureProvisioningStrings.DeleteAzureResourceCommandName,
AzureProvisioningStrings.DeleteAzureResourceCommandDescription,
AzureProvisioningStrings.DeleteAzureResourceCommandConfirmation,
"Delete",
IconVariant.Regular,
IsHighlighted: true,
ExecuteCommand: static (controller, resourceName, context) => controller.ExecuteDeleteAzureResourceCommandAsync(resourceName, context)),
new(
AzureResourceCommand.ForgetState,
ForgetStateCommandName,
AzureProvisioningStrings.ForgetStateCommandName,
AzureProvisioningStrings.ForgetStateCommandDescription,
AzureProvisioningStrings.ForgetStateCommandConfirmation,
"ArrowReset",
IconVariant.Regular,
IsHighlighted: false,
ExecuteCommand: static (controller, resourceName, context) => controller.ExecuteForgetStateCommandAsync(resourceName, context))
];
// The Change Azure context dialog is a cascade:
//
// Tenant ID -> Subscription ID -> Resource group -> Location
//
// The dashboard sends update requests when a user edits a source input, and the
// hosting interaction service then reloads any inputs that list that source in
// DependsOnInputs. That dependency mechanism does not fire for values populated by
// startup loads, configuration, or the current Azure context. Any downstream input
// that must be usable immediately for an already-known upstream value must also opt
// into AlwaysLoadOnStart.
private static IReadOnlyList<InteractionInput> CreateAzureContextCommandArguments() =>
[
new()
{
Name = TenantIdArgumentName,
Label = AzureProvisioningStrings.TenantLabel,
Placeholder = AzureProvisioningStrings.TenantPlaceholder,
InputType = InputType.Choice,
Required = true,
AllowCustomChoice = true,
// The dashboard opens the dialog before dynamic options finish loading. Keep this
// server-owned until the startup load seeds the current tenant value; otherwise the
// client treats the empty local value as user-owned and ignores the loaded selection.
Disabled = true,
DynamicLoading = new InputLoadOptions
{
// Tenant ID has no dependencies, so startup loading is the only opportunity to
// populate the current tenant and enumerate available tenants before the user edits
// anything in the dialog.
AlwaysLoadOnStart = true,
LoadCallback = LoadTenantArgumentOptionsAsync
}
},
new()
{
Name = SubscriptionIdArgumentName,
Label = AzureProvisioningStrings.SubscriptionIdLabel,
Placeholder = AzureProvisioningStrings.SubscriptionIdPlaceholder,
InputType = InputType.Choice,
Required = true,
AllowCustomChoice = true,
// Keep the subscription control unavailable until the load callback resolves the
// selected tenant. This avoids accepting a stale subscription value from a previous
// tenant while the dashboard is still waiting for tenant-scoped subscription options.
Disabled = true,
DynamicLoading = new InputLoadOptions
{
// Startup loading handles a tenant preselected from configuration/current context,
// not from a user edit, and then re-enables this input.
AlwaysLoadOnStart = true,
LoadCallback = LoadSubscriptionArgumentOptionsAsync,
DependsOnInputs = [TenantIdArgumentName]
}
},
new()
{
Name = ResourceGroupArgumentName,
Label = AzureProvisioningStrings.ResourceGroupLabel,
Placeholder = AzureProvisioningStrings.ResourceGroupPlaceholder,
InputType = InputType.Choice,
Required = true,
AllowCustomChoice = true,
Disabled = true,
DynamicLoading = new InputLoadOptions
{
// Startup loading handles a subscription preselected from configuration/current
// context; dependency updates handle later Subscription ID edits.
AlwaysLoadOnStart = true,
LoadCallback = LoadResourceGroupArgumentOptionsAsync,
DependsOnInputs = [SubscriptionIdArgumentName]
}
},
new()
{
Name = LocationArgumentName,
Label = AzureProvisioningStrings.LocationLabel,
Placeholder = AzureProvisioningStrings.LocationPlaceholder,
InputType = InputType.Choice,
Required = true,
AllowCustomChoice = true,
Disabled = true,
DynamicLoading = new InputLoadOptions
{
// Startup loading reflects a known resource-group location immediately; dependency
// updates handle later edits to either upstream input.
AlwaysLoadOnStart = true,
LoadCallback = context => LoadLocationArgumentOptionsAsync(context),
DependsOnInputs = [SubscriptionIdArgumentName, ResourceGroupArgumentName]
}
}
];
internal static IReadOnlyList<InteractionInput> CreateChangeLocationCommandArguments(string? deploymentStateResourceName) =>
[
new()
{
Name = LocationArgumentName,
Label = AzureProvisioningStrings.LocationLabel,
Placeholder = AzureProvisioningStrings.LocationPlaceholder,
InputType = InputType.Choice,
Required = true,
AllowCustomChoice = true,
Disabled = true,
DynamicLoading = new InputLoadOptions
{
AlwaysLoadOnStart = true,
LoadCallback = context => LoadLocationArgumentOptionsAsync(context, deploymentStateResourceName)
}
},
CreateChangeLocationDeleteConfirmationArgument()
];
private static InteractionInput CreateChangeLocationDeleteConfirmationArgument() => new()
{
Name = ConfirmDeleteArgumentName,
Label = AzureProvisioningStrings.ChangeResourceLocationConfirmDeleteLabel,
Description = AzureProvisioningStrings.ChangeResourceLocationConfirmDeleteDescription,
InputType = InputType.Boolean,
Value = "false"
};
private static async Task LoadTenantArgumentOptionsAsync(LoadInputContext context)
{
try
{
var controller = context.Services.GetRequiredService<AzureProvisioningController>();
var currentContext = await controller.GetCurrentAzureContextAsync(context.CancellationToken).ConfigureAwait(false);
// Preserve a value the user has already typed or selected. Dynamic loading can run again
// after dashboard updates, and replacing a non-empty value here would undo user intent.
if (string.IsNullOrEmpty(context.Input.Value))
{
context.Input.Value = currentContext.TenantId;
}
var tenantOptions = await controller.GetTenantOptionsAsync(context.CancellationToken).ConfigureAwait(false);
if (tenantOptions.Count > 0)
{
context.Input.Options = tenantOptions;
}
}
finally
{
// Re-enable even if context or tenant enumeration fails. AllowCustomChoice lets users
// paste a tenant ID manually and then retry the command.
context.Input.Disabled = false;
}
}
private static async Task LoadSubscriptionArgumentOptionsAsync(LoadInputContext context)
{
try
{
var controller = context.Services.GetRequiredService<AzureProvisioningController>();
var currentContext = await controller.GetCurrentAzureContextAsync(context.CancellationToken).ConfigureAwait(false);
var tenantId = context.AllInputs.TryGetByName(TenantIdArgumentName, out var tenantInput) && !string.IsNullOrWhiteSpace(tenantInput.Value)
? tenantInput.Value
: currentContext.TenantId;
// Seed with the current subscription so the common case is preselected. If the user picked
// a different tenant and this subscription is not in the loaded option list, the interaction
// loading pipeline clears the invalid value for non-custom choices. This input allows custom
// choices, so users can still paste a subscription that enumeration did not return.
if (string.IsNullOrEmpty(context.Input.Value))
{
context.Input.Value = currentContext.SubscriptionId;
}
var subscriptionOptions = await controller.GetSubscriptionOptionsAsync(tenantId, context.CancellationToken).ConfigureAwait(false);
if (subscriptionOptions.Count > 0)
{
context.Input.Options = subscriptionOptions;
}
}
finally
{
// The control starts disabled because it is tenant-scoped. Re-enable it even if context
// or enumeration fails; AllowCustomChoice lets the user enter a subscription ID manually.
context.Input.Disabled = false;
}
}
private static async Task LoadResourceGroupArgumentOptionsAsync(LoadInputContext context)
{
try
{
var controller = context.Services.GetRequiredService<AzureProvisioningController>();
var currentContext = await controller.GetCurrentAzureContextAsync(context.CancellationToken).ConfigureAwait(false);
var subscriptionId = context.AllInputs.TryGetByName(SubscriptionIdArgumentName, out var subscriptionInput) && !string.IsNullOrWhiteSpace(subscriptionInput.Value)
? subscriptionInput.Value
: currentContext.SubscriptionId;
// Keep the persisted/current resource group as the default unless the user has already
// provided a value in the open dialog.
if (string.IsNullOrEmpty(context.Input.Value))
{
context.Input.Value = currentContext.ResourceGroup;
}
var resourceGroupOptions = await controller.GetResourceGroupOptionsAsync(subscriptionId, context.CancellationToken).ConfigureAwait(false);
context.Input.Options = resourceGroupOptions.Select(static rg => KeyValuePair.Create(rg.Name, rg.Name)).ToList();
}
finally
{
// A user can create or target a resource group that is not returned by enumeration, so
// context or enumeration failures should still leave the input editable.
context.Input.Disabled = false;
}
}
private static async Task LoadLocationArgumentOptionsAsync(LoadInputContext context, string? deploymentStateResourceName = null)
{
var locationPinnedToResourceGroup = false;
try
{
var controller = context.Services.GetRequiredService<AzureProvisioningController>();
var currentContext = await controller.GetCurrentAzureContextAsync(context.CancellationToken).ConfigureAwait(false);
var subscriptionId = context.AllInputs.TryGetByName(SubscriptionIdArgumentName, out var subscriptionInput) && !string.IsNullOrWhiteSpace(subscriptionInput.Value)
? subscriptionInput.Value
: currentContext.SubscriptionId;
var resourceGroupName = context.AllInputs.TryGetByName(ResourceGroupArgumentName, out var resourceGroupInput) && !string.IsNullOrWhiteSpace(resourceGroupInput.Value)
? resourceGroupInput.Value
: null;
if (!string.IsNullOrWhiteSpace(resourceGroupName))
{
var resourceGroupOptions = await controller.GetResourceGroupOptionsAsync(subscriptionId, context.CancellationToken).ConfigureAwait(false);
var (_, resourceGroupLocation) = resourceGroupOptions.FirstOrDefault(rg => rg.Name.Equals(resourceGroupName, StringComparisons.AzureResourceGroupName));
if (!string.IsNullOrEmpty(resourceGroupLocation))
{
// Existing resource groups are pinned to a single Azure region. When the selected
// group is known, constrain the location input to that region instead of letting the
// user pick a value that ARM will reject for deployments into that group.
context.Input.Options = [KeyValuePair.Create(resourceGroupLocation, resourceGroupLocation)];
context.Input.Value = resourceGroupLocation;
locationPinnedToResourceGroup = true;
context.Input.Disabled = true;
return;
}
}
// For Change resource location, deploymentStateResourceName identifies the specific resource
// whose effective location should be shown. For Change Azure context, there is no per-resource
// target, so the current environment location is the fallback.
if (string.IsNullOrEmpty(context.Input.Value))
{
context.Input.Value = !string.IsNullOrEmpty(deploymentStateResourceName)
? await controller.GetEffectiveResourceLocationAsync(deploymentStateResourceName, context.CancellationToken).ConfigureAwait(false)
: currentContext.Location;
}
context.Input.Options = await controller.GetLocationOptionsAsync(subscriptionId, context.CancellationToken).ConfigureAwait(false);
}
finally
{
if (!locationPinnedToResourceGroup)
{
// The input is disabled only when an existing resource group determines the location.
// Otherwise the user must be able to choose or type a location even if loading fails.
context.Input.Disabled = false;
}
}
}
private static Task ValidateAzureContextCommandArguments(InputsDialogValidationContext validationContext)
{
ValidateGuidArgument(validationContext, SubscriptionIdArgumentName, AzureProvisioningStrings.ValidationSubscriptionIdInvalid);
ValidateGuidArgument(validationContext, TenantIdArgumentName, AzureProvisioningStrings.ValidationTenantIdInvalid);
var resourceGroupInput = validationContext.Inputs[ResourceGroupArgumentName];
if (!BaseProvisioningContextProvider.IsValidResourceGroupName(resourceGroupInput.Value))
{
validationContext.AddValidationError(resourceGroupInput, AzureProvisioningStrings.ValidationResourceGroupNameInvalid);
}
return Task.CompletedTask;
}
private static void ValidateGuidArgument(InputsDialogValidationContext validationContext, string inputName, string validationMessage)
{
var input = validationContext.Inputs[inputName];
if (!string.IsNullOrWhiteSpace(input.Value) && !Guid.TryParse(input.Value, out _))
{
validationContext.AddValidationError(input, validationMessage);
}
}
public async Task ResetStateAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(model);
await RunOperationAsync(model, new ResetStateIntent(ReprovisionAfterReset: false), cancellationToken).ConfigureAwait(false);
}
public async Task ForgetResourceStateAsync(DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(model);
ArgumentException.ThrowIfNullOrEmpty(resourceName);
await RunOperationAsync(model, new ForgetResourceStateIntent(resourceName), cancellationToken).ConfigureAwait(false);
}
public async Task<bool> ChangeAzureContextAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(model);
return await RunOperationAsync<bool>(model, new ChangeAzureContextIntent(Options: null), cancellationToken).ConfigureAwait(false);
}
private async Task<bool> ChangeAzureContextAsync(DistributedApplicationModel model, AzureProvisioningOptionsUpdate options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(model);
ArgumentNullException.ThrowIfNull(options);
return await RunOperationAsync<bool>(model, new ChangeAzureContextIntent(options), cancellationToken).ConfigureAwait(false);
}
public Task EnsureProvisionedAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(model);
return RunOperationAsync(model, new EnsureProvisionedIntent(), cancellationToken);
}
public async Task<bool> ReprovisionAllAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(model);
return await RunOperationAsync<bool>(model, new ReprovisionAllIntent(), cancellationToken).ConfigureAwait(false);
}
public async Task DeleteAzureResourcesAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(model);
await RunOperationAsync(model, new DeleteAzureResourcesIntent(), cancellationToken).ConfigureAwait(false);
}
public async Task CheckForDriftAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(model);
lock (_operationStateLock)
{
// Drift intents use AzureOperationState.None so they never set CurrentIntent; this flag
// dedupes queued/running drift checks without disabling dashboard commands.
if (_state.Status.CurrentIntent is not null || _driftCheckQueued)
{
return;
}
_driftCheckQueued = true;
}
try
{
await QueueAndWaitForOperationAsync(model, new DetectDriftIntent(), cancellationToken).ConfigureAwait(false);
}
catch
{
lock (_operationStateLock)
{
_driftCheckQueued = false;
}
throw;
}
}
public async Task<bool> ReprovisionResourceAsync(DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(model);
ArgumentException.ThrowIfNullOrEmpty(resourceName);
return await RunOperationAsync<bool>(model, new ReprovisionResourceIntent(resourceName), cancellationToken).ConfigureAwait(false);
}
public async Task CancelResourceAsync(DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(model);
ArgumentException.ThrowIfNullOrEmpty(resourceName);
await CancelResourceCoreAsync(model, resourceName, cancellationToken).ConfigureAwait(false);
}
public async Task DeleteAzureResourceAsync(DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(model);
ArgumentException.ThrowIfNullOrEmpty(resourceName);
await RunOperationAsync(model, new DeleteAzureResourceIntent(resourceName), cancellationToken).ConfigureAwait(false);
}
public async Task<bool> ChangeResourceLocationAsync(DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(model);
ArgumentException.ThrowIfNullOrEmpty(resourceName);
ThrowIfKeyVaultLocationChangeTarget(model, resourceName);
var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
if (!interactionService.IsAvailable)
{
throw new MissingConfigurationException("Azure resource location can't be changed because the interaction service is unavailable.");
}
var targetResources = GetTargetAzureResources(model, resourceName, includeAnnotationParentRelationships: false);
var currentLocation = await GetEffectiveResourceLocationAsync(GetDeploymentStateResourceName(targetResources[0]), cancellationToken).ConfigureAwait(false);
var locationOptions = await GetLocationOptionsAsync(cancellationToken).ConfigureAwait(false);
var useChoiceInput = locationOptions.Count > 0;
var result = await interactionService.PromptInputsAsync(
AzureProvisioningStrings.ChangeResourceLocationPromptTitle,
string.Format(CultureInfo.CurrentCulture, AzureProvisioningStrings.ChangeResourceLocationPromptMessage, resourceName),
[
new InteractionInput
{
Name = AzureBicepResource.KnownParameters.Location,
Label = AzureProvisioningStrings.LocationLabel,
Placeholder = AzureProvisioningStrings.LocationPlaceholder,
InputType = useChoiceInput ? InputType.Choice : InputType.Text,
AllowCustomChoice = true,
Required = true,
Value = currentLocation,
Options = locationOptions
},
CreateChangeLocationDeleteConfirmationArgument()
],
cancellationToken: cancellationToken).ConfigureAwait(false);
if (result.Canceled)
{
return false;
}
var location = result.Data[AzureBicepResource.KnownParameters.Location].Value;
if (string.IsNullOrWhiteSpace(location))
{
return false;
}
return await ChangeResourceLocationAsync(
model,
resourceName,
location,
IsResourceDeletionConfirmed(result.Data),
cancellationToken).ConfigureAwait(false);
}
private async Task<bool> ChangeResourceLocationAsync(
DistributedApplicationModel model,
string resourceName,
string location,
bool confirmDelete,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(model);
ArgumentException.ThrowIfNullOrEmpty(resourceName);
ArgumentException.ThrowIfNullOrWhiteSpace(location);
ThrowIfKeyVaultLocationChangeTarget(model, resourceName);
location = NormalizeLocation(location, await GetLocationOptionsAsync(cancellationToken).ConfigureAwait(false));
return await RunOperationAsync<bool>(
model,
new ChangeResourceLocationIntent(resourceName, location, confirmDelete),
cancellationToken).ConfigureAwait(false);
}
private Task<ExecuteCommandResult> ExecuteResetProvisioningStateCommandAsync(ExecuteCommandContext context)
{
ArgumentNullException.ThrowIfNull(context);
var model = context.Services.GetRequiredService<DistributedApplicationModel>();
return ExecuteCommandAsync(
() => RunOperationAsync<bool>(model, new ResetStateIntent(ReprovisionAfterReset: true), context.CancellationToken),
AzureProvisioningStrings.ResetProvisioningStateCommandSuccess,
() => CreateEnvironmentCommandResultDataAsync(ResetProvisioningStateCommandName, model, context.CancellationToken),
AzureProvisioningFailureDetails.ProvisionOperation);
}
private Task<ExecuteCommandResult> ExecuteChangeAzureContextCommandAsync(ExecuteCommandContext context)
{
ArgumentNullException.ThrowIfNull(context);
var model = context.Services.GetRequiredService<DistributedApplicationModel>();
return ExecuteCommandAsync(
() => ChangeAzureContextCommandAsync(model, context.Arguments, context.CancellationToken),
AzureProvisioningStrings.ChangeAzureContextCommandSuccess,
() => CreateEnvironmentCommandResultDataAsync(ChangeAzureContextCommandName, model, context.CancellationToken),
AzureProvisioningFailureDetails.ProvisionOperation);
}
private Task<ExecuteCommandResult> ExecuteReprovisionAllCommandAsync(ExecuteCommandContext context)
{
ArgumentNullException.ThrowIfNull(context);
var model = context.Services.GetRequiredService<DistributedApplicationModel>();
return ExecuteCommandAsync(
() => ReprovisionAllAsync(model, context.CancellationToken),
AzureProvisioningStrings.ReprovisionAllCommandSuccess,
() => CreateEnvironmentCommandResultDataAsync(ReprovisionAllCommandName, model, context.CancellationToken),
AzureProvisioningFailureDetails.ProvisionOperation);
}
private Task<ExecuteCommandResult> ExecuteDeleteAzureResourcesCommandAsync(ExecuteCommandContext context)
{
ArgumentNullException.ThrowIfNull(context);
var model = context.Services.GetRequiredService<DistributedApplicationModel>();
return ExecuteCommandAsync(
() => DeleteAzureResourcesAsync(model, context.CancellationToken),
AzureProvisioningStrings.DeleteAzureResourcesCommandSuccess,
() => CreateEnvironmentCommandResultDataAsync(DeleteAzureResourcesCommandName, model, context.CancellationToken));
}
private Task<ExecuteCommandResult> ExecuteChangeResourceLocationCommandAsync(string resourceName, ExecuteCommandContext context)
{
ArgumentException.ThrowIfNullOrEmpty(resourceName);
ArgumentNullException.ThrowIfNull(context);
var model = context.Services.GetRequiredService<DistributedApplicationModel>();
return ExecuteCommandAsync(
() => ChangeResourceLocationCommandAsync(model, resourceName, context.Arguments, context.CancellationToken),
AzureProvisioningStrings.ChangeResourceLocationCommandSuccess,
() => CreateResourceCommandResultDataAsync(ChangeResourceLocationCommandName, model, resourceName, context.CancellationToken),
AzureProvisioningFailureDetails.ProvisionOperation);
}
private Task<ExecuteCommandResult> ExecuteGetAzureResourceCommandAsync(string resourceName, ExecuteCommandContext context)
{
ArgumentException.ThrowIfNullOrEmpty(resourceName);
ArgumentNullException.ThrowIfNull(context);
var model = context.Services.GetRequiredService<DistributedApplicationModel>();
return ExecuteCommandAsync(
() => Task.CompletedTask,
AzureProvisioningStrings.GetAzureResourceCommandSuccess,
() => CreateAzureResourceInfoCommandResultDataAsync(model, resourceName, context.CancellationToken));
}
private Task<ExecuteCommandResult> ExecuteCancelCommandAsync(string resourceName, ExecuteCommandContext context)
{
ArgumentException.ThrowIfNullOrEmpty(resourceName);
ArgumentNullException.ThrowIfNull(context);
var model = context.Services.GetRequiredService<DistributedApplicationModel>();
return ExecuteCommandAsync(
() => CancelResourceAsync(model, resourceName, context.CancellationToken),
AzureProvisioningStrings.CancelCommandSuccess,
() => CreateResourceCommandResultDataAsync(CancelCommandName, model, resourceName, context.CancellationToken));
}
private Task<ExecuteCommandResult> ExecuteDeleteAzureResourceCommandAsync(string resourceName, ExecuteCommandContext context)
{
ArgumentException.ThrowIfNullOrEmpty(resourceName);
ArgumentNullException.ThrowIfNull(context);
var model = context.Services.GetRequiredService<DistributedApplicationModel>();
return ExecuteCommandAsync(
() => RunOperationAsync<DeleteAzureResourceResult>(model, new DeleteAzureResourceIntent(resourceName), context.CancellationToken),
AzureProvisioningStrings.DeleteAzureResourceCommandSuccess,
result => CreateDeleteAzureResourceCommandResultDataAsync(model, resourceName, result, context.CancellationToken));
}
private Task<ExecuteCommandResult> ExecuteForgetStateCommandAsync(string resourceName, ExecuteCommandContext context)
{
ArgumentException.ThrowIfNullOrEmpty(resourceName);
ArgumentNullException.ThrowIfNull(context);
var model = context.Services.GetRequiredService<DistributedApplicationModel>();
return ExecuteCommandAsync(
() => ForgetResourceStateAsync(model, resourceName, context.CancellationToken),
AzureProvisioningStrings.ForgetStateCommandSuccess,
() => CreateResourceCommandResultDataAsync(ForgetStateCommandName, model, resourceName, context.CancellationToken));
}
private Task<ExecuteCommandResult> ExecuteReprovisionResourceCommandAsync(string resourceName, ExecuteCommandContext context)
{
ArgumentException.ThrowIfNullOrEmpty(resourceName);
ArgumentNullException.ThrowIfNull(context);
var model = context.Services.GetRequiredService<DistributedApplicationModel>();
return ExecuteCommandAsync(
() => ReprovisionResourceAsync(model, resourceName, context.CancellationToken),
AzureProvisioningStrings.ReprovisionResourceCommandSuccess,
() => CreateResourceCommandResultDataAsync(ReprovisionResourceCommandName, model, resourceName, context.CancellationToken),
AzureProvisioningFailureDetails.ProvisionOperation);
}
private async Task<bool> ChangeAzureContextCommandAsync(DistributedApplicationModel model, InteractionInputCollection arguments, CancellationToken cancellationToken)
{
if (arguments.Count == 0)
{
// Resource command execution can still invoke the command without dashboard-provided
// arguments. In that case fall back to the provisioning options manager prompt path,
// which is also used outside the dashboard command argument flow.
return await ChangeAzureContextAsync(model, cancellationToken).ConfigureAwait(false);
}
var location = arguments.GetString(LocationArgumentName);
if (!string.IsNullOrWhiteSpace(location))
{
// Users can type either a display name or a canonical Azure location. Normalize before
// persisting so future provisioning compares and reapplies a stable location value.
location = NormalizeLocation(location, await GetLocationOptionsAsync(arguments.GetString(SubscriptionIdArgumentName), cancellationToken).ConfigureAwait(false));
}
// Convert the dialog inputs into the persisted provisioning options shape. Tenant ID was
// added after the original subscription/resource-group/location options, so keep the lookup
// tolerant in case an older caller submits the smaller argument set.
var options = new AzureProvisioningOptionsUpdate(
SubscriptionId: arguments.GetString(SubscriptionIdArgumentName),
ResourceGroup: arguments.GetString(ResourceGroupArgumentName),
Location: location,
TenantId: arguments.TryGetByName(TenantIdArgumentName, out var tenantInput) ? tenantInput.Value : null);
return await ChangeAzureContextAsync(model, options, cancellationToken).ConfigureAwait(false);
}
private Task<bool> ChangeResourceLocationCommandAsync(DistributedApplicationModel model, string resourceName, InteractionInputCollection arguments, CancellationToken cancellationToken)
{
if (arguments.Count == 0)
{
// Commands invoked from non-dashboard surfaces may not include the pre-declared
// argument collection. Preserve the interactive prompt path for those callers instead
// of treating the command as malformed.
return ChangeResourceLocationAsync(model, resourceName, cancellationToken);
}
var location = arguments.GetString(LocationArgumentName);
if (string.IsNullOrWhiteSpace(location))
{
return Task.FromResult(false);
}
return ChangeResourceLocationAsync(
model,
resourceName,
location,
IsResourceDeletionConfirmed(arguments),
cancellationToken);
}
private static bool IsResourceDeletionConfirmed(InteractionInputCollection arguments)
=> arguments.TryGetByName(ConfirmDeleteArgumentName, out var input) &&
bool.TryParse(input.Value, out var confirmDelete) &&
confirmDelete;
internal ResourceCommandState GetEnvironmentCommandState()
{
lock (_operationStateLock)
{
// Environment commands affect every provisionable Azure resource, so expose a simple
// global gate: while any user-visible Azure operation is running, prevent another one
// from entering the serialized queue.
return _state.Status.CurrentIntent is null ? ResourceCommandState.Enabled : ResourceCommandState.Disabled;
}
}
internal ResourceCommandState GetResourceCommandState(string resourceName, AzureResourceCommand command, UpdateCommandStateContext context)
{
ArgumentException.ThrowIfNullOrEmpty(resourceName);
ArgumentNullException.ThrowIfNull(context);
if (command == AzureResourceCommand.ChangeLocation &&
IsKeyVaultTarget(context.Services.GetRequiredService<DistributedApplicationModel>(), resourceName))
{
return ResourceCommandState.Hidden;
}
lock (_operationStateLock)
{
var activeOperation = _state.Status.ActiveOperation;
var currentOperation = activeOperation?.Operation;
if (currentOperation is not null)
{
if (command == AzureResourceCommand.GetAzureResource)
{
// The info command is read-only and useful while an operation is in flight,
// especially for debugging cached deployment state after a failure.
return ResourceCommandState.Enabled;
}
var currentOperationAffectsResource = currentOperation.IsAllResources || currentOperation.ResourceNames.Contains(resourceName);
if (command == AzureResourceCommand.Cancel)
{
// Cancellation is the only mutating command that can be enabled during another operation.
// Keep one user-facing command that cancels both the active Aspire operation and any
// cached ARM deployment associated with the resource.
if (!currentOperationAffectsResource)
{
return ResourceCommandState.Hidden;
}
if (!IsActiveOperationCancelableResourceState(context.ResourceSnapshot))
{
return ResourceCommandState.Hidden;
}
return activeOperation!.CancellationRequested ? ResourceCommandState.Disabled : ResourceCommandState.Enabled;
}
return currentOperationAffectsResource
? ResourceCommandState.Disabled
: ResourceCommandState.Enabled;
}
}
return command switch
{
AzureResourceCommand.Cancel when !IsCancelableDeploymentState(context.ResourceSnapshot) => ResourceCommandState.Hidden,
_ => ResourceCommandState.Enabled
};
}
private static bool IsCancelableDeploymentState(CustomResourceSnapshot snapshot)
{
var state = snapshot.State?.Text;
return state == CreatingArmDeploymentState || state == WaitingForDeploymentState;
}
private static bool IsActiveOperationCancelableResourceState(CustomResourceSnapshot snapshot)
{
var state = snapshot.State?.Text;
return state == StartingState ||
state == CompilingArmTemplateState ||
state == CreatingArmDeploymentState ||
state == WaitingForDeploymentState ||
state == ChangingLocationState ||
state == DeletingState ||
state == CancelingState ||
state == ProvisionedState ||
state?.StartsWith(ProvisioningStatePrefix, StringComparison.Ordinal) == true;
}
private async Task RunOperationAsync(DistributedApplicationModel model, AzureIntent intent, CancellationToken cancellationToken)
{
_ = await QueueAndWaitForOperationAsync(
model,
intent,
cancellationToken).ConfigureAwait(false);
}
private async Task<T> RunOperationAsync<T>(DistributedApplicationModel model, AzureIntent intent, CancellationToken cancellationToken)
{
return (T)(await QueueAndWaitForOperationAsync(
model,
intent,
cancellationToken).ConfigureAwait(false))!;
}
private async Task<bool> EnsureProvisionedCoreAsync(
DistributedApplicationModel model,
IReadOnlyList<(IResource Resource, IAzureResource AzureResource)> azureResources,
CancellationToken cancellationToken)
{
if (azureResources.Count == 0)
{
return true;
}
await PublishAzureEnvironmentStateAsync(
model,
new ResourceStateSnapshot(StartingState, KnownResourceStateStyles.Info),
cancellationToken).ConfigureAwait(false);
var parentChildLookup = model.Resources.OfType<IResourceWithParent>().ToLookup(r => r.Parent);
var afterProvisionTasks = new List<Task>(azureResources.Count);
foreach (var resource in azureResources)
{
await ApplyResourceOverridesAsync(resource.AzureResource, cancellationToken).ConfigureAwait(false);
// Per-resource provisioning completion is used to sequence dependent Azure resources. A resource completes
// this TCS as soon as its own cached state is applied or its deployment finishes so dependents do not wait
// for unrelated resources in the same batch. Preserve an existing incomplete TCS because project startup
// can already be resolving connection strings through BicepOutputReference; replacing that TCS would strand
// those waiters even after provisioning completes.
if (resource.AzureResource.ProvisioningTaskCompletionSource is not { Task.IsCompleted: false })
{
resource.AzureResource.ProvisioningTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new(StartingState, KnownResourceStateStyles.Info),
Properties = state.Properties.WithoutAzureProvisioningFailureProperties()
}).ConfigureAwait(false);
afterProvisionTasks.Add(AfterProvisionAsync(resource, parentChildLookup));
}
await ProvisionAzureResourcesAsync(azureResources, parentChildLookup, cancellationToken).ConfigureAwait(false);
// AfterProvisionAsync is responsible for publishing each resource's terminal state.
// Wait for those observers before publishing the aggregate environment state, but
// inspect the per-resource TCSs below so one failed observer does not hide others.
await Task.WhenAll(afterProvisionTasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
cancellationToken.ThrowIfCancellationRequested();
var hasFailures = azureResources.Any(static resource =>
resource.AzureResource.ProvisioningTaskCompletionSource?.Task is { IsFaulted: true } or { IsCanceled: true });
await PublishAzureEnvironmentStateAsync(
model,
hasFailures
? new ResourceStateSnapshot(AzureProvisioningStrings.ResourceStateFailedToProvision, KnownResourceStateStyles.Error)
: new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success),
hasFailures ? CreateAzureEnvironmentFailureProperties(azureResources) : [],
cancellationToken).ConfigureAwait(false);
if (hasFailures && HasMissingAzureContextFailure(azureResources))
{
EnsureAzureContextNotificationStarted(model);
}
return !hasFailures;
}
private static bool HasMissingAzureContextFailure(IReadOnlyList<(IResource Resource, IAzureResource AzureResource)> azureResources)
=> azureResources.Any(static resource =>
resource.AzureResource.ProvisioningTaskCompletionSource?.Task.Exception?.InnerExceptions.Any(IsMissingAzureContextFailure) == true);
private static ImmutableArray<ResourcePropertySnapshot> CreateAzureEnvironmentFailureProperties(IReadOnlyList<(IResource Resource, IAzureResource AzureResource)> azureResources)
{
var failedResourceNames = azureResources
.Where(static resource => resource.AzureResource.ProvisioningTaskCompletionSource?.Task is { IsFaulted: true } or { IsCanceled: true })
.Select(static resource => resource.Resource.Name)
.Distinct(StringComparers.ResourceName)
.Order(StringComparers.ResourceName)
.ToArray();
return failedResourceNames.Length == 0
? []
: [AzureProvisioningFailureDetails.CreateHighlightedFailureProperty("failed.resources", failedResourceNames, AzureProvisioningStrings.FailurePropertyFailedResourcesDisplayName)];
}
private static Exception? GetProvisioningFailureException(IReadOnlyList<(IResource Resource, IAzureResource AzureResource)> azureResources)
=> azureResources
.Select(static resource => resource.AzureResource.ProvisioningTaskCompletionSource?.Task.Exception)
.FirstOrDefault(static exception => exception is not null);
private static string CreateProvisioningFailureMessage(IReadOnlyList<(IResource Resource, IAzureResource AzureResource)> azureResources)
{
var failureException = GetProvisioningFailureException(azureResources);
if (failureException is not null &&
AzureProvisioningFailureDetails.TryCreate(failureException, AzureProvisioningFailureDetails.ProvisionOperation) is { } failure)
{
return $"Azure provisioning failed. {failure.ToCommandMessage()}";
}
return "Azure provisioning failed.";
}
private async Task<bool> EnsureProvisionedOrThrowAsync(
DistributedApplicationModel model,
IReadOnlyList<(IResource Resource, IAzureResource AzureResource)> azureResources,
CancellationToken cancellationToken)
{
if (!await EnsureProvisionedCoreAsync(model, azureResources, cancellationToken).ConfigureAwait(false))
{
var failureException = GetProvisioningFailureException(azureResources);
var message = CreateProvisioningFailureMessage(azureResources);
throw failureException is null
? new InvalidOperationException(message)
: new InvalidOperationException(message, failureException);
}
return true;
}
private async Task ResetResourcesAsync(
DistributedApplicationModel model,
IReadOnlyCollection<(IResource Resource, IAzureResource AzureResource)> azureResources,
bool preserveOverrides,
CancellationToken cancellationToken,
bool preserveInferredLocationOverrides = true)
{
var parentChildLookup = model.Resources.OfType<IResourceWithParent>().ToLookup(r => r.Parent);
// When preserving overrides, compare any per-resource location against the current
// environment location. A persisted value equal to the environment is treated as inferred
// state, not a user override, unless the caller explicitly asks to preserve inferred values.
var environmentLocation = preserveOverrides
? (await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false)).Location
: null;
foreach (var resource in azureResources)
{
if (resource.AzureResource is not AzureBicepResource bicepResource)
{
continue;
}
var currentLocationOverride = preserveOverrides && preserveInferredLocationOverrides
? TryGetCurrentResourceLocationOverride(bicepResource, environmentLocation)
: null;
if (currentLocationOverride is not null)
{
// Apply the override before clearing cached state so the next provisioning pass
// emits Bicep with the desired per-resource location.
bicepResource.Parameters[AzureBicepResource.KnownParameters.Location] = currentLocationOverride;
}
else if (!preserveOverrides || !preserveInferredLocationOverrides)
{
// Remove stale inferred parameters when the environment context changed. Leaving
// them in place would make a reset/reprovision keep deploying to the previous
// environment location.
bicepResource.Parameters.Remove(AzureBicepResource.KnownParameters.Location);
}
await ClearCachedDeploymentStateAsync(bicepResource, preserveOverrides, environmentLocation, currentLocationOverride, preserveInferredLocationOverrides, cancellationToken).ConfigureAwait(false);
// BicepResource outputs are cached in-memory as well as in deployment state. Clear both
// so connection strings and dependent resources do not observe values from a prior ARM
// deployment after a reset.
bicepResource.Outputs.Clear();
bicepResource.SecretOutputs.Clear();
if (bicepResource is IAzureKeyVaultResource keyVaultResource)
{
// Key Vault secret resolution is tied to the old deployment outputs. Force it to be
// rebuilt after the resource is provisioned again.
keyVaultResource.SecretResolver = null;
}
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = KnownResourceStates.NotStarted,
Properties = FilterProperties(state.Properties),
Urls = [],
CreationTimeStamp = null,
StartTimeStamp = null,
StopTimeStamp = null
}).ConfigureAwait(false);
}
}
private async Task DeleteSectionAsync(string sectionName, CancellationToken cancellationToken)
{
var section = await deploymentStateManager.AcquireSectionAsync(sectionName, cancellationToken).ConfigureAwait(false);
// Clear before DeleteSectionAsync so implementations that persist the same section instance
// do not accidentally keep stale values if deletion is implemented as a save/remove hybrid.
section.Data.Clear();
await deploymentStateManager.DeleteSectionAsync(section, cancellationToken).ConfigureAwait(false);
}
private static List<(IResource Resource, IAzureResource AzureResource)> GetProvisionableAzureResources(DistributedApplicationModel model)
{
// Containers and emulators participate in the Aspire model but do not produce ARM
// deployments. Keep them out of controller operations so command state and drift checks only
// target resources backed by Azure deployment state.
return [.. AzureResourcePreparer.GetAzureResourcesFromAppModel(model).Where(static resource =>
resource.AzureResource is AzureBicepResource bicepResource &&
!bicepResource.IsContainer() &&
!bicepResource.IsEmulator())];
}
private static List<(IResource Resource, IAzureResource AzureResource)> GetTargetAzureResources(
DistributedApplicationModel model,
string resourceName,
bool includeAnnotationParentRelationships)
{
var azureResources = GetProvisionableAzureResources(model);
var targetResource = azureResources.SingleOrDefault(resource =>
string.Equals(resource.Resource.Name, resourceName, StringComparisons.ResourceName) ||
string.Equals(resource.AzureResource.Name, resourceName, StringComparisons.ResourceName));
if (targetResource == default)
{
throw new InvalidOperationException($"Azure resource '{resourceName}' was not found or cannot be reprovisioned.");
}
var parentChildLookup = GetTargetParentChildLookup(model, includeAnnotationParentRelationships);
var visitedResources = new HashSet<string>(StringComparers.ResourceName);
var queue = new Queue<(IResource Resource, IAzureResource AzureResource)>();
var targetResources = new List<(IResource Resource, IAzureResource AzureResource)>();
Enqueue(targetResource);
// Per-resource operations need to include any provisionable Azure resource owned by the
// selected resource, including children attached through the Aspire resource graph or
// RoleAssignment annotations, so dependent resources stay in sync with the target.
while (queue.Count > 0)
{
var current = queue.Dequeue();
targetResources.Add(current);
foreach (var child in parentChildLookup[current.Resource])
{
if (TryGetAzureResource(azureResources, child, out var childResource))
{
Enqueue(childResource);
}
}
if (!ReferenceEquals(current.Resource, current.AzureResource))
{
foreach (var child in parentChildLookup[current.AzureResource])
{
if (TryGetAzureResource(azureResources, child, out var childResource))
{
Enqueue(childResource);
}
}
}
if (current.AzureResource.TryGetAnnotationsOfType<RoleAssignmentResourceAnnotation>(out var roleAssignments))
{
foreach (var roleAssignment in roleAssignments)
{
if (TryGetAzureResource(azureResources, roleAssignment.RolesResource, out var roleAssignmentResource))
{
Enqueue(roleAssignmentResource);
}
}
}
}
return targetResources;
void Enqueue((IResource Resource, IAzureResource AzureResource) resource)
{
if (visitedResources.Add(resource.Resource.Name))
{
queue.Enqueue(resource);
}
}
}
private static ILookup<IResource, IResource> GetTargetParentChildLookup(DistributedApplicationModel model, bool includeAnnotationParentRelationships)
{
// WithParentRelationship stores visual parentage as ResourceRelationshipAnnotation instead of
// IResourceWithParent. Reprovision target discovery needs both forms so parent commands include
// generated Azure children such as implicit Key Vaults.
return model.Resources
.SelectMany(resource => GetResourceParents(resource, includeAnnotationParentRelationships).Select(parent => (Parent: parent, Child: resource)))
.ToLookup(static relationship => relationship.Parent, static relationship => relationship.Child);
}
private static IEnumerable<IResource> GetResourceParents(IResource resource, bool includeAnnotationParentRelationships)
{
if (resource is IResourceWithParent resourceWithParent)
{
yield return resourceWithParent.Parent;
}
// Change-location opts out of annotation-based parents so a parent resource with an
// implicit Key Vault child is not treated as a Key Vault location-change target.
if (includeAnnotationParentRelationships &&
resource.TryGetAnnotationsOfType<ResourceRelationshipAnnotation>(out var relationships) &&
relationships.LastOrDefault(static relationship => relationship.Type == KnownRelationshipTypes.Parent) is { } parentRelationship)
{
yield return parentRelationship.Resource;
}
}
private static bool IsKeyVaultTarget(DistributedApplicationModel model, string resourceName)
{
return GetTargetAzureResources(model, resourceName, includeAnnotationParentRelationships: false)
.Any(static resource => resource.AzureResource is IAzureKeyVaultResource);
}
private static void ThrowIfKeyVaultLocationChangeTarget(DistributedApplicationModel model, string resourceName)
=> ThrowIfKeyVaultLocationChangeTarget(GetTargetAzureResources(model, resourceName, includeAnnotationParentRelationships: false));
private static void ThrowIfKeyVaultLocationChangeTarget(IReadOnlyList<(IResource Resource, IAzureResource AzureResource)> targetResources)
{
if (targetResources.Any(static resource => resource.AzureResource is IAzureKeyVaultResource))
{
throw new InvalidOperationException(AzureProvisioningStrings.ChangeResourceLocationKeyVaultUnsupported);
}
}
private static void ThrowIfKeyVaultEnvironmentLocationChange(DistributedApplicationModel model, string? currentLocation, string? requestedLocation)
{
if (string.IsNullOrWhiteSpace(currentLocation) ||
string.IsNullOrWhiteSpace(requestedLocation) ||
string.Equals(currentLocation, requestedLocation, StringComparisons.AzureLocation))
{
return;
}
ThrowIfKeyVaultLocationChangeTarget(GetProvisionableAzureResources(model));
}
private static bool TryGetAzureResource(
IReadOnlyList<(IResource Resource, IAzureResource AzureResource)> azureResources,
IResource target,
out (IResource Resource, IAzureResource AzureResource) azureResource)
{
foreach (var resource in azureResources)
{
if (ReferenceEquals(resource.Resource, target) || ReferenceEquals(resource.AzureResource, target))
{
azureResource = resource;
return true;
}
}
azureResource = default;
return false;
}
private static List<(IResource Resource, IAzureResource AzureResource)> GetActiveOperationTargetAzureResources(DistributedApplicationModel model, ActiveAzureOperation activeOperation)
{
if (activeOperation.Operation.IsAllResources)
{
return GetProvisionableAzureResources(model);
}
return GetProvisionableAzureResources(model).Where(resource =>
activeOperation.Operation.ResourceNames.Contains(resource.Resource.Name) ||
activeOperation.Operation.ResourceNames.Contains(resource.AzureResource.Name)).ToList();
}
private async Task<object?> QueueAndWaitForOperationAsync(
DistributedApplicationModel model,
AzureIntent intent,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// The first Azure operation lazily starts the background pieces. This avoids creating tasks
// in apps that reference Azure hosting packages but never provision in run mode.
EnsureDriftMonitorStarted(model);
EnsureOperationLoopStarted();
var queuedOperationState = CreateQueuedOperationState(model, intent);
RegisterQueuedOperation(intent, queuedOperationState);
var queuedOperation = new QueuedOperation(
model,
intent,
new(TaskCreationOptions.RunContinuationsAsynchronously),
cancellationToken);
try
{
// All dashboard, CLI, and background Azure operations enter through this queue.
// Running them inline would reintroduce re-entrancy between command handlers and
// provisioning callbacks; the single reader below is the synchronization boundary.
await _operationChannel.Writer.WriteAsync(queuedOperation, cancellationToken).ConfigureAwait(false);
return await queuedOperation.Completion.Task.ConfigureAwait(false);
}
finally
{
UnregisterQueuedOperation(queuedOperationState);
}
}
internal static QueuedOperationForTesting CreateEnsureProvisionedQueuedOperationForTesting(DistributedApplicationModel model, TaskCompletionSource<object?> completion, CancellationToken cancellationToken)
{
return new(new QueuedOperation(model, new EnsureProvisionedIntent(), completion, cancellationToken));
}
internal Task ProcessQueuedOperationForTesting(QueuedOperationForTesting queuedOperation)
{
ArgumentNullException.ThrowIfNull(queuedOperation);
return ProcessQueuedOperationAsync((QueuedOperation)queuedOperation.Operation);
}
private void EnsureDriftMonitorStarted(DistributedApplicationModel model)
{
if (Interlocked.CompareExchange(ref _driftMonitorStarted, 1, 0) != 0)
{
return;
}
var stoppingToken = serviceProvider.GetService<IHostApplicationLifetime>()?.ApplicationStopping ?? CancellationToken.None;
_ = Task.Run(async () =>
{
// Delay before each check so the gap between drift checks is constant regardless of how long
// the previous check ran. PeriodicTimer would fire back-to-back if a check exceeded the interval.
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(DriftCheckInterval, _timeProvider, stoppingToken).ConfigureAwait(false);
await CheckForDriftAsync(model, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Azure drift check failed.");
}
}
}, stoppingToken);
}
private void EnsureOperationLoopStarted()
{
if (Interlocked.CompareExchange(ref _operationLoopStarted, 1, 0) == 0)
{
var stoppingToken = serviceProvider.GetService<IHostApplicationLifetime>()?.ApplicationStopping ?? CancellationToken.None;
_ = Task.Run(async () =>
{
try
{
await ProcessOperationLoopAsync(stoppingToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Azure operation processing failed.");
CancelPendingOperations(stoppingToken);
}
}, stoppingToken);
}
}
private async Task ProcessOperationLoopAsync(CancellationToken stoppingToken)
{
try
{
await foreach (var operation in _operationChannel.Reader.ReadAllAsync(stoppingToken).ConfigureAwait(false))
{
if (operation.CancellationToken.IsCancellationRequested)
{
if (operation.Intent is DetectDriftIntent)
{
CompleteDriftCheck();
}
operation.Completion.TrySetCanceled(operation.CancellationToken);
continue;
}
await ProcessQueuedOperationAsync(operation).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
CancelPendingOperations(stoppingToken);
}
}
private void CancelPendingOperations(CancellationToken cancellationToken)
{
while (_operationChannel.Reader.TryRead(out var operation))
{
if (operation.Intent is DetectDriftIntent)
{
CompleteDriftCheck();
}
operation.Completion.TrySetCanceled(cancellationToken);
}
}
private async Task ProcessQueuedOperationAsync(QueuedOperation queuedOperation)
{
var updatesCommandState = queuedOperation.Intent is not DetectDriftIntent;
var shouldPromptForMissingAzureContext = false;
ActiveAzureOperation? activeOperation = null;
var operationCancellationToken = queuedOperation.CancellationToken;
object? result = null;
Exception? failure = null;
var canceled = false;
var canceledToken = CancellationToken.None;
if (updatesCommandState)
{
// Publish command-state changes before running the operation so dashboard buttons
// disable immediately instead of remaining clickable until the first resource update.
activeOperation = StartOperation(queuedOperation.Model, queuedOperation.Intent, queuedOperation.CancellationToken);
operationCancellationToken = activeOperation.CancellationToken;
}
try
{
if (updatesCommandState)
{
await RefreshCommandStatesAsync(queuedOperation.Model, operationCancellationToken).ConfigureAwait(false);
}
result = await ExecuteIntentAsync(queuedOperation.Model, queuedOperation.Intent, operationCancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (operationCancellationToken.IsCancellationRequested || ex.CancellationToken == operationCancellationToken || queuedOperation.CancellationToken.IsCancellationRequested)
{
canceled = true;
canceledToken = operationCancellationToken.IsCancellationRequested ? operationCancellationToken : ex.CancellationToken;
}
catch (Exception ex)
{
if (IsMissingAzureContextFailure(ex))
{
shouldPromptForMissingAzureContext = true;
}
failure = ex;
}
finally
{
try
{
if (updatesCommandState)
{
CompleteOperation(queuedOperation.Intent, activeOperation);
// Use CancellationToken.None for the final refresh because command state must be
// re-enabled even if the operation request token was canceled after the work stopped.
await RefreshCommandStatesAsync(queuedOperation.Model, CancellationToken.None).ConfigureAwait(false);
}
else
{
// Drift detection is a background probe. It must serialize with commands, but it
// should not make dashboard commands flicker disabled while it checks ARM state.
CompleteDriftCheck();
}
if (shouldPromptForMissingAzureContext)
{
EnsureAzureContextNotificationStarted(queuedOperation.Model);
}
}
catch (Exception ex)
{
queuedOperation.Completion.TrySetException(ex);
throw;
}
CompleteQueuedOperation(queuedOperation, result, canceled, canceledToken, failure);
}
}
private static void CompleteQueuedOperation(QueuedOperation queuedOperation, object? result, bool canceled, CancellationToken canceledToken, Exception? failure)
{
if (failure is not null)
{
queuedOperation.Completion.TrySetException(failure);
}
else if (canceled)
{
queuedOperation.Completion.TrySetCanceled(canceledToken);
}
else
{
queuedOperation.Completion.TrySetResult(result);
}
}
private void EnsureAzureContextNotificationStarted(DistributedApplicationModel model)
{
if (Interlocked.CompareExchange(ref _azureContextNotificationStarted, 1, 0) != 0)
{
return;
}
var stoppingToken = serviceProvider.GetService<IHostApplicationLifetime>()?.ApplicationStopping ?? CancellationToken.None;
_ = Task.Run(async () =>
{
try
{
await PromptForMissingAzureContextAsync(model, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
catch (Exception ex)
{
_logger.LogError(ex, "Azure context notification failed.");
}
finally
{
Interlocked.Exchange(ref _azureContextNotificationStarted, 0);
}
}, stoppingToken);
}
private async Task PromptForMissingAzureContextAsync(DistributedApplicationModel model, CancellationToken cancellationToken)
{
var azureResources = GetProvisionableAzureResources(model);
if (azureResources.Count == 0)
{
return;
}
// This loop is the "missing Azure context" state expressed as an interaction. It intentionally
// lives outside the Azure operation queue: waiting for a user to click the notification or fill
// the dialog can take an arbitrary amount of time, and holding the queue would disable commands
// that should remain usable while the dashboard is asking for context.
while (true)
{
var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
if (!interactionService.IsAvailable ||
await provisioningOptionsManager.EnsureProvisioningOptionsAsync(forcePrompt: false, cancellationToken).ConfigureAwait(false))
{
// Another path may have supplied the context while this notification task was waiting
// to run. Re-check before posting so we don't show stale UI.
return;
}
var notificationResult = await interactionService.PromptNotificationAsync(
AzureProvisioningStrings.NotificationTitle,
AzureProvisioningStrings.NotificationMessage,
new NotificationInteractionOptions
{
Intent = MessageIntent.Warning,
PrimaryButtonText = AzureProvisioningStrings.NotificationPrimaryButtonText
},
cancellationToken).ConfigureAwait(false);
if (notificationResult.Canceled || !notificationResult.Data)
{
// Dismissing the notification does not change the underlying state. If Azure resources
// still need context, loop so the notification is posted again.
continue;
}
var updated = await provisioningOptionsManager.EnsureProvisioningOptionsAsync(forcePrompt: true, cancellationToken).ConfigureAwait(false);
if (!updated)
{
// Canceling the configure dialog leaves the context missing too, so keep the
// notification alive instead of treating the prompt as a one-shot operation.
continue;
}
// Only the apply/reprovision work is queued. At this point the user interaction has
// completed and the serialized operation can safely reset state and provision resources.
await provisioningOptionsManager.PersistProvisioningOptionsAsync(cancellationToken).ConfigureAwait(false);
await RunOperationAsync<bool>(model, new ApplyAzureContextIntent(), cancellationToken).ConfigureAwait(false);
return;
}
}
private static bool IsMissingAzureContextFailure(Exception ex)
=> ex is MissingConfigurationException ||
ex is AggregateException aggregateException && aggregateException.InnerExceptions.Any(IsMissingAzureContextFailure);
private async Task<object?> ExecuteIntentAsync(DistributedApplicationModel model, AzureIntent intent, CancellationToken cancellationToken)
{
return intent switch
{
ResetStateIntent resetState => await ExecuteResetStateAsync(model, resetState, cancellationToken).ConfigureAwait(false),
ForgetResourceStateIntent forgetResourceState => await ExecuteForgetResourceStateAsync(model, forgetResourceState, cancellationToken).ConfigureAwait(false),
ChangeAzureContextIntent changeAzureContext => await ExecuteChangeAzureContextAsync(model, changeAzureContext, cancellationToken).ConfigureAwait(false),
ApplyAzureContextIntent => await ExecuteApplyAzureContextAsync(model, cancellationToken).ConfigureAwait(false),
EnsureProvisionedIntent => await ExecuteEnsureProvisionedAsync(model, cancellationToken).ConfigureAwait(false),
ReprovisionAllIntent => await ExecuteReprovisionAllAsync(model, cancellationToken).ConfigureAwait(false),
DeleteAzureResourcesIntent => await ExecuteDeleteAzureResourcesAsync(model, cancellationToken).ConfigureAwait(false),
ChangeResourceLocationIntent changeResourceLocation => await ExecuteChangeResourceLocationAsync(model, changeResourceLocation, cancellationToken).ConfigureAwait(false),
ReprovisionResourceIntent reprovisionResource => await ExecuteReprovisionResourceAsync(model, reprovisionResource, cancellationToken).ConfigureAwait(false),
DeleteAzureResourceIntent deleteAzureResource => await ExecuteDeleteAzureResourceAsync(model, deleteAzureResource, cancellationToken).ConfigureAwait(false),
DetectDriftIntent => await ExecuteDetectDriftAsync(model, cancellationToken).ConfigureAwait(false),
_ => throw new ArgumentOutOfRangeException(nameof(intent), intent, "Unexpected Azure intent.")
};
}
private async Task<bool> ExecuteResetStateAsync(DistributedApplicationModel model, ResetStateIntent intent, CancellationToken cancellationToken)
{
UpdateActiveOperationPhase(intent, AzureProvisioningStrings.OperationPhaseResettingProvisioningState);
// Resetting the environment removes the top-level provisioning context first, then clears
// each deployment section. This makes future prompts fall back to configuration/defaults and
// prevents resources from showing old Azure identity properties.
await DeleteSectionAsync("Azure", cancellationToken).ConfigureAwait(false);
var azureResources = GetProvisionableAzureResources(model);
await ResetResourcesAsync(model, azureResources, preserveOverrides: false, cancellationToken).ConfigureAwait(false);
await PublishAzureEnvironmentStateAsync(model, KnownResourceStates.NotStarted, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Azure provisioning state reset for {Count} Azure resources.", azureResources.Count);
if (!intent.ReprovisionAfterReset || !serviceProvider.GetRequiredService<IInteractionService>().IsAvailable)
{
return true;
}
return await EnsureProvisionedCoreAsync(model, azureResources, cancellationToken).ConfigureAwait(false);
}
private async Task<object?> ExecuteForgetResourceStateAsync(DistributedApplicationModel model, ForgetResourceStateIntent intent, CancellationToken cancellationToken)
{
var targetResources = GetTargetAzureResources(model, intent.ResourceName, includeAnnotationParentRelationships: true);
// Forgetting state is local-only. It deliberately does not call ARM delete; users choose the
// Delete command when they want Aspire to remove live Azure resources.
await ResetResourcesAsync(model, targetResources, preserveOverrides: false, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Azure provisioning state reset for resource {ResourceName}.", intent.ResourceName);
return null;
}
private async Task<bool> ExecuteChangeAzureContextAsync(DistributedApplicationModel model, ChangeAzureContextIntent intent, CancellationToken cancellationToken)
{
UpdateActiveOperationPhase(intent, AzureProvisioningStrings.OperationPhaseChangingAzureContext);
var currentContext = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false);
if (intent.Options is null)
{
// This is the legacy/non-dashboard path. The options manager owns prompting and
// persistence when the command is invoked without the dashboard argument collection.
var updated = await provisioningOptionsManager.EnsureProvisioningOptionsAsync(forcePrompt: true, cancellationToken).ConfigureAwait(false);
if (!updated)
{
return false;
}
var provisioningOptions = await provisioningOptionsManager.GetProvisioningOptionsAsync(cancellationToken).ConfigureAwait(false);
ThrowIfKeyVaultEnvironmentLocationChange(model, currentContext.Location, provisioningOptions.Location);
await provisioningOptionsManager.PersistProvisioningOptionsAsync(cancellationToken).ConfigureAwait(false);
}
else
{
ThrowIfKeyVaultEnvironmentLocationChange(model, currentContext.Location, intent.Options.Location);
await provisioningOptionsManager.ApplyProvisioningOptionsAsync(intent.Options, cancellationToken).ConfigureAwait(false);
}
// Changing subscription/resource group/location invalidates cached deployment state. Preserve
// explicit user location overrides, but drop inferred overrides that only mirrored the previous
// environment location; otherwise resources can accidentally stay pinned to the old context.
await ResetResourcesAsync(model, GetProvisionableAzureResources(model), preserveOverrides: true, cancellationToken, preserveInferredLocationOverrides: false).ConfigureAwait(false);
await PublishAzureEnvironmentStateAsync(model, KnownResourceStates.NotStarted, cancellationToken).ConfigureAwait(false);
return await EnsureProvisionedOrThrowAsync(model, GetProvisionableAzureResources(model), cancellationToken).ConfigureAwait(false);
}
private async Task<bool> ExecuteApplyAzureContextAsync(DistributedApplicationModel model, CancellationToken cancellationToken)
{
UpdateCurrentActiveOperationPhase(AzureProvisioningStrings.OperationPhaseApplyingAzureContext);
// The notification prompt runs outside the operation queue so dashboard commands stay responsive
// while the dialog is open. This queued operation only applies the saved context and reprovisions.
await ResetResourcesAsync(model, GetProvisionableAzureResources(model), preserveOverrides: true, cancellationToken, preserveInferredLocationOverrides: false).ConfigureAwait(false);
await PublishAzureEnvironmentStateAsync(model, KnownResourceStates.NotStarted, cancellationToken).ConfigureAwait(false);
return await EnsureProvisionedOrThrowAsync(model, GetProvisionableAzureResources(model), cancellationToken).ConfigureAwait(false);
}
private async Task<object?> ExecuteEnsureProvisionedAsync(DistributedApplicationModel model, CancellationToken cancellationToken)
{
UpdateCurrentActiveOperationPhase(AzureProvisioningStrings.OperationPhaseProvisioningAzureResources);
var azureResources = GetProvisionableAzureResources(model);
await EnsureProvisionedCoreAsync(model, azureResources, cancellationToken).ConfigureAwait(false);
return null;
}
private async Task<bool> ExecuteReprovisionAllAsync(DistributedApplicationModel model, CancellationToken cancellationToken)
{
UpdateCurrentActiveOperationPhase(AzureProvisioningStrings.OperationPhaseReprovisioningAzureResources);
await ResetResourcesAsync(model, GetProvisionableAzureResources(model), preserveOverrides: true, cancellationToken).ConfigureAwait(false);
await PublishAzureEnvironmentStateAsync(model, KnownResourceStates.NotStarted, cancellationToken).ConfigureAwait(false);
return await EnsureProvisionedOrThrowAsync(model, GetProvisionableAzureResources(model), cancellationToken).ConfigureAwait(false);
}
private async Task<object?> ExecuteDeleteAzureResourcesAsync(DistributedApplicationModel model, CancellationToken cancellationToken)
{
UpdateCurrentActiveOperationPhase(AzureProvisioningStrings.OperationPhaseDeletingAzureResources);
// Delete-all operates at the resource-group boundary because run-mode provisioning creates a
// single environment resource group. Per-resource deletion uses cached deployment outputs
// instead, since individual Azure resources may not map one-to-one to a resource group.
await PublishAzureEnvironmentStateAsync(
model,
new ResourceStateSnapshot(DeletingState, KnownResourceStateStyles.Info),
cancellationToken).ConfigureAwait(false);
string? resourceGroupName;
try
{
resourceGroupName = await DeleteCurrentResourceGroupIfExistsAsync(cancellationToken).ConfigureAwait(false);
await ResetResourcesAsync(model, GetProvisionableAzureResources(model), preserveOverrides: true, cancellationToken).ConfigureAwait(false);
await PublishAzureEnvironmentStateAsync(model, KnownResourceStates.NotStarted, cancellationToken).ConfigureAwait(false);
}
catch (RequestFailedException)
{
await PublishAzureEnvironmentStateAsync(
model,
new ResourceStateSnapshot(AzureProvisioningStrings.ResourceStateFailedToDelete, KnownResourceStateStyles.Error),
cancellationToken).ConfigureAwait(false);
throw;
}
if (string.IsNullOrEmpty(resourceGroupName))
{
_logger.LogInformation("Azure deployment state reset without deleting a resource group because no Azure resource group was configured.");
}
else
{
_logger.LogInformation("Azure resource group {ResourceGroup} was deleted or was already absent.", resourceGroupName);
}
return null;
}
private async Task<string?> DeleteCurrentResourceGroupIfExistsAsync(CancellationToken cancellationToken)
{
var section = await deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false);
var subscriptionId = section.Data["SubscriptionId"]?.GetValue<string>();
var resourceGroupName = section.Data["ResourceGroup"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(subscriptionId) ||
string.IsNullOrWhiteSpace(resourceGroupName))
{
return null;
}
var armClientProvider = serviceProvider.GetRequiredService<IArmClientProvider>();
var tokenCredentialProvider = serviceProvider.GetRequiredService<ITokenCredentialProvider>();
var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, subscriptionId);
// Resolve the subscription through ARM before accessing resource groups so tenant/subscription
// mismatches fail with the same Azure SDK behavior used by provisioning.
var (subscription, _) = await armClient.GetSubscriptionAndTenantAsync(cancellationToken).ConfigureAwait(false);
try
{
var response = await subscription.GetResourceGroups().GetAsync(resourceGroupName, cancellationToken).ConfigureAwait(false);
await response.Value.DeleteAsync(WaitUntil.Completed, cancellationToken).ConfigureAwait(false);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
_logger.LogInformation("Azure resource group {ResourceGroup} was already absent.", resourceGroupName);
}
return resourceGroupName;
}
private async Task<bool> ExecuteChangeResourceLocationAsync(DistributedApplicationModel model, ChangeResourceLocationIntent intent, CancellationToken cancellationToken)
{
var targetResources = GetTargetAzureResources(model, intent.ResourceName, includeAnnotationParentRelationships: false);
ThrowIfKeyVaultLocationChangeTarget(targetResources);
var targetBicepResource = targetResources[0].AzureResource as AzureBicepResource;
LocationChangeResourceDeletion? resourceDeletion = null;
if (targetBicepResource is not null)
{
// Validate the potentially destructive step before publishing ChangingLocation. A rejected
// invocation must leave the resource tree at its prior terminal state so drift checks continue.
resourceDeletion = await PrepareCachedResourceDeletionForLocationChangeAsync(
targetBicepResource,
intent.Location,
intent.ConfirmDelete,
cancellationToken).ConfigureAwait(false);
}
var parentChildLookup = model.Resources.OfType<IResourceWithParent>().ToLookup(r => r.Parent);
UpdateActiveOperationPhase(
intent,
ChangingLocationState,
FormatUserString(AzureProvisioningStrings.OperationPhaseChangingLocationToFormat, intent.Location));
foreach (var resource in targetResources)
{
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new(ChangingLocationState, KnownResourceStateStyles.Info)
}).ConfigureAwait(false);
}
if (targetBicepResource is not null)
{
// ARM rejects redeploying many resource types to a different location while the old
// resource still exists. Delete the cached live resource first, then save the override
// that the next provisioning pass will apply.
UpdateActiveOperationPhase(
intent,
AzureProvisioningStrings.OperationPhaseDeletingAzureResource,
FormatUserString(AzureProvisioningStrings.OperationPhaseDeletingExistingAzureResourceBeforeChangingLocationFormat, intent.Location));
await DeleteCachedResourceForLocationChangeAsync(targetBicepResource, intent.Location, resourceDeletion, cancellationToken).ConfigureAwait(false);
await SetResourceLocationOverrideAsync(targetBicepResource.Name, intent.Location, cancellationToken).ConfigureAwait(false);
}
UpdateActiveOperationPhase(
intent,
AzureProvisioningStrings.OperationPhaseReprovisioning,
FormatUserString(AzureProvisioningStrings.OperationPhaseReprovisioningInLocationFormat, intent.Location));
await ResetResourcesAsync(model, targetResources, preserveOverrides: true, cancellationToken).ConfigureAwait(false);
return await EnsureProvisionedOrThrowAsync(model, targetResources, cancellationToken).ConfigureAwait(false);
}
private async Task<bool> ExecuteReprovisionResourceAsync(DistributedApplicationModel model, ReprovisionResourceIntent intent, CancellationToken cancellationToken)
{
UpdateActiveOperationPhase(intent, AzureProvisioningStrings.OperationPhaseReprovisioning);
var targetResources = GetTargetAzureResources(model, intent.ResourceName, includeAnnotationParentRelationships: true);
var effectiveLocation = await GetEffectiveResourceLocationAsync(GetDeploymentStateResourceName(targetResources[0]), cancellationToken).ConfigureAwait(false);
var currentContext = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false);
await ResetResourcesAsync(model, targetResources, preserveOverrides: true, cancellationToken).ConfigureAwait(false);
try
{
return await EnsureProvisionedOrThrowAsync(model, targetResources, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested &&
ex is InvalidOperationException or AzureProvisioningFailureException &&
TryGetKeyVaultSoftDeleteConflictResourceId(ex, out var keyVaultResourceId))
{
// If a delete command is canceled after the live vault is deleted but before purge finishes,
// the next reprovision sees ARM's soft-delete conflict. At that point the user explicitly
// asked to recreate this resource, so purge the tombstone for the same target ID when Azure
// can still find it.
var keyVaultResourceIdentifier = new ResourceIdentifier(keyVaultResourceId);
UpdateActiveOperationPhase(
intent,
AzureProvisioningStrings.OperationPhasePurgingDeletedKeyVault,
FormatUserString(AzureProvisioningStrings.OperationPhasePurgingRecoverableKeyVaultFormat, keyVaultResourceIdentifier.Name));
var armClient = await GetArmClientForResourceIdAsync(keyVaultResourceId, cancellationToken).ConfigureAwait(false);
// Key Vault names are reserved by a location-scoped soft-delete tombstone after the live
// vault is gone. Reprovision has to purge that tombstone before ARM can create a new vault
// with the same generated name. ARM only reports this conflict once the live vault is already
// absent, so issue the purge directly rather than starting another delete that can wait on
// the same tombstone and delay the retry.
var purged = await PurgeDeletedKeyVaultAsync(
armClient,
keyVaultResourceId,
intent.ResourceName,
effectiveLocation,
currentContext.Location,
allowTimeout: false,
cancellationToken).ConfigureAwait(false);
if (!purged)
{
var failureDetails = AzureProvisioningFailureDetails.CreateKeyVaultDeletedStateTombstoneNotFound(
keyVaultResourceIdentifier.Name,
keyVaultResourceId,
GetKeyVaultPurgeLocations(effectiveLocation, currentContext.Location));
await PublishSyntheticProvisioningFailureAsync(model, targetResources, failureDetails).ConfigureAwait(false);
throw new AzureProvisioningFailureException(failureDetails, ex);
}
await ResetResourcesAsync(model, targetResources, preserveOverrides: true, cancellationToken).ConfigureAwait(false);
return await EnsureProvisionedOrThrowAsync(model, targetResources, cancellationToken).ConfigureAwait(false);
}
}
// Key Vault is special because deleting a vault leaves a location-scoped soft-delete tombstone.
// A later create with the same vault name can fail until that tombstone is purged, even though
// the live resource no longer exists. Detect that specific provisioning failure so reprovision
// can purge the recoverable vault for the same target resource ID and retry once.
private static bool TryGetKeyVaultSoftDeleteConflictResourceId(Exception exception, out string keyVaultResourceId)
{
keyVaultResourceId = string.Empty;
if (AzureProvisioningFailureDetails.TryCreate(exception, AzureProvisioningFailureDetails.ProvisionOperation) is not { } failure ||
!string.Equals(failure.ResourceType, KeyVaultVaultResourceType, StringComparisons.AzureResourceType) ||
string.IsNullOrWhiteSpace(failure.TargetResourceId) ||
!ResourceIdentifier.TryParse(failure.TargetResourceId, out var parsedResourceId) ||
parsedResourceId is null ||
!string.Equals(parsedResourceId.ResourceType.ToString(), KeyVaultVaultResourceType, StringComparisons.AzureResourceType))
{
return false;
}
// Observed ARM shapes include:
// code: ConflictError, message: "... is currently in a deleted state ... purge ..."
// code: VaultAlreadyExists, message: "... is currently in a deleted state ..."
// Some providers vary the code, so keep the message check as the fallback signal.
if (string.Equals(failure.ErrorCode, "ConflictError", StringComparisons.AzureProvisioningErrorCode) ||
string.Equals(failure.ErrorCode, "VaultAlreadyExists", StringComparisons.AzureProvisioningErrorCode) ||
(failure.ErrorMessage.Contains("deleted state", StringComparison.OrdinalIgnoreCase) &&
failure.ErrorMessage.Contains("purge", StringComparison.OrdinalIgnoreCase)))
{
keyVaultResourceId = failure.TargetResourceId;
return true;
}
return false;
}
private async Task CancelResourceCoreAsync(DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken)
{
var targetResources = GetTargetAzureResources(model, resourceName, includeAnnotationParentRelationships: true);
var parentChildLookup = model.Resources.OfType<IResourceWithParent>().ToLookup(r => r.Parent);
ActiveAzureOperation? activeOperation = null;
lock (_operationStateLock)
{
var currentActiveOperation = _state.Status.ActiveOperation;
if (currentActiveOperation is not null &&
(currentActiveOperation.Operation.IsAllResources || currentActiveOperation.Operation.ResourceNames.Contains(resourceName)))
{
activeOperation = currentActiveOperation;
}
}
var targetResourcesToCancel = activeOperation is not null
? GetActiveOperationTargetAzureResources(model, activeOperation)
: targetResources;
var inFlightResourcesToCancel = activeOperation is not null
? targetResourcesToCancel
.Where(static resource => resource.AzureResource.ProvisioningTaskCompletionSource?.Task.IsCompleted == false)
.ToList()
: [];
// Snapshot in-flight resources before canceling the CTS. Cancellation continuations can
// complete provisioning tasks synchronously, but those resources should still show the
// user that the cancel request was accepted.
activeOperation?.Cancel();
if (activeOperation is not null)
{
foreach (var resource in inFlightResourcesToCancel)
{
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new(CancelingState, KnownResourceStateStyles.Info)
}).ConfigureAwait(false);
}
}
var canceledDeploymentCount = await CancelCachedDeploymentsAsync(
targetResourcesToCancel,
requireDeployment: activeOperation is null,
requiredDeploymentResourceName: resourceName,
cancellationToken).ConfigureAwait(false);
if (activeOperation is null)
{
foreach (var resource in targetResourcesToCancel)
{
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new(AzureProvisioningStrings.ResourceStateCanceled, KnownResourceStateStyles.Info)
}).ConfigureAwait(false);
}
}
_logger.LogInformation(
"Cancellation requested for Azure resource {ResourceName}. Active operation canceled: {ActiveOperationCanceled}. Canceled {Count} cached deployment(s).",
resourceName,
activeOperation is not null,
canceledDeploymentCount);
}
private async Task<DeleteAzureResourceResult> ExecuteDeleteAzureResourceAsync(DistributedApplicationModel model, DeleteAzureResourceIntent intent, CancellationToken cancellationToken)
{
UpdateActiveOperationPhase(intent, AzureProvisioningStrings.OperationPhaseDeletingAzureResource);
var targetResources = GetTargetAzureResources(model, intent.ResourceName, includeAnnotationParentRelationships: true);
var parentChildLookup = model.Resources.OfType<IResourceWithParent>().ToLookup(r => r.Parent);
foreach (var resource in targetResources)
{
// Show the whole affected resource tree as deleting before the first ARM call so the
// dashboard reflects that child resources/role assignments are part of the operation.
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new(DeletingState, KnownResourceStateStyles.Info)
}).ConfigureAwait(false);
}
IReadOnlyList<string> resourceIds;
try
{
// A resource can have an in-progress deployment and already-created target resources.
// Cancel first to stop ARM from continuing to create/update resources while deletion is
// collecting and removing the known targets.
await CancelCachedDeploymentsAsync(
targetResources,
requireDeployment: false,
requiredDeploymentResourceName: null,
cancellationToken).ConfigureAwait(false);
resourceIds = await GetAzureResourceIdsForDeletionAsync(targetResources, cancellationToken).ConfigureAwait(false);
if (resourceIds.Count == 0)
{
throw new InvalidOperationException($"No cached Azure resource IDs were found for resource '{intent.ResourceName}'. Use '{ForgetStateCommandName}' to clear local state only.");
}
var effectiveLocation = await GetEffectiveResourceLocationAsync(GetDeploymentStateResourceName(targetResources[0]), cancellationToken).ConfigureAwait(false);
var currentContext = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false);
await DeleteAzureResourceIdsAsync(resourceIds, intent.ResourceName, effectiveLocation, currentContext.Location, allowKeyVaultPurgeTimeout: true, cancellationToken).ConfigureAwait(false);
await ResetResourcesAsync(model, targetResources, preserveOverrides: true, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
foreach (var resource in targetResources)
{
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new(AzureProvisioningStrings.ResourceStateCanceled, KnownResourceStateStyles.Info)
}).ConfigureAwait(false);
}
throw;
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
foreach (var resource in targetResources)
{
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new(AzureProvisioningStrings.ResourceStateFailedToDelete, KnownResourceStateStyles.Error)
}).ConfigureAwait(false);
}
throw;
}
_logger.LogInformation("Deleted {Count} Azure resource(s) for resource {ResourceName}.", resourceIds.Count, intent.ResourceName);
return new DeleteAzureResourceResult(resourceIds);
}
private async Task<object?> ExecuteDetectDriftAsync(DistributedApplicationModel model, CancellationToken cancellationToken)
{
// Drift only matters after the environment is Running. During provisioning, reset, or delete
// operations the resource states already communicate that Azure may not match cached state.
if (model.Resources.OfType<AzureEnvironmentResource>().SingleOrDefault() is not { } environmentResource ||
!notificationService.TryGetCurrentState(environmentResource.Name, out var environmentEvent) ||
environmentEvent.Snapshot.State?.Text != KnownResourceStates.Running)
{
return null;
}
var context = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false);
if (!Guid.TryParse(context.SubscriptionId, out _))
{
// Without a valid subscription we cannot safely ask ARM whether cached resource IDs
// exist. Leave state unchanged rather than marking everything as drifted.
return null;
}
var armClientProvider = serviceProvider.GetRequiredService<IArmClientProvider>();
var tokenCredentialProvider = serviceProvider.GetRequiredService<ITokenCredentialProvider>();
var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, context.SubscriptionId);
var parentChildLookup = model.Resources.OfType<IResourceWithParent>().ToLookup(r => r.Parent);
List<string>? driftedResources = null;
foreach (var resource in GetProvisionableAzureResources(model))
{
if (!ShouldCheckForDrift(resource.Resource) ||
await TryGetResourceIdFromDeploymentStateAsync((AzureBicepResource)resource.AzureResource, cancellationToken).ConfigureAwait(false) is not { } resourceId)
{
// Resources without cached IDs are either not provisioned yet or already reset. They
// do not provide enough information for a live ARM existence check.
continue;
}
if (await armClient.ResourceExistsAsync(resourceId, cancellationToken).ConfigureAwait(false))
{
continue;
}
driftedResources ??= [];
driftedResources.Add(resource.Resource.Name);
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new(MissingInAzureState, KnownResourceStateStyles.Error)
}).ConfigureAwait(false);
}
if (driftedResources is null)
{
return null;
}
await PublishAzureEnvironmentStateAsync(
model,
new ResourceStateSnapshot(DriftedState, KnownResourceStateStyles.Error),
cancellationToken).ConfigureAwait(false);
_logger.LogWarning("Azure drift detected for resources: {ResourceNames}.", string.Join(", ", driftedResources));
return null;
}
private ActiveAzureOperation StartOperation(DistributedApplicationModel model, AzureIntent intent, CancellationToken cancellationToken)
{
var activeOperation = ActiveAzureOperation.Create(intent, CreateActiveOperationState(model, intent), _timeProvider.GetUtcNow(), cancellationToken);
lock (_operationStateLock)
{
// Store the intent rather than just a boolean so command-state calculation can keep
// unaffected resources enabled while a per-resource operation is running.
_state = CreateControllerState(intent, activeOperation);
}
return activeOperation;
}
private void CompleteOperation(AzureIntent intent, ActiveAzureOperation? activeOperation)
{
ActiveAzureOperation? operationToDispose = null;
lock (_operationStateLock)
{
if (ReferenceEquals(_state.Status.CurrentIntent, intent) &&
ReferenceEquals(_state.Status.ActiveOperation, activeOperation))
{
operationToDispose = _state.Status.ActiveOperation;
_state = CreateControllerState(currentIntent: null, activeOperation: null);
}
}
operationToDispose?.Dispose();
}
private void CompleteDriftCheck()
{
lock (_operationStateLock)
{
_driftCheckQueued = false;
}
}
private void UpdateActiveOperationPhase(AzureIntent intent, string phase, string? status = null)
{
lock (_operationStateLock)
{
if (ReferenceEquals(_state.Status.CurrentIntent, intent))
{
_state.Status.ActiveOperation?.SetPhase(phase, status);
}
}
}
private void UpdateCurrentActiveOperationPhase(string phase, string? status = null)
{
lock (_operationStateLock)
{
_state.Status.ActiveOperation?.SetPhase(phase, status);
}
}
private static string FormatUserString(string format, params object?[] args)
=> string.Format(CultureInfo.CurrentCulture, format, args);
private static AzureOperationState CreateActiveOperationState(DistributedApplicationModel model, AzureIntent intent)
{
if (intent.Operation.IsAllResources)
{
return intent.Operation;
}
var resourceNames = GetTargetAzureResources(
model,
intent.Operation.ResourceNames.Single(),
includeAnnotationParentRelationships: intent is not ChangeResourceLocationIntent)
.SelectMany(static resource => resource.Resource == resource.AzureResource
? [resource.Resource.Name]
: new[] { resource.Resource.Name, resource.AzureResource.Name })
.ToHashSet(StringComparers.ResourceName);
return AzureOperationState.Resources(resourceNames, intent.Operation.DisplayName);
}
private static AzureOperationState CreateQueuedOperationState(DistributedApplicationModel model, AzureIntent intent)
{
if (intent.Operation.IsNone)
{
return AzureOperationState.None;
}
return CreateActiveOperationState(model, intent);
}
private void RegisterQueuedOperation(AzureIntent intent, AzureOperationState queuedOperationState)
{
if (queuedOperationState.IsNone)
{
return;
}
lock (_operationStateLock)
{
if (_state.Status.ActiveOperation?.Operation is { } activeOperation &&
OperationsConflict(activeOperation, queuedOperationState))
{
throw new InvalidOperationException(FormatUserString(
AzureProvisioningStrings.OperationAlreadyRunningOrQueued,
activeOperation.DisplayName,
intent.Operation.DisplayName,
CancelCommandName));
}
foreach (var queuedOperation in _queuedOperationStates)
{
if (OperationsConflict(queuedOperation, queuedOperationState))
{
throw new InvalidOperationException(FormatUserString(
AzureProvisioningStrings.OperationAlreadyRunningOrQueued,
queuedOperation.DisplayName,
intent.Operation.DisplayName,
CancelCommandName));
}
}
// The queue is single-reader but multi-writer. Track accepted operations before they
// become active so rapid CLI/MCP calls cannot bypass dashboard disabled-state checks by
// slipping multiple conflicting commands into the channel before the reader runs.
_queuedOperationStates.Add(queuedOperationState);
}
}
private void UnregisterQueuedOperation(AzureOperationState queuedOperationState)
{
if (queuedOperationState.IsNone)
{
return;
}
lock (_operationStateLock)
{
_queuedOperationStates.Remove(queuedOperationState);
}
}
private static bool OperationsConflict(AzureOperationState currentOperation, AzureOperationState requestedOperation)
{
if (currentOperation.IsNone || requestedOperation.IsNone)
{
return false;
}
if (currentOperation.IsAllResources || requestedOperation.IsAllResources)
{
return true;
}
return currentOperation.ResourceNames.Any(requestedOperation.ResourceNames.Contains);
}
private static AzureControllerState CreateControllerState(AzureIntent? currentIntent, ActiveAzureOperation? activeOperation)
=> new(new AzureControllerStatus(currentIntent, activeOperation));
private static async Task<ExecuteCommandResult> ExecuteCommandAsync(Func<Task> action, string successMessage, Func<Task<CommandResultData>> createResultData, string? failureOperation = null)
{
try
{
await action().ConfigureAwait(false);
return CommandResults.Success(successMessage, await createResultData().ConfigureAwait(false));
}
catch (OperationCanceledException)
{
return CommandResults.Canceled();
}
catch (Exception ex)
{
return CreateFailureCommandResult(ex, failureOperation);
}
}
private static async Task<ExecuteCommandResult> ExecuteCommandAsync<T>(Func<Task<T>> action, string successMessage, Func<T, Task<CommandResultData>> createResultData, string? failureOperation = null)
{
try
{
var result = await action().ConfigureAwait(false);
return CommandResults.Success(successMessage, await createResultData(result).ConfigureAwait(false));
}
catch (OperationCanceledException)
{
return CommandResults.Canceled();
}
catch (Exception ex)
{
return CreateFailureCommandResult(ex, failureOperation);
}
}
private static async Task<ExecuteCommandResult> ExecuteCommandAsync(Func<Task<bool>> action, string successMessage, Func<Task<CommandResultData>> createResultData, string? failureOperation = null)
{
try
{
return await action().ConfigureAwait(false)
? CommandResults.Success(successMessage, await createResultData().ConfigureAwait(false))
: CommandResults.Canceled();
}
catch (OperationCanceledException)
{
return CommandResults.Canceled();
}
catch (Exception ex)
{
return CreateFailureCommandResult(ex, failureOperation);
}
}
private async Task<CommandResultData> CreateEnvironmentCommandResultDataAsync(string commandName, DistributedApplicationModel model, CancellationToken cancellationToken)
{
var json = await CreateCommandResultJsonAsync(commandName, resourceName: null, cancellationToken).ConfigureAwait(false);
json["resourceCount"] = GetProvisionableAzureResources(model).Count;
if (string.Equals(commandName, ResetProvisioningStateCommandName, StringComparison.Ordinal))
{
json["warning"] = "Local Azure provisioning state was reset without deleting live Azure resources. Existing Azure resources may be orphaned if a later reprovision uses a different Azure context.";
json["recommendedActions"] = AzureProvisioningFailureDetails.CreateRecommendedActionsJsonArray(
[
new("delete-live-resources", $"Use '{DeleteAzureResourcesCommandName}' when you want Aspire to remove live Azure resources."),
new("review-azure-context", "Review the final 'azureContext' fields before reprovisioning.")
]);
}
return CreateJsonResultData(json);
}
private async Task<CommandResultData> CreateResourceCommandResultDataAsync(string commandName, DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken)
=> CreateJsonResultData(await CreateResourceCommandResultJsonAsync(commandName, model, resourceName, cancellationToken).ConfigureAwait(false));
private async Task<CommandResultData> CreateDeleteAzureResourceCommandResultDataAsync(DistributedApplicationModel model, string resourceName, DeleteAzureResourceResult result, CancellationToken cancellationToken)
{
var json = await CreateResourceCommandResultJsonAsync(DeleteAzureResourceCommandName, model, resourceName, cancellationToken).ConfigureAwait(false);
var deletedResourceIds = new JsonArray();
foreach (var resourceId in result.ResourceIds)
{
deletedResourceIds.Add(JsonValue.Create(resourceId));
}
json["deletedResourceCount"] = result.ResourceIds.Count;
json["deletedResourceIds"] = deletedResourceIds;
return CreateJsonResultData(json);
}
private async Task<JsonObject> CreateResourceCommandResultJsonAsync(string commandName, DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken)
{
var json = await CreateCommandResultJsonAsync(commandName, resourceName, cancellationToken).ConfigureAwait(false);
var targetResources = GetTargetAzureResources(
model,
resourceName,
includeAnnotationParentRelationships: commandName != ChangeResourceLocationCommandName);
var effectiveLocation = await GetEffectiveResourceLocationAsync(GetDeploymentStateResourceName(targetResources[0]), cancellationToken).ConfigureAwait(false);
json["resourceCount"] = targetResources.Count;
json["location"] = effectiveLocation;
json["effectiveLocation"] = effectiveLocation;
return json;
}
private async Task<CommandResultData> CreateAzureResourceInfoCommandResultDataAsync(DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken)
{
var targetResources = GetTargetAzureResources(model, resourceName, includeAnnotationParentRelationships: true);
// Targeting a parent Azure resource can include children and role assignments that must
// be reprovisioned together. The info command, however, reports the resource the user
// named so agents can map the command output back to the visible dashboard resource.
var targetResource = targetResources[0];
var json = await CreateCommandResultJsonAsync(GetAzureResourceCommandName, resourceName, cancellationToken).ConfigureAwait(false);
var effectiveLocation = await GetEffectiveResourceLocationAsync(GetDeploymentStateResourceName(targetResource), cancellationToken).ConfigureAwait(false);
json["resourceCount"] = targetResources.Count;
json["location"] = effectiveLocation;
json["effectiveLocation"] = effectiveLocation;
if (targetResource.AzureResource is AzureBicepResource bicepResource)
{
var context = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false);
// Include both cached deployment state and a best-effort live probe. Cached state is
// available offline; the live block lets agents distinguish "state exists but resource
// was deleted" from "state exists and ARM can still find it".
var deployment = await CreateCachedDeploymentStateInfoAsync(bicepResource, context, cancellationToken).ConfigureAwait(false);
json["deployment"] = deployment;
json["live"] = await CreateLiveResourceInfoAsync(
deployment.TryGetPropertyValue("resourceId", out var resourceIdNode) ? resourceIdNode?.GetValue<string>() : null,
context,
cancellationToken).ConfigureAwait(false);
}
return CreateJsonResultData(json, displayImmediately: true);
}
private async Task<JsonObject> CreateCachedDeploymentStateInfoAsync(AzureBicepResource resource, AzureContextState context, CancellationToken cancellationToken)
{
var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false);
var deploymentId = section.Data["Id"]?.GetValue<string>();
// Deployment state stores JSON as strings because it is shared with the lower-level
// provisioner. Parse lazily for the diagnostic command so malformed cached state can be
// reported next to the raw payload instead of breaking command execution.
var outputs = ParseDeploymentStateJson(resource.Name, "Outputs", section.Data["Outputs"]?.GetValue<string>());
var resourceId = TryGetOutputValue(outputs, "id");
var tenantId = Guid.TryParse(context.TenantId, out var parsedTenantId) ? parsedTenantId : (Guid?)null;
var json = new JsonObject
{
["hasState"] = section.Data.Count > 0,
["deploymentId"] = deploymentId,
["resourceId"] = resourceId,
["resourcePortalUrl"] = resourceId is not null ? AzurePortalUrls.GetResourceUrl(resourceId, tenantId) : null,
["locationOverride"] = section.Data[LocationOverrideKey]?.GetValue<string>(),
["checksum"] = section.Data["CheckSum"]?.GetValue<string>(),
["outputs"] = outputs,
["scope"] = ParseDeploymentStateJson(resource.Name, "Scope", section.Data["Scope"]?.GetValue<string>())
};
// Do not echo cached deployment parameters in user-facing diagnostics. ARM parameter
// files can contain secure values such as generated passwords or connection strings;
// location and override information is surfaced separately above.
if (section.Data[BicepProvisioner.DeploymentStateProvisioningStateKey]?.GetValue<string>() is { Length: > 0 } provisioningState)
{
json["provisioningState"] = provisioningState;
}
if (deploymentId is not null &&
ResourceIdentifier.TryParse(deploymentId, out var deploymentResourceId) &&
deploymentResourceId is not null)
{
// The deployment ID is itself an ARM resource ID. If it parses, provide a portal link to
// the deployment operation in addition to the provisioned resource link.
json["deploymentPortalUrl"] = AzurePortalUrls.GetDeploymentUrl(deploymentResourceId);
}
return json;
}
private async Task<JsonObject> CreateLiveResourceInfoAsync(string? resourceId, AzureContextState context, CancellationToken cancellationToken)
{
// Start with a "not checked" shape so every early-return path is explicit and callers do not
// need to infer why live ARM state is missing from absent properties.
var json = new JsonObject
{
["checked"] = false,
["exists"] = null
};
if (string.IsNullOrWhiteSpace(resourceId))
{
json["reason"] = "missing-resource-id";
json["source"] = "aspire";
json["code"] = "missing-resource-id";
json["message"] = "Live Azure resource existence cannot be checked because cached deployment state does not contain a resource ID. This can happen after state is forgotten, deleted, or before provisioning completes.";
json["recommendedActions"] = AzureProvisioningFailureDetails.CreateRecommendedActionsJsonArray(
AzureProvisioningFailureDetails.GetRecommendedActions(AzureProvisioningFailureDetails.MissingResourceIdReason));
return json;
}
if (!Guid.TryParse(context.SubscriptionId, out _))
{
json["reason"] = "invalid-subscription-id";
return json;
}
try
{
var armClientProvider = serviceProvider.GetRequiredService<IArmClientProvider>();
var tokenCredentialProvider = serviceProvider.GetRequiredService<ITokenCredentialProvider>();
var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, context.SubscriptionId);
json["checked"] = true;
var exists = await armClient.ResourceExistsAsync(resourceId, cancellationToken).ConfigureAwait(false);
json["exists"] = exists;
if (!exists)
{
json["reason"] = AzureProvisioningFailureDetails.MissingLiveResourceReason;
json["source"] = "azure";
json["code"] = AzureProvisioningFailureDetails.MissingLiveResourceReason;
json["message"] = "Cached deployment state points at an Azure resource ID, but the resource was not found in Azure. This can happen when resources are deleted outside Aspire or a resource group deletion is still in progress.";
json["recommendedActions"] = AzureProvisioningFailureDetails.CreateRecommendedActionsJsonArray(
AzureProvisioningFailureDetails.GetRecommendedActions(AzureProvisioningFailureDetails.MissingLiveResourceReason));
}
}
catch (CredentialUnavailableException ex)
{
// get-azure-resource is a diagnostic command. Return a machine-readable reason instead
// of failing the command so local runs without Azure auth still expose cached state.
_logger.LogDebug(ex, "Unable to query live Azure resource state for {ResourceId} because no Azure credential is available.", resourceId);
json["reason"] = "credential-unavailable";
json["message"] = ex.Message;
}
catch (RequestFailedException ex)
{
// Surface ARM failures as structured JSON so agents can distinguish "missing",
// authorization failures, and transient request errors without scraping logs.
_logger.LogDebug(ex, "Unable to query live Azure resource state for {ResourceId}.", resourceId);
var failure = AzureProvisioningFailureDetails.FromRequestFailedException(ex, AzureProvisioningFailureDetails.LiveResourceCheckOperation);
json["reason"] = "request-failed";
json["source"] = "azure";
json["status"] = ex.Status;
json["provider"] = failure.Provider;
json["httpStatus"] = failure.HttpStatus;
json["errorCode"] = failure.ErrorCode;
json["message"] = failure.ErrorMessage;
json["operation"] = failure.Operation;
json["requestId"] = failure.RequestId;
json["correlationId"] = failure.CorrelationId;
if (!failure.RecommendedActions.IsDefaultOrEmpty)
{
json["recommendedActions"] = AzureProvisioningFailureDetails.CreateRecommendedActionsJsonArray(failure.RecommendedActions);
}
}
return json;
}
private JsonNode? ParseDeploymentStateJson(string resourceName, string propertyName, string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
// Keep parse failures in the command payload instead of throwing so a diagnostic
// command can still show the rest of the cached state.
return AzureProvisioningJsonHelpers.ParseDeploymentStateJson(json);
}
catch (JsonException ex)
{
_logger.LogDebug(ex, "Unable to parse cached {PropertyName} for Azure resource {ResourceName}.", propertyName, resourceName);
return new JsonObject
{
["parseError"] = ex.Message,
["raw"] = json
};
}
}
private static string? TryGetOutputValue(JsonNode? outputs, string outputName)
{
// Bicep deployment outputs are persisted in the ARM output shape:
// { "id": { "type": "String", "value": "/subscriptions/..." } }
// Only the nested value is useful to commands; ignore partial/malformed output entries.
if (outputs is not JsonObject outputsObject ||
!outputsObject.TryGetPropertyValue(outputName, out var outputNode) ||
outputNode is not JsonObject outputObject ||
!outputObject.TryGetPropertyValue("value", out var valueNode))
{
return null;
}
return valueNode?.ToString();
}
private async Task<JsonObject> CreateCommandResultJsonAsync(string commandName, string? resourceName, CancellationToken cancellationToken)
{
var context = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false);
// Keep command output machine-readable and stable for agents/CLI automation. Additional
// command-specific details are appended by the caller, but the Azure context fields are
// present on every successful Azure command result.
var json = new JsonObject
{
["schemaVersion"] = 1,
["command"] = commandName,
["success"] = true,
["subscriptionId"] = context.SubscriptionId,
["tenantId"] = context.TenantId,
["resourceGroup"] = context.ResourceGroup,
["azureLocation"] = context.Location,
["azureContext"] = new JsonObject
{
["subscriptionId"] = context.SubscriptionId,
["tenantId"] = context.TenantId,
["resourceGroup"] = context.ResourceGroup,
["location"] = context.Location
}
};
if (!string.IsNullOrEmpty(resourceName))
{
json["resourceName"] = resourceName;
}
return json;
}
private static CommandResultData CreateJsonResultData(JsonObject json, bool displayImmediately = false) =>
new()
{
// Serialize through the shared helper so formatting/scrubbing stays consistent with the
// rest of Azure provisioning diagnostics.
Value = AzureProvisioningJsonHelpers.ToCommandResultJsonString(json),
Format = CommandResultFormat.Json,
DisplayImmediately = displayImmediately
};
private static ExecuteCommandResult CreateFailureCommandResult(Exception exception, string? operation = null)
{
if (AzureProvisioningFailureDetails.TryCreate(exception, operation) is not { } failure)
{
return CommandResults.Failure(exception.Message);
}
// Keep the human-facing message unchanged for existing dashboard/CLI behavior, but attach
// a stable diagnostics array so agents and JSON clients can reason over provider, code,
// resource type, request IDs, and recommended actions without scraping the rendered text.
var json = new JsonObject
{
["schemaVersion"] = 1,
["success"] = false,
["message"] = exception.Message,
["status"] = "failed",
["summary"] = exception.Message,
["diagnostics"] = new JsonArray
{
failure.ToJsonObject()
}
};
return CommandResults.Failure(exception.Message, CreateJsonResultData(json, displayImmediately: true));
}
private async Task ApplyResourceOverridesAsync(IAzureResource azureResource, CancellationToken cancellationToken)
{
if (azureResource is not AzureBicepResource bicepResource)
{
return;
}
var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{bicepResource.Name}", cancellationToken).ConfigureAwait(false);
if (section.Data[LocationOverrideKey]?.GetValue<string>() is { Length: > 0 } locationOverride)
{
// Normalize old override values opportunistically. Older state or manual edits may store
// display names such as "West US 2"; Bicep parameters should use canonical names such as
// "westus2" so equality checks and ARM deployments are stable.
var normalizedLocation = NormalizeLocation(locationOverride, await GetLocationOptionsAsync(cancellationToken).ConfigureAwait(false));
if (!string.Equals(normalizedLocation, locationOverride, StringComparison.Ordinal))
{
section.Data[LocationOverrideKey] = normalizedLocation;
await deploymentStateManager.SaveSectionAsync(section, cancellationToken).ConfigureAwait(false);
}
bicepResource.Parameters[AzureBicepResource.KnownParameters.Location] = normalizedLocation;
}
}
private async Task<string?> GetEffectiveResourceLocationAsync(string resourceName, CancellationToken cancellationToken)
{
var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resourceName}", cancellationToken).ConfigureAwait(false);
if (section.Data[LocationOverrideKey]?.GetValue<string>() is { Length: > 0 } locationOverride)
{
// Per-resource overrides win over the environment location. The dashboard command uses
// this to show the effective value users will get on the next provisioning pass.
return locationOverride;
}
return (await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false)).Location;
}
private async Task SetResourceLocationOverrideAsync(string resourceName, string location, CancellationToken cancellationToken)
{
var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resourceName}", cancellationToken).ConfigureAwait(false);
section.Data[LocationOverrideKey] = location;
await deploymentStateManager.SaveSectionAsync(section, cancellationToken).ConfigureAwait(false);
}
// Deployment state is keyed by the AzureBicepResource name because the provisioner owns that
// state. Some visible resources are projected to a separate AzureBicepResource, so do not use
// the visible Aspire resource name when a Bicep resource is available.
private static string GetDeploymentStateResourceName((IResource Resource, IAzureResource AzureResource) resource)
=> resource.AzureResource is AzureBicepResource bicepResource ? bicepResource.Name : resource.Resource.Name;
private async Task<int> CancelCachedDeploymentsAsync(
IReadOnlyCollection<(IResource Resource, IAzureResource AzureResource)> targetResources,
bool requireDeployment,
string? requiredDeploymentResourceName,
CancellationToken cancellationToken)
{
var canceledDeploymentIds = new HashSet<string>(StringComparers.AzureResourceId);
foreach (var resource in targetResources)
{
if (resource.AzureResource is not AzureBicepResource bicepResource)
{
continue;
}
var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{bicepResource.Name}", cancellationToken).ConfigureAwait(false);
if (TryGetCachedDeploymentId(section) is not { } deploymentId ||
!IsActiveCachedDeployment(section))
{
// Only active cached deployments can be canceled. Completed or missing deployments
// should not turn a best-effort cleanup into an error unless the caller explicitly
// required an active deployment below.
continue;
}
if (canceledDeploymentIds.Add(deploymentId))
{
// Multiple Aspire resources can share one ARM deployment. Track IDs so a resource
// tree cancellation sends at most one cancel request per deployment.
await CancelCachedDeploymentAsync(deploymentId, loggerService.GetLogger(resource.AzureResource), cancellationToken).ConfigureAwait(false);
}
await MarkCachedDeploymentCanceledAsync(
$"Azure:Deployments:{bicepResource.Name}",
deploymentId,
cancellationToken).ConfigureAwait(false);
}
if (requireDeployment && canceledDeploymentIds.Count == 0)
{
var resourceName = requiredDeploymentResourceName ?? (targetResources.Count == 1 ? targetResources.Single().Resource.Name : string.Join(", ", targetResources.Select(static resource => resource.Resource.Name)));
throw new InvalidOperationException($"No active cached Azure deployment was found for resource '{resourceName}'.");
}
return canceledDeploymentIds.Count;
}
private async Task MarkCachedDeploymentCanceledAsync(string sectionName, string deploymentId, CancellationToken cancellationToken)
{
const int maxAttempts = 3;
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
var section = await deploymentStateManager.AcquireSectionAsync(sectionName, cancellationToken).ConfigureAwait(false);
if (TryGetCachedDeploymentId(section) is not { } currentDeploymentId ||
!StringComparers.AzureResourceId.Equals(currentDeploymentId, deploymentId) ||
!IsActiveCachedDeployment(section))
{
return;
}
// The active provisioner and the fast-path cancel command can update the same deployment
// state section concurrently. Re-acquire before marking the state canceled and retry when
// optimistic concurrency detects that the provisioner saved a newer snapshot first.
section.Data[BicepProvisioner.DeploymentStateProvisioningStateKey] = BicepProvisioner.DeploymentStateProvisioningStateCanceled;
try
{
await deploymentStateManager.SaveSectionAsync(section, cancellationToken).ConfigureAwait(false);
return;
}
catch (InvalidOperationException ex) when (IsDeploymentStateConcurrencyConflict(ex))
{
if (attempt == maxAttempts - 1)
{
throw;
}
}
}
}
private static bool IsDeploymentStateConcurrencyConflict(InvalidOperationException ex)
=> ex.Message.Contains("Concurrency conflict detected in section", StringComparison.Ordinal);
private async Task CancelCachedDeploymentAsync(string deploymentId, ILogger resourceLogger, CancellationToken cancellationToken)
{
var armClient = await GetArmClientForResourceIdAsync(deploymentId, cancellationToken).ConfigureAwait(false);
try
{
await armClient.CancelDeploymentAsync(deploymentId, cancellationToken).ConfigureAwait(false);
resourceLogger.LogInformation("Cancellation requested for Azure deployment {DeploymentId}.", deploymentId);
}
catch (RequestFailedException ex) when (ex.Status == 404 || ex.Status == 409)
{
// ARM returns 404 when the deployment record is already gone and 409 when the deployment
// is no longer in a cancelable state. Both mean the requested end state has effectively
// been reached from Aspire's perspective.
_logger.LogInformation(ex, "Azure deployment {DeploymentId} was already absent or no longer active during cancellation.", deploymentId);
resourceLogger.LogInformation("Azure deployment {DeploymentId} was already absent or no longer active during cancellation.", deploymentId);
}
}
private async Task<IReadOnlyList<string>> GetAzureResourceIdsForDeletionAsync(
IReadOnlyCollection<(IResource Resource, IAzureResource AzureResource)> targetResources,
CancellationToken cancellationToken)
{
var resourceIds = new HashSet<string>(StringComparers.AzureResourceId);
foreach (var resource in targetResources)
{
if (resource.AzureResource is not AzureBicepResource bicepResource)
{
continue;
}
if (await TryGetResourceIdFromDeploymentStateAsync(bicepResource, cancellationToken).ConfigureAwait(false) is { } resourceId &&
!IsArmDeploymentResourceId(resourceId))
{
// The primary output ID is usually the user-visible resource. Exclude deployment
// resources because deleting the ARM deployment record does not delete the resources
// the deployment created.
resourceIds.Add(resourceId);
}
// Some Bicep files create more than one Azure resource. Ask ARM for the deployment
// operation targets so delete-resource removes all created resources, not just the main
// output ID.
await AddDeploymentOperationTargetResourceIdsAsync(bicepResource, resourceIds, cancellationToken).ConfigureAwait(false);
}
// Delete children before parents by ordering longer resource IDs first. Azure child resource
// IDs include their parent ID as a prefix, so length is a cheap dependency-safe heuristic.
return [.. resourceIds.OrderByDescending(static resourceId => resourceId.Length)];
}
private async Task AddDeploymentOperationTargetResourceIdsAsync(AzureBicepResource resource, HashSet<string> resourceIds, CancellationToken cancellationToken)
{
var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false);
if (TryGetCachedDeploymentId(section) is not { } deploymentId)
{
return;
}
var armClient = await GetArmClientForResourceIdAsync(deploymentId, cancellationToken).ConfigureAwait(false);
try
{
await foreach (var resourceId in armClient.GetDeploymentTargetResourceIdsAsync(deploymentId, cancellationToken).ConfigureAwait(false))
{
if (!IsArmDeploymentResourceId(resourceId))
{
resourceIds.Add(resourceId);
}
}
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
_logger.LogInformation(ex, "Azure deployment {DeploymentId} was absent while collecting target resources for {ResourceName}.", deploymentId, resource.Name);
}
}
private async Task DeleteAzureResourceIdsAsync(IReadOnlyList<string> resourceIds, string resourceName, string? resourceLocation, string? fallbackResourceLocation, bool allowKeyVaultPurgeTimeout, CancellationToken cancellationToken)
{
foreach (var resourceId in resourceIds)
{
// Resolve the ARM client per resource ID because a resource tree can contain resources
// from deployment state that point at a different subscription than the current context.
var armClient = await GetArmClientForResourceIdAsync(resourceId, cancellationToken).ConfigureAwait(false);
await DeleteAzureResourceIdAndPurgeDeletedKeyVaultAsync(
armClient,
resourceId,
resourceName,
resourceLocation,
fallbackResourceLocation,
allowKeyVaultPurgeTimeout,
cancellationToken).ConfigureAwait(false);
}
}
private async Task<bool> DeleteAzureResourceIdAndPurgeDeletedKeyVaultAsync(
IArmClient armClient,
string resourceId,
string resourceName,
string? resourceLocation,
string? fallbackResourceLocation,
bool allowKeyVaultPurgeTimeout,
CancellationToken cancellationToken)
{
_logger.LogInformation("Deleting Azure resource {ResourceId} for {ResourceName}.", resourceId, resourceName);
try
{
await armClient.DeleteResourceAsync(resourceId, cancellationToken).ConfigureAwait(false);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
_logger.LogInformation(ex, "Azure resource {ResourceId} was already absent while deleting resources for {ResourceName}.", resourceId, resourceName);
}
return await PurgeDeletedKeyVaultAsync(
armClient,
resourceId,
resourceName,
resourceLocation,
fallbackResourceLocation,
allowKeyVaultPurgeTimeout,
cancellationToken).ConfigureAwait(false);
}
private async Task<bool> PurgeDeletedKeyVaultAsync(
IArmClient armClient,
string resourceId,
string resourceName,
string? resourceLocation,
string? fallbackResourceLocation,
bool allowTimeout,
CancellationToken cancellationToken)
{
if (!IsKeyVaultResourceId(resourceId))
{
return false;
}
var keyVaultLocations = GetKeyVaultPurgeLocations(resourceLocation, fallbackResourceLocation);
foreach (var keyVaultLocation in keyVaultLocations)
{
try
{
if (await armClient.PurgeDeletedKeyVaultAsync(resourceId, keyVaultLocation, cancellationToken).ConfigureAwait(false))
{
_logger.LogInformation("Purged deleted Azure Key Vault {ResourceId} in {Location} for {ResourceName}.", resourceId, keyVaultLocation, resourceName);
// The locations are candidates for the same recoverable vault tombstone. Once
// Azure finds and accepts the purge in one location, there is no second
// tombstone to purge.
return true;
}
_logger.LogInformation("Deleted Azure Key Vault {ResourceId} was not found in {Location} while purging for {ResourceName}.", resourceId, keyVaultLocation, resourceName);
}
catch (TimeoutException ex) when (allowTimeout)
{
_logger.LogWarning(ex, "Timed out waiting for deleted Azure Key Vault {ResourceId} in {Location} to be purged for {ResourceName}. The live vault was deleted and purge was requested; a later reprovision can retry if Azure is still finalizing the tombstone.", resourceId, keyVaultLocation, resourceName);
return true;
}
}
return false;
}
private static bool IsKeyVaultResourceId(string resourceId)
{
return ResourceIdentifier.TryParse(resourceId, out var parsedResourceId) &&
parsedResourceId is not null &&
string.Equals(parsedResourceId.ResourceType.ToString(), KeyVaultVaultResourceType, StringComparisons.AzureResourceType);
}
private static IReadOnlyList<string> GetKeyVaultPurgeLocations(string? resourceLocation, string? fallbackResourceLocation)
{
// Deleted Key Vault lookups are location-scoped. Try the resource's effective location first,
// then the current Azure context location as a fallback for older cached state or partial
// failures where the resource-specific location was not persisted.
var keyVaultLocations = new List<string>();
AddLocationIfPresent(keyVaultLocations, resourceLocation);
AddLocationIfPresent(keyVaultLocations, fallbackResourceLocation);
return keyVaultLocations;
}
private static void AddLocationIfPresent(List<string> locations, string? location)
{
if (string.IsNullOrWhiteSpace(location) ||
locations.Contains(location, StringComparers.AzureLocation))
{
return;
}
locations.Add(location);
}
private async Task<IArmClient> GetArmClientForResourceIdAsync(string resourceId, CancellationToken cancellationToken)
{
string? subscriptionId = null;
if (ResourceIdentifier.TryParse(resourceId, out var parsedResourceId) &&
parsedResourceId is not null)
{
// Prefer the subscription embedded in the ARM resource ID. This makes cleanup resilient
// after the user changes Azure context but still has cached state for old resources.
subscriptionId = parsedResourceId.SubscriptionId;
}
if (string.IsNullOrWhiteSpace(subscriptionId))
{
subscriptionId = (await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false)).SubscriptionId;
}
if (!Guid.TryParse(subscriptionId, out _))
{
throw new MissingConfigurationException("Azure resources cannot be managed because the Azure subscription ID is missing or invalid.");
}
var armClientProvider = serviceProvider.GetRequiredService<IArmClientProvider>();
var tokenCredentialProvider = serviceProvider.GetRequiredService<ITokenCredentialProvider>();
return armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, subscriptionId);
}
private static string? TryGetCachedDeploymentId(DeploymentStateSection section)
=> section.Data["Id"]?.GetValue<string>() is { Length: > 0 } deploymentId ? deploymentId : null;
private static bool IsActiveCachedDeployment(DeploymentStateSection section)
=> string.Equals(
section.Data[BicepProvisioner.DeploymentStateProvisioningStateKey]?.GetValue<string>(),
BicepProvisioner.DeploymentStateProvisioningStateRunning,
StringComparison.Ordinal);
private static bool IsArmDeploymentResourceId(string resourceId)
{
if (!ResourceIdentifier.TryParse(resourceId, out var parsedResourceId) ||
parsedResourceId is null)
{
return false;
}
return string.Equals(parsedResourceId.ResourceType.ToString(), "Microsoft.Resources/deployments", StringComparisons.AzureResourceType);
}
private string? TryGetCurrentResourceLocationOverride(AzureBicepResource resource, string? environmentLocation)
{
var currentLocationValue = TryGetCurrentResourceLocation(resource);
if (!string.IsNullOrWhiteSpace(currentLocationValue) &&
(string.IsNullOrWhiteSpace(environmentLocation) ||
!string.Equals(currentLocationValue, environmentLocation, StringComparisons.AzureLocation)))
{
// The dashboard snapshot is the most recent observed effective location. Prefer it when
// deciding whether a reset should preserve a per-resource override.
return currentLocationValue;
}
if (resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.Location, out var parameterLocation) &&
parameterLocation?.ToString() is { Length: > 0 } parameterLocationValue &&
(string.IsNullOrWhiteSpace(environmentLocation) ||
!string.Equals(parameterLocationValue, environmentLocation, StringComparisons.AzureLocation)))
{
// If the Bicep parameter is already different from the environment location, treat it as
// an explicit per-resource setting that should survive reset/reprovision operations.
return parameterLocationValue;
}
return null;
}
private string? TryGetCurrentResourceLocation(AzureBicepResource resource)
{
if (!notificationService.TryGetCurrentState(resource.Name, out var resourceEvent))
{
return null;
}
return resourceEvent.Snapshot.Properties
.FirstOrDefault(static p => string.Equals(p.Name, "azure.location", StringComparison.Ordinal))
?.Value?.ToString();
}
private string? TryGetPreservedLocationOverride(AzureBicepResource resource, DeploymentStateSection section, string? environmentLocation)
{
if (TryGetExplicitLocationOverride(section) is { } locationOverride)
{
// Explicit overrides come from the Change location command and should survive even when
// they match the current environment location.
return locationOverride;
}
if (section.Data["Parameters"]?.GetValue<string>() is not { Length: > 0 } parametersJson)
{
return null;
}
try
{
var persistedLocation = AzureProvisioningJsonHelpers.ParseDeploymentStateJson(parametersJson)?[AzureBicepResource.KnownParameters.Location]?["value"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(persistedLocation))
{
return null;
}
if (string.IsNullOrWhiteSpace(environmentLocation) ||
!string.Equals(persistedLocation, environmentLocation, StringComparisons.AzureLocation))
{
// A resource can intentionally live in a different Azure region than the environment.
// Preserve that persisted per-resource value across reprovisioning instead of replacing
// it with the current environment location.
return persistedLocation;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Unable to parse persisted parameters while preserving Azure resource location overrides.");
}
return TryGetCurrentResourceLocationOverride(resource, environmentLocation);
}
private static string? TryGetExplicitLocationOverride(DeploymentStateSection section)
=> section.Data[LocationOverrideKey]?.GetValue<string>() is { Length: > 0 } locationOverride ? locationOverride : null;
private static string NormalizeLocation(string location, IReadOnlyList<KeyValuePair<string, string>> locationOptions)
{
if (string.IsNullOrWhiteSpace(location))
{
return location;
}
foreach (var option in locationOptions)
{
if (string.Equals(option.Key, location, StringComparisons.AzureLocation) ||
string.Equals(option.Value, location, StringComparisons.AzureLocation))
{
// Prefer the option key because Azure SDK/Bicep APIs expect canonical location names
// even though users often choose or type display names.
return option.Key;
}
}
var canonicalLocation = CanonicalizeLocation(location);
if (!string.Equals(canonicalLocation, location, StringComparison.Ordinal))
{
return canonicalLocation;
}
return location;
}
private static string CanonicalizeLocation(string location)
{
// Last-resort normalization for manually typed display names when ARM enumeration is not
// available. Azure canonical names are lowercase alphanumeric values such as "westus2".
Span<char> buffer = stackalloc char[location.Length];
var index = 0;
foreach (var c in location)
{
if (char.IsLetterOrDigit(c))
{
buffer[index++] = char.ToLowerInvariant(c);
}
}
return index == 0 ? location : new string(buffer[..index]);
}
private async Task<IReadOnlyList<KeyValuePair<string, string>>> GetLocationOptionsAsync(CancellationToken cancellationToken)
{
return await GetLocationOptionsAsync(subscriptionId: null, cancellationToken).ConfigureAwait(false);
}
private async Task<IReadOnlyList<KeyValuePair<string, string>>> GetTenantOptionsAsync(CancellationToken cancellationToken)
{
try
{
var armClientProvider = serviceProvider.GetRequiredService<IArmClientProvider>();
var tokenCredentialProvider = serviceProvider.GetRequiredService<ITokenCredentialProvider>();
var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential);
return [.. (await armClient.GetAvailableTenantsAsync(cancellationToken).ConfigureAwait(false))
.Select(static tenant =>
{
var tenantId = tenant.TenantId?.ToString() ?? "";
var displayName = !string.IsNullOrEmpty(tenant.DisplayName)
? tenant.DisplayName
: !string.IsNullOrEmpty(tenant.DefaultDomain)
? tenant.DefaultDomain
: "Unknown";
var description = displayName;
if (!string.IsNullOrEmpty(tenant.DefaultDomain) &&
!string.Equals(tenant.DisplayName, tenant.DefaultDomain, StringComparison.Ordinal))
{
description += $" ({tenant.DefaultDomain})";
}
return KeyValuePair.Create(tenantId, $"{description} — {tenantId}");
})
.OrderBy(static option => option.Value)];
}
catch (Exception ex)
{
// Enumeration improves the dialog but is not required because Azure IDs are accepted as
// custom choices. Log and keep the command usable in restricted/offline environments.
_logger.LogWarning(ex, "Failed to enumerate Azure tenants for context selection.");
return [];
}
}
private async Task<IReadOnlyList<KeyValuePair<string, string>>> GetSubscriptionOptionsAsync(string? tenantId, CancellationToken cancellationToken)
{
try
{
var armClientProvider = serviceProvider.GetRequiredService<IArmClientProvider>();
var tokenCredentialProvider = serviceProvider.GetRequiredService<ITokenCredentialProvider>();
var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential);
return [.. (await armClient.GetAvailableSubscriptionsAsync(tenantId, cancellationToken).ConfigureAwait(false))
.Select(static subscription => KeyValuePair.Create(
subscription.Id.SubscriptionId ?? "",
$"{subscription.DisplayName ?? subscription.Id.SubscriptionId} ({subscription.Id.SubscriptionId})"))
.OrderBy(static option => option.Value)];
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enumerate Azure subscriptions for context selection.");
return [];
}
}
private async Task<IReadOnlyList<(string Name, string Location)>> GetResourceGroupOptionsAsync(string? subscriptionId, CancellationToken cancellationToken)
{
if (!Guid.TryParse(subscriptionId, out _))
{
// Resource group enumeration requires a valid subscription ID. Return no options rather
// than throwing so the dialog can still accept a manually entered resource group.
return [];
}
try
{
var armClientProvider = serviceProvider.GetRequiredService<IArmClientProvider>();
var tokenCredentialProvider = serviceProvider.GetRequiredService<ITokenCredentialProvider>();
var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential);
return [.. await armClient.GetAvailableResourceGroupsWithLocationAsync(subscriptionId, cancellationToken).ConfigureAwait(false)];
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enumerate Azure resource groups for context selection.");
return [];
}
}
private async Task<IReadOnlyList<KeyValuePair<string, string>>> GetLocationOptionsAsync(string? subscriptionId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(subscriptionId))
{
subscriptionId = (await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false)).SubscriptionId;
}
if (!Guid.TryParse(subscriptionId, out _))
{
// Location has a useful static fallback. Use it when the subscription is missing or
// invalid so users can still pick common Azure regions before the context is complete.
return GetStaticLocationOptions();
}
try
{
var armClientProvider = serviceProvider.GetRequiredService<IArmClientProvider>();
var tokenCredentialProvider = serviceProvider.GetRequiredService<ITokenCredentialProvider>();
var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential);
return [.. (await armClient.GetAvailableLocationsAsync(subscriptionId, cancellationToken).ConfigureAwait(false))
.Select(location => KeyValuePair.Create(location.Name, location.DisplayName))];
}
catch (Exception ex)
{
// Fall back to the AzureLocation catalog when ARM enumeration fails. This keeps the
// location picker useful even if the user lacks permission to list locations.
_logger.LogWarning(ex, "Failed to enumerate Azure locations for resource override.");
return GetStaticLocationOptions();
}
}
private static IReadOnlyList<KeyValuePair<string, string>> GetStaticLocationOptions()
{
return [.. typeof(AzureLocation)
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
.Where(static p => p.PropertyType == typeof(AzureLocation))
.Select(static p => (AzureLocation)p.GetValue(null)!)
.Select(static location => KeyValuePair.Create(location.Name, location.DisplayName ?? location.Name))];
}
private async Task<AzureContextState> GetCurrentAzureContextAsync(CancellationToken cancellationToken)
{
var section = await deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false);
// Persisted dashboard/CLI choices have highest precedence. Options/configuration are
// fallback defaults for first run or after reset, matching the provisioning context provider.
return new AzureContextState(
section.Data["SubscriptionId"]?.GetValue<string>() ?? provisionerOptions.Value.SubscriptionId ?? configuration["Azure:SubscriptionId"],
section.Data["ResourceGroup"]?.GetValue<string>() ?? provisionerOptions.Value.ResourceGroup ?? configuration["Azure:ResourceGroup"],
section.Data["Location"]?.GetValue<string>() ?? provisionerOptions.Value.Location ?? configuration["Azure:Location"],
section.Data["TenantId"]?.GetValue<string>() ?? provisionerOptions.Value.TenantId ?? configuration["Azure:TenantId"],
section.Data["Tenant"]?.GetValue<string>());
}
private bool ShouldCheckForDrift(IResource resource)
{
if (!notificationService.TryGetCurrentState(resource.Name, out var resourceEvent))
{
// No dashboard state means the resource has not been published yet, so a background ARM
// probe would race startup rather than detecting meaningful drift.
return false;
}
return resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running;
}
private async Task<string?> TryGetResourceIdFromDeploymentStateAsync(AzureBicepResource resource, CancellationToken cancellationToken)
{
var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false);
if (section.Data["Outputs"]?.GetValue<string>() is not { Length: > 0 } outputsJson)
{
// Resources that were never successfully deployed will not have an output ID. Treat that
// as "no live resource to check" rather than a drift/delete failure.
return null;
}
try
{
return AzureProvisioningJsonHelpers.ParseDeploymentStateJson(outputsJson)?["id"]?["value"]?.GetValue<string>();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Unable to parse cached outputs for resource {ResourceName} while checking for Azure drift.", resource.Name);
return null;
}
}
private async Task<LocationChangeResourceDeletion?> PrepareCachedResourceDeletionForLocationChangeAsync(
AzureBicepResource resource,
string requestedLocation,
bool confirmDelete,
CancellationToken cancellationToken)
{
var currentLocation = TryGetCurrentResourceLocation(resource) ??
await TryGetPersistedResourceLocationAsync(resource, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(currentLocation) ||
string.Equals(currentLocation, requestedLocation, StringComparisons.AzureLocation))
{
// If the current location is unknown or already matches the requested location, there is
// nothing safe or necessary to delete before reprovisioning.
return null;
}
if (await TryGetResourceIdFromDeploymentStateAsync(resource, cancellationToken).ConfigureAwait(false) is not { } resourceId)
{
// Without a cached resource ID we cannot target the old live resource. Let
// reprovisioning proceed and surface any ARM conflict through the normal deployment path.
return null;
}
var context = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false);
if (!Guid.TryParse(context.SubscriptionId, out _))
{
// Deleting for a location change is a best-effort preflight. If context is invalid, avoid
// making a destructive call and let the subsequent provisioning validation report the
// missing subscription configuration.
return null;
}
var armClientProvider = serviceProvider.GetRequiredService<IArmClientProvider>();
var tokenCredentialProvider = serviceProvider.GetRequiredService<ITokenCredentialProvider>();
var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, context.SubscriptionId);
if (!await armClient.ResourceExistsAsync(resourceId, cancellationToken).ConfigureAwait(false))
{
// Cached state can point at a resource that has already been manually deleted. In that
// case the location change only needs to update local override state and reprovision.
return null;
}
if (!confirmDelete)
{
// Resource command confirmations are UI metadata and are not enforced by non-interactive
// CLI or MCP callers. Require an explicit command argument before publishing transient
// state or reaching the destructive boundary.
throw new InvalidOperationException(FormatUserString(
AzureProvisioningStrings.ChangeResourceLocationDeleteConfirmationRequiredFormat,
resource.Name,
ConfirmDeleteArgumentName));
}
return new(armClient, resourceId, currentLocation);
}
private async Task DeleteCachedResourceForLocationChangeAsync(
AzureBicepResource resource,
string requestedLocation,
LocationChangeResourceDeletion? resourceDeletion,
CancellationToken cancellationToken)
{
if (resourceDeletion is null)
{
return;
}
_logger.LogInformation(
"Deleting Azure resource {ResourceId} before reprovisioning {ResourceName} from {CurrentLocation} to {RequestedLocation}.",
resourceDeletion.ResourceId,
resource.Name,
resourceDeletion.CurrentLocation,
requestedLocation);
try
{
await resourceDeletion.ArmClient.DeleteResourceAsync(resourceDeletion.ResourceId, cancellationToken).ConfigureAwait(false);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
_logger.LogInformation(
"Azure resource {ResourceId} was already absent before reprovisioning {ResourceName} from {CurrentLocation} to {RequestedLocation}.",
resourceDeletion.ResourceId,
resource.Name,
resourceDeletion.CurrentLocation,
requestedLocation);
}
await PurgeDeletedKeyVaultAsync(
resourceDeletion.ArmClient,
resourceDeletion.ResourceId,
resource.Name,
resourceDeletion.CurrentLocation,
null,
allowTimeout: false,
cancellationToken).ConfigureAwait(false);
}
private async Task<string?> TryGetPersistedResourceLocationAsync(AzureBicepResource resource, CancellationToken cancellationToken)
{
var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false);
if (section.Data[LocationOverrideKey]?.GetValue<string>() is { Length: > 0 } locationOverride)
{
// The explicit override is the most reliable persisted effective location because it was
// written by the controller rather than inferred from a deployment payload.
return locationOverride;
}
if (section.Data["Parameters"]?.GetValue<string>() is not { Length: > 0 } parametersJson)
{
return null;
}
try
{
return AzureProvisioningJsonHelpers.ParseDeploymentStateJson(parametersJson)?[AzureBicepResource.KnownParameters.Location]?["value"]?.GetValue<string>();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Unable to parse persisted parameters while checking whether Azure resource {ResourceName} must be deleted for a location change.", resource.Name);
return null;
}
}
private async Task<bool> IsMissingCachedResourceAsync(AzureBicepResource resource, CancellationToken cancellationToken)
{
if (await TryGetResourceIdFromDeploymentStateAsync(resource, cancellationToken).ConfigureAwait(false) is not { } resourceId)
{
// No cached ID means there is no prior live resource to verify.
return false;
}
var context = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false);
if (!Guid.TryParse(context.SubscriptionId, out _))
{
// Missing context should fail during provisioning setup, not be interpreted as drift.
return false;
}
try
{
var armClientProvider = serviceProvider.GetRequiredService<IArmClientProvider>();
var tokenCredentialProvider = serviceProvider.GetRequiredService<ITokenCredentialProvider>();
var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, context.SubscriptionId);
return !await armClient.ResourceExistsAsync(resourceId, cancellationToken).ConfigureAwait(false);
}
catch (CredentialUnavailableException ex)
{
// Offline development should not clear otherwise valid cached state just because the
// drift probe cannot authenticate.
_logger.LogDebug(ex, "Unable to verify cached Azure resource state for {ResourceName} because no Azure credential is available.", resource.Name);
return false;
}
catch (RequestFailedException ex)
{
// Treat probe failures as inconclusive. A transient ARM or authorization failure should
// not force reprovisioning and potentially overwrite valid resources.
_logger.LogDebug(ex, "Unable to verify cached Azure resource state for {ResourceName} because the Azure resource probe failed.", resource.Name);
return false;
}
}
private async Task ClearCachedDeploymentStateAsync(
AzureBicepResource resource,
bool preserveOverrides,
string? environmentLocation,
string? currentLocationOverride,
bool preserveInferredLocationOverrides,
CancellationToken cancellationToken)
{
var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false);
var locationOverride = preserveOverrides
? TryGetExplicitLocationOverride(section) ?? (preserveInferredLocationOverrides
? currentLocationOverride ?? TryGetPreservedLocationOverride(resource, section, environmentLocation)
: null)
: null;
// Clear all provisioner state in one place so outputs, deployment IDs, checksums, and old
// parameters cannot leak into the next provisioning pass. Only the selected location override
// is intentionally copied forward.
section.Data.Clear();
if (locationOverride is not null)
{
section.Data[LocationOverrideKey] = locationOverride;
await deploymentStateManager.SaveSectionAsync(section, cancellationToken).ConfigureAwait(false);
}
else
{
// Removing the section entirely keeps the deployment state store small and lets callers
// distinguish "no state" from "state exists only for an override".
await deploymentStateManager.DeleteSectionAsync(section, cancellationToken).ConfigureAwait(false);
}
}
private CustomResourceSnapshot ApplyActiveOperationProperties(IResource resource, CustomResourceSnapshot state)
{
ActiveAzureOperationSnapshot? activeOperation;
lock (_operationStateLock)
{
activeOperation = _state.Status.ActiveOperation?.CreateSnapshot();
}
var properties = state.Properties.WithoutActiveOperationProperties();
if (activeOperation is null ||
resource is not AzureEnvironmentResource && !activeOperation.AffectsResource(resource.Name))
{
return state with { Properties = properties };
}
return state with
{
Properties = properties.SetResourcePropertyRange(
AzureResourceProperties.CreateActiveOperationProperties(
activeOperation.DisplayName,
activeOperation.Phase,
activeOperation.Status,
activeOperation.TargetLocation,
activeOperation.StartedAt))
};
}
private async Task RefreshCommandStatesAsync(DistributedApplicationModel model, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var resource in GetResourcesForCommandStateRefresh(model))
{
// Publishing the same state is intentional: command availability is calculated during
// snapshot publication, so a no-op state update still refreshes dashboard command state.
await notificationService.PublishUpdateAsync(resource, state => ApplyActiveOperationProperties(resource, state)).ConfigureAwait(false);
}
}
private static IEnumerable<IResource> GetResourcesForCommandStateRefresh(DistributedApplicationModel model)
{
var seenNames = new HashSet<string>(StringComparer.Ordinal);
var resources = new List<IResource>();
if (model.Resources.OfType<AzureEnvironmentResource>().SingleOrDefault() is { } environmentResource)
{
Add(environmentResource);
}
foreach (var (resource, azureResource) in GetProvisionableAzureResources(model))
{
// Include both the visible resource and its Azure provisioning surrogate. Some command
// annotations are attached to one while dashboard state may be keyed by the other.
Add(resource);
Add(azureResource);
}
return resources;
void Add(IResource resource)
{
if (seenNames.Add(resource.Name))
{
resources.Add(resource);
}
}
}
private async Task PublishUpdateToResourceTreeAsync(
(IResource Resource, IAzureResource AzureResource) resource,
ILookup<IResource, IResourceWithParent> parentChildLookup,
Func<CustomResourceSnapshot, CustomResourceSnapshot> stateFactory)
{
async Task PublishAsync(IResource targetResource)
{
await notificationService.PublishUpdateAsync(targetResource, state => ApplyActiveOperationProperties(targetResource, stateFactory(state))).ConfigureAwait(false);
}
// Some model resources are represented by a surrogate AzureBicepResource during
// provisioning. Publish to both so CLI wait/dashboard state stays consistent whether
// callers address the visible resource or the Azure resource used by the provisioner.
await PublishAsync(resource.AzureResource).ConfigureAwait(false);
if (resource.Resource != resource.AzureResource)
{
await PublishAsync(resource.Resource).ConfigureAwait(false);
}
var childResources = parentChildLookup[resource.Resource].ToList();
for (var i = 0; i < childResources.Count; i++)
{
var child = childResources[i];
foreach (var grandChild in parentChildLookup[child])
{
if (!childResources.Contains(grandChild))
{
// Walk descendants without recursion so deeply nested resource graphs do not
// risk stack growth while publishing a broad parent update.
childResources.Add(grandChild);
}
}
await PublishAsync(child).ConfigureAwait(false);
}
}
private async Task AfterProvisionAsync(
(IResource Resource, IAzureResource AzureResource) resource,
ILookup<IResource, IResourceWithParent> parentChildLookup)
{
try
{
await resource.AzureResource.ProvisioningTaskCompletionSource!.Task.ConfigureAwait(false);
// ARM deployment completion only means the resources exist. Role assignment
// propagation can lag, so do not mark the resource Running until the assigned
// principals can actually use the provisioned resource.
var rolesFailed = await WaitForRoleAssignmentsAsync(resource, parentChildLookup).ConfigureAwait(false);
if (!rolesFailed)
{
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new(KnownResourceStates.Running, KnownResourceStateStyles.Success)
}).ConfigureAwait(false);
}
}
catch (MissingConfigurationException)
{
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new(AzureProvisioningStrings.ResourceStateMissingSubscriptionConfiguration, KnownResourceStateStyles.Error)
}).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new(AzureProvisioningStrings.ResourceStateCanceled, KnownResourceStateStyles.Info)
}).ConfigureAwait(false);
}
catch (Exception ex)
{
if (await TryPublishTerminalDeploymentStateAsync(resource, parentChildLookup).ConfigureAwait(false))
{
return;
}
var failureDetails = AzureProvisioningFailureDetails.TryCreate(ex, AzureProvisioningFailureDetails.ProvisionOperation);
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new(AzureProvisioningStrings.ResourceStateFailedToProvision, KnownResourceStateStyles.Error),
Properties = failureDetails is null
? state.Properties
: failureDetails.SetResourceProperties(state.Properties, AzureProvisioningFailureDetails.ProvisionOperation)
}).ConfigureAwait(false);
}
}
private async Task PublishSyntheticProvisioningFailureAsync(
DistributedApplicationModel model,
IReadOnlyList<(IResource Resource, IAzureResource AzureResource)> targetResources,
AzureProvisioningFailureDetails failureDetails)
{
// This is only for synthetic reprovision failures raised outside the normal per-resource
// provisioning task. Exceptions observed by AfterProvisionAsync keep their existing path so
// ARM-specific terminal states are not replaced with a generic provisioning failure.
var parentChildLookup = model.Resources.OfType<IResourceWithParent>().ToLookup(r => r.Parent);
foreach (var targetResource in targetResources)
{
await PublishUpdateToResourceTreeAsync(targetResource, parentChildLookup, state => state with
{
State = new(AzureProvisioningStrings.ResourceStateFailedToProvision, KnownResourceStateStyles.Error),
Properties = failureDetails.SetResourceProperties(state.Properties, AzureProvisioningFailureDetails.ProvisionOperation)
}).ConfigureAwait(false);
}
}
private async Task<bool> TryPublishTerminalDeploymentStateAsync(
(IResource Resource, IAzureResource AzureResource) resource,
ILookup<IResource, IResourceWithParent> parentChildLookup)
{
if (resource.AzureResource is not AzureBicepResource bicepResource)
{
return false;
}
if (TryGetPublishedTerminalDeploymentState(resource.Resource.Name, out var publishedTerminalState) ||
TryGetPublishedTerminalDeploymentState(bicepResource.Name, out publishedTerminalState))
{
// The provisioner already published an ARM-specific terminal state before throwing to
// stop normal provisioning. Do not replace that more precise state with generic failure,
// and make sure projected resources see the same terminal state as the Bicep resource.
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = publishedTerminalState
}).ConfigureAwait(false);
return true;
}
if (!IsResourceInState(resource.Resource.Name, CancelingState) &&
!IsResourceInState(bicepResource.Name, CancelingState))
{
return false;
}
var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{bicepResource.Name}").ConfigureAwait(false);
if (!string.Equals(
section.Data[BicepProvisioner.DeploymentStateProvisioningStateKey]?.GetValue<string>(),
BicepProvisioner.DeploymentStateProvisioningStateCanceled,
StringComparisons.AzureProvisioningState))
{
return false;
}
// ARM can win the cancellation race and report a canceled deployment as a non-cancellation
// failure from the SDK path. The cache is the source of truth for that terminal outcome.
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new(AzureProvisioningStrings.ResourceStateCanceled, KnownResourceStateStyles.Info)
}).ConfigureAwait(false);
return true;
}
private bool IsResourceInState(string resourceName, string state)
{
return notificationService.TryGetCurrentState(resourceName, out var currentEvent) &&
string.Equals(currentEvent.Snapshot.State?.Text, state, StringComparison.Ordinal);
}
private bool TryGetPublishedTerminalDeploymentState(string resourceName, out ResourceStateSnapshot state)
{
if (notificationService.TryGetCurrentState(resourceName, out var currentEvent) &&
currentEvent.Snapshot.State is { Text: { } stateText } currentState &&
(string.Equals(stateText, AzureProvisioningStrings.ResourceStateAzureDeploymentCanceled, StringComparison.Ordinal) ||
string.Equals(stateText, AzureProvisioningStrings.ResourceStateAzureDeploymentFailed, StringComparison.Ordinal)))
{
state = currentState;
return true;
}
state = default!;
return false;
}
private async Task<bool> WaitForRoleAssignmentsAsync(
(IResource Resource, IAzureResource AzureResource) resource,
ILookup<IResource, IResourceWithParent> parentChildLookup)
{
var rolesFailed = false;
if (resource.AzureResource.TryGetAnnotationsOfType<RoleAssignmentResourceAnnotation>(out var roleAssignments))
{
try
{
foreach (var roleAssignment in roleAssignments)
{
// A resource can depend on role assignments that are modeled as separate Azure
// resources. Wait for those provisioning TCSs before marking the main resource
// running so dependent app code does not race RBAC propagation.
await roleAssignment.RolesResource.ProvisioningTaskCompletionSource!.Task.ConfigureAwait(false);
}
}
catch (Exception)
{
rolesFailed = true;
await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with
{
State = new("Failed to Provision Roles", KnownResourceStateStyles.Error)
}).ConfigureAwait(false);
}
}
return rolesFailed;
}
private async Task ProvisionAzureResourcesAsync(
IReadOnlyList<(IResource Resource, IAzureResource AzureResource)> azureResources,
ILookup<IResource, IResourceWithParent> parentChildLookup,
CancellationToken cancellationToken)
{
// Share one provisioning context across the batch, but let each resource complete its own provisioning TCS so
// dependent resources can continue as soon as their prerequisites are ready.
// The Lazy prevents Azure context creation until at least one resource actually needs ARM
// provisioning; resources satisfied from existing configuration/user secrets can complete
// without forcing Azure authentication.
var provisioningContextLazy = new Lazy<Task<ProvisioningContext>>(() => provisioningContextProvider.CreateProvisioningContextAsync(cancellationToken));
var tasks = new List<Task>(azureResources.Count);
foreach (var resource in azureResources)
{
tasks.Add(ProcessResourceAsync(provisioningContextLazy, resource, parentChildLookup, cancellationToken));
}
var task = Task.WhenAll(tasks);
await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
private async Task ProcessResourceAsync(
Lazy<Task<ProvisioningContext>> provisioningContextLazy,
(IResource Resource, IAzureResource AzureResource) resource,
ILookup<IResource, IResourceWithParent> parentChildLookup,
CancellationToken cancellationToken)
{
// This method owns the lifecycle for a single Azure resource within a batch. It is also responsible for
// completing the per-resource TCS that dependency waits observe.
var resourceLogger = loggerService.GetLogger(resource.AzureResource);
try
{
var beforeResourceStartedEvent = new BeforeResourceStartedEvent(resource.Resource, serviceProvider);
await eventing.PublishAsync(beforeResourceStartedEvent, cancellationToken).ConfigureAwait(false);
if (resource.AzureResource is not AzureBicepResource bicepResource)
{
// Non-Bicep Azure resources are not deployed by this controller, but they may still
// appear in dependency graphs. Complete their TCS so downstream resources do not wait
// forever for a deployment that will never run here.
CompleteProvisioning(resource.AzureResource);
resourceLogger.LogInformation("Skipping {resourceName} because it is not a Bicep resource.", resource.AzureResource.Name);
return;
}
if (bicepResource.IsContainer() || bicepResource.IsEmulator())
{
// Local emulators/container-backed resources are represented by Azure resource types
// but are started by DCP/container orchestration, not ARM provisioning.
CompleteProvisioning(resource.AzureResource);
resourceLogger.LogInformation("Skipping {resourceName} because it is not configured to be provisioned.", resource.AzureResource.Name);
}
else
{
var executionContext = serviceProvider.GetRequiredService<DistributedApplicationExecutionContext>();
await WaitForProvisioningDependenciesAsync(bicepResource, executionContext, cancellationToken).ConfigureAwait(false);
if (await IsMissingCachedResourceAsync(bicepResource, cancellationToken).ConfigureAwait(false))
{
// Cached state can survive a user deleting the Azure resource outside Aspire.
// Clear it before ConfigureResourceAsync so the provisioner creates a fresh
// deployment rather than trusting outputs that point at a missing resource.
resourceLogger.LogWarning("Cached Azure deployment state for {resourceName} points to a missing Azure resource. Reprovisioning.", resource.AzureResource.Name);
await ClearCachedDeploymentStateAsync(
bicepResource,
preserveOverrides: true,
environmentLocation: (await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false)).Location,
currentLocationOverride: null,
preserveInferredLocationOverrides: true,
cancellationToken).ConfigureAwait(false);
}
if (await bicepProvisioner.ConfigureResourceAsync(bicepResource, cancellationToken).ConfigureAwait(false))
{
// ConfigureResourceAsync returns true when existing local configuration is enough
// to satisfy the resource. Complete the TCS and publish connection-string events
// without creating or touching ARM resources.
CompleteProvisioning(resource.AzureResource);
resourceLogger.LogInformation("Using connection information stored in user secrets for {resourceName}.", resource.AzureResource.Name);
await PublishConnectionStringAvailableEventAsync(resource.Resource, parentChildLookup, cancellationToken).ConfigureAwait(false);
}
else
{
if (resource.AzureResource.IsExisting())
{
resourceLogger.LogInformation("Resolving {resourceName} as existing resource...", resource.AzureResource.Name);
}
else
{
resourceLogger.LogInformation("Provisioning {resourceName}...", resource.AzureResource.Name);
}
var provisioningContext = await provisioningContextLazy.Value.ConfigureAwait(false);
if (!await bicepProvisioner.ReconcileDeploymentStateAsync(bicepResource, provisioningContext, cancellationToken).ConfigureAwait(false))
{
// The provisioner owns Bicep compilation, state persistence, and ARM operations.
// The controller is responsible for sequencing, cancellation, and publishing the
// resource lifecycle around this call.
await bicepProvisioner.GetOrCreateResourceAsync(
bicepResource,
provisioningContext,
cancellationToken).ConfigureAwait(false);
}
CompleteProvisioning(resource.AzureResource);
await PublishConnectionStringAvailableEventAsync(resource.Resource, parentChildLookup, cancellationToken).ConfigureAwait(false);
}
}
}
catch (AzureCliNotOnPathException ex)
{
resourceLogger.LogCritical("Using Azure resources during local development requires the installation of the Azure CLI. See https://aka.ms/dotnet/aspire/azcli for instructions.");
FailProvisioning(resource.AzureResource, ex);
}
catch (MissingConfigurationException ex)
{
resourceLogger.LogCritical("Resource could not be provisioned because Azure subscription, location, and resource group information is missing. See https://aka.ms/dotnet/aspire/azure/provisioning for more details.");
FailProvisioning(resource.AzureResource, ex);
}
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested || ex.CancellationToken == cancellationToken)
{
CancelProvisioning(resource.AzureResource, cancellationToken.IsCancellationRequested ? cancellationToken : ex.CancellationToken);
}
catch (Exception ex)
{
resourceLogger.LogError(ex, "Error provisioning {ResourceName}.", resource.AzureResource.Name);
FailProvisioning(resource.AzureResource, new InvalidOperationException($"Unable to provision {resource.AzureResource.Name}.", ex));
}
}
private static void CompleteProvisioning(IAzureResource resource)
{
resource.ProvisioningTaskCompletionSource?.TrySetResult();
}
private static void FailProvisioning(IAzureResource resource, Exception exception)
{
resource.ProvisioningTaskCompletionSource?.TrySetException(exception);
}
private static void CancelProvisioning(IAzureResource resource, CancellationToken cancellationToken)
{
resource.ProvisioningTaskCompletionSource?.TrySetCanceled(cancellationToken);
}
private static async Task WaitForProvisioningDependenciesAsync(
AzureBicepResource resource,
DistributedApplicationExecutionContext executionContext,
CancellationToken cancellationToken)
{
// Force template generation before dependency discovery. Some resources populate parameter
// values and references lazily while generating Bicep.
_ = resource.GetBicepTemplateString();
var dependencies = new HashSet<IAzureResource>();
var discoveredDependencies = await resource.GetResourceDependenciesAsync(
executionContext,
ResourceDependencyDiscoveryMode.Recursive,
cancellationToken).ConfigureAwait(false);
dependencies.UnionWith(discoveredDependencies.OfType<IAzureResource>());
foreach (var parameter in resource.Parameters.Values)
{
// Parameters and references can contain nested IValueWithReferences instances that are
// not visible from the app model graph. Include them so provisioning waits for resources
// referenced only through generated Bicep values.
CollectProvisioningDependencies(dependencies, parameter);
}
foreach (var reference in resource.References)
{
CollectProvisioningDependencies(dependencies, reference);
}
await Task.WhenAll(dependencies
.Where(dependency => !ReferenceEquals(dependency, resource))
.Select(dependency => dependency.ProvisioningTaskCompletionSource?.Task.WaitAsync(cancellationToken))
.OfType<Task>()).ConfigureAwait(false);
}
private static void CollectProvisioningDependencies(HashSet<IAzureResource> dependencies, object? value)
{
CollectProvisioningDependencies(dependencies, value, []);
}
private static void CollectProvisioningDependencies(HashSet<IAzureResource> dependencies, object? value, HashSet<object> visited)
{
if (value is null || !visited.Add(value))
{
// Values can reference each other. Track visited objects by reference to avoid infinite
// recursion while walking IValueWithReferences graphs.
return;
}
if (value is IAzureResource azureResource)
{
dependencies.Add(azureResource);
}
if (value is IValueWithReferences valueWithReferences)
{
foreach (var reference in valueWithReferences.References)
{
CollectProvisioningDependencies(dependencies, reference, visited);
}
}
}
private async Task PublishConnectionStringAvailableEventAsync(
IResource targetResource,
ILookup<IResource, IResourceWithParent> parentChildLookup,
CancellationToken cancellationToken)
{
if (targetResource is IResourceWithConnectionString)
{
// Connection string events unblock resources that resolve connection strings lazily after
// Azure outputs are available.
var connectionStringAvailableEvent = new ConnectionStringAvailableEvent(targetResource, serviceProvider);
await eventing.PublishAsync(connectionStringAvailableEvent, cancellationToken).ConfigureAwait(false);
}
if (parentChildLookup[targetResource] is { } children)
{
foreach (var child in children.OfType<IResourceWithConnectionString>().Where(static c => c is IResourceWithParent))
{
// Child resources such as databases often derive their connection strings from a
// parent Azure account. Recurse so children observe the same availability signal.
await PublishConnectionStringAvailableEventAsync(child, parentChildLookup, cancellationToken).ConfigureAwait(false);
}
}
}
private static ImmutableArray<ResourcePropertySnapshot> FilterProperties(ImmutableArray<ResourcePropertySnapshot> properties)
{
if (properties.IsDefaultOrEmpty)
{
return [];
}
return [.. properties.Where(static property =>
!s_resettableProperties.Contains(property.Name, StringComparer.Ordinal) &&
!AzureProvisioningFailureDetails.IsFailureProperty(property.Name))];
}
private async Task PublishAzureEnvironmentStateAsync(
DistributedApplicationModel model,
string state,
CancellationToken cancellationToken)
{
await PublishAzureEnvironmentStateAsync(
model,
new ResourceStateSnapshot(state, state == KnownResourceStates.NotStarted ? KnownResourceStateStyles.Info : KnownResourceStateStyles.Success),
cancellationToken).ConfigureAwait(false);
}
private async Task PublishAzureEnvironmentStateAsync(
DistributedApplicationModel model,
ResourceStateSnapshot state,
CancellationToken cancellationToken)
{
await PublishAzureEnvironmentStateAsync(
model,
state,
[],
cancellationToken).ConfigureAwait(false);
}
private async Task PublishAzureEnvironmentStateAsync(
DistributedApplicationModel model,
ResourceStateSnapshot state,
ImmutableArray<ResourcePropertySnapshot> additionalProperties,
CancellationToken cancellationToken)
{
if (model.Resources.OfType<AzureEnvironmentResource>().SingleOrDefault() is not { } azureEnvironmentResource)
{
return;
}
var azureEnvironmentProperties = state.Text == KnownResourceStates.NotStarted
? ImmutableArray<ResourcePropertySnapshot>.Empty
: BuildAzureEnvironmentProperties(await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false));
if (!additionalProperties.IsDefaultOrEmpty)
{
azureEnvironmentProperties = azureEnvironmentProperties.SetResourcePropertyRange(additionalProperties);
}
// NotStarted represents "no active Azure context for this run" after reset/delete/forget.
// Strip Azure-specific properties and URLs so the dashboard does not show stale subscription,
// resource group, or portal links from the previous context.
await notificationService.PublishUpdateAsync(azureEnvironmentResource, existingState => ApplyActiveOperationProperties(azureEnvironmentResource, existingState with
{
State = state,
Properties = state.Text == KnownResourceStates.NotStarted
? FilterProperties(existingState.Properties)
: FilterProperties(existingState.Properties).SetResourcePropertyRange(azureEnvironmentProperties),
Urls = state.Text == KnownResourceStates.NotStarted ? [] : existingState.Urls,
CreationTimeStamp = state.Text == KnownResourceStates.NotStarted ? null : existingState.CreationTimeStamp,
StartTimeStamp = state.Text == KnownResourceStates.NotStarted ? null : existingState.StartTimeStamp,
StopTimeStamp = state.Text == KnownResourceStates.NotStarted ? null : existingState.StopTimeStamp
})).ConfigureAwait(false);
if (state.Text == KnownResourceStates.NotStarted)
{
loggerService.GetLogger(azureEnvironmentResource).LogInformation("Azure provisioning state has been reset.");
}
}
private static ImmutableArray<ResourcePropertySnapshot> BuildAzureEnvironmentProperties(AzureContextState context)
{
return AzureResourceProperties.CreateContextProperties(
context.SubscriptionId,
context.ResourceGroup,
context.TenantId,
context.TenantDomain,
context.Location);
}
private sealed class AzureOperationState(string displayName, bool isAllResources, IEnumerable<string> resourceNames)
{
// Read under _operationStateLock on the hot command-state path, so it holds only what command
// enablement needs: target resource names and whether the operation affects all resources.
public string DisplayName { get; } = displayName;
public bool IsAllResources { get; } = isAllResources;
public IReadOnlySet<string> ResourceNames { get; } = new HashSet<string>(resourceNames, StringComparers.ResourceName);
public bool IsNone => !IsAllResources && ResourceNames.Count == 0;
public static AzureOperationState None { get; } = new(string.Empty, false, []);
public static AzureOperationState All(string displayName) => new(displayName, true, []);
public static AzureOperationState Resource(string resourceName, string displayName) => new(displayName, false, [resourceName]);
public static AzureOperationState Resources(IReadOnlySet<string> resourceNames, string displayName) => new(displayName, false, resourceNames);
}
private sealed class ActiveAzureOperation : IDisposable
{
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly object _lock = new();
private string _phase;
private string _status;
private ActiveAzureOperation(AzureIntent intent, AzureOperationState operation, DateTimeOffset startedAt, CancellationTokenSource cancellationTokenSource)
{
Intent = intent;
Operation = operation;
StartedAt = startedAt;
_cancellationTokenSource = cancellationTokenSource;
_phase = operation.DisplayName;
_status = KnownResourceStates.Running;
}
public AzureIntent Intent { get; }
public AzureOperationState Operation { get; }
public DateTimeOffset StartedAt { get; }
public CancellationToken CancellationToken => _cancellationTokenSource.Token;
public bool CancellationRequested => _cancellationTokenSource.IsCancellationRequested;
public string? TargetLocation => Intent is ChangeResourceLocationIntent changeLocation ? changeLocation.Location : null;
public static ActiveAzureOperation Create(AzureIntent intent, AzureOperationState operation, DateTimeOffset startedAt, CancellationToken cancellationToken)
=> new(intent, operation, startedAt, CancellationTokenSource.CreateLinkedTokenSource(cancellationToken));
public void SetPhase(string phase, string? status = null)
{
lock (_lock)
{
_phase = phase;
if (status is not null)
{
_status = status;
}
}
}
public ActiveAzureOperationSnapshot CreateSnapshot()
{
lock (_lock)
{
return new ActiveAzureOperationSnapshot(
Operation.DisplayName,
_phase,
_status,
TargetLocation,
StartedAt,
Operation.IsAllResources,
Operation.ResourceNames,
CancellationRequested);
}
}
public void Cancel()
{
lock (_lock)
{
_status = AzureProvisioningStrings.ResourceStateCanceling;
}
_cancellationTokenSource.Cancel();
}
public void Dispose()
{
_cancellationTokenSource.Dispose();
}
}
private sealed record ActiveAzureOperationSnapshot(
string DisplayName,
string Phase,
string Status,
string? TargetLocation,
DateTimeOffset StartedAt,
bool IsAllResources,
IReadOnlySet<string> ResourceNames,
bool CancellationRequested)
{
public bool AffectsResource(string resourceName) => IsAllResources || ResourceNames.Contains(resourceName);
}
private sealed record AzureControllerState(AzureControllerStatus Status)
{
public static AzureControllerState Empty { get; } = new(new AzureControllerStatus(null, null));
}
private sealed record AzureControllerStatus(AzureIntent? CurrentIntent, ActiveAzureOperation? ActiveOperation);
private sealed record DeleteAzureResourceResult(IReadOnlyList<string> ResourceIds);
internal enum AzureEnvironmentCommand
{
ResetProvisioningState,
ChangeAzureContext,
ReprovisionAll,
DeleteAzureResources
}
internal enum AzureResourceCommand
{
ChangeLocation,
GetAzureResource,
Cancel,
DeleteAzureResource,
ForgetState,
Reprovision
}
internal sealed record EnvironmentCommandDefinition(
AzureEnvironmentCommand Command,
string Name,
string DisplayName,
string Description,
string ConfirmationMessage,
string IconName,
IconVariant IconVariant,
bool IsHighlighted,
Func<AzureProvisioningController, ExecuteCommandContext, Task<ExecuteCommandResult>> ExecuteCommand,
IReadOnlyList<InteractionInput>? Arguments = null,
Func<InputsDialogValidationContext, Task>? ValidateArguments = null);
internal sealed record ResourceCommandDefinition(
AzureResourceCommand Command,
string Name,
string DisplayName,
string Description,
string? ConfirmationMessage,
string IconName,
IconVariant IconVariant,
bool IsHighlighted,
Func<AzureProvisioningController, string, ExecuteCommandContext, Task<ExecuteCommandResult>> ExecuteCommand,
IReadOnlyList<InteractionInput>? Arguments = null,
Func<InputsDialogValidationContext, Task>? ValidateArguments = null);
// Intents are the messages consumed by the single-reader operation loop. Keeping them as records
// makes each queued operation self-describing for command-state calculation and execution.
private abstract record AzureIntent(AzureOperationState Operation);
private sealed record ResetStateIntent(bool ReprovisionAfterReset) : AzureIntent(AzureOperationState.All("Reset provisioning state"));
private sealed record ChangeAzureContextIntent(AzureProvisioningOptionsUpdate? Options) : AzureIntent(AzureOperationState.All("Change Azure context"));
private sealed record ApplyAzureContextIntent() : AzureIntent(AzureOperationState.All("Configure Azure context"));
private sealed record EnsureProvisionedIntent() : AzureIntent(AzureOperationState.All("Provision Azure resources"));
private sealed record ReprovisionAllIntent() : AzureIntent(AzureOperationState.All("Reprovision all Azure resources"));
private sealed record DeleteAzureResourcesIntent() : AzureIntent(AzureOperationState.All("Delete Azure resources"));
private sealed record ForgetResourceStateIntent(string ResourceName) : AzureIntent(AzureOperationState.Resource(ResourceName, "Reset provisioning state"));
private sealed record ChangeResourceLocationIntent(string ResourceName, string Location, bool ConfirmDelete) : AzureIntent(AzureOperationState.Resource(ResourceName, "Change Azure resource location"));
private sealed record LocationChangeResourceDeletion(IArmClient ArmClient, string ResourceId, string CurrentLocation);
private sealed record ReprovisionResourceIntent(string ResourceName) : AzureIntent(AzureOperationState.Resource(ResourceName, "Reprovision Azure resource"));
private sealed record DeleteAzureResourceIntent(string ResourceName) : AzureIntent(AzureOperationState.Resource(ResourceName, "Delete Azure resource"));
// Drift checks use AzureOperationState.None so they serialize with commands without disabling
// dashboard command buttons.
private sealed record DetectDriftIntent() : AzureIntent(AzureOperationState.None);
private sealed record QueuedOperation(
DistributedApplicationModel Model,
AzureIntent Intent,
TaskCompletionSource<object?> Completion,
CancellationToken CancellationToken);
internal sealed class QueuedOperationForTesting
{
internal object Operation { get; }
internal QueuedOperationForTesting(object operation)
{
Operation = operation;
}
}
private sealed record AzureContextState(string? SubscriptionId, string? ResourceGroup, string? Location, string? TenantId, string? TenantDomain);
}